diff --git a/src/js/tracing/nativeframes.ts b/src/js/tracing/nativeframes.ts index 80fac1428c..84eab70ec5 100644 --- a/src/js/tracing/nativeframes.ts +++ b/src/js/tracing/nativeframes.ts @@ -12,6 +12,12 @@ export interface FramesMeasurements extends Measurements { frames_frozen: { value: number; unit: MeasurementUnit }; } +/** The listeners for each native frames response, keyed by traceId. This must be global to avoid closure issues and reading outdated values. */ +const _framesListeners: Map void> = new Map(); + +/** The native frames at the transaction finish time, keyed by traceId. This must be global to avoid closure issues and reading outdated values. */ +const _finishFrames: Map = new Map(); + /** * A margin of error of 50ms is allowed for the async native bridge call. * Anything larger would reduce the accuracy of our frames measurements. @@ -22,10 +28,6 @@ const MARGIN_OF_ERROR_SECONDS = 0.05; * Instrumentation to add native slow/frozen frames measurements onto transactions. */ export class NativeFramesInstrumentation { - /** The native frames at the transaction finish time, keyed by traceId. */ - private _finishFrames: Map = new Map(); - /** The listeners for each native frames response, keyed by traceId */ - private _framesListeners: Map void> = new Map(); /** The native frames at the finish time of the most recent span. */ private _lastSpanFinishFrames?: { timestamp: number; @@ -98,22 +100,22 @@ export class NativeFramesInstrumentation { finalEndTimestamp: number, startFrames: NativeFramesResponse, ): Promise { - if (this._finishFrames.has(traceId)) { + if (_finishFrames.has(traceId)) { return this._prepareMeasurements(traceId, finalEndTimestamp, startFrames); } return new Promise(resolve => { const timeout = setTimeout(() => { - this._framesListeners.delete(traceId); + _framesListeners.delete(traceId); resolve(null); }, 2000); - this._framesListeners.set(traceId, () => { + _framesListeners.set(traceId, () => { resolve(this._prepareMeasurements(traceId, finalEndTimestamp, startFrames)); clearTimeout(timeout); - this._framesListeners.delete(traceId); + _framesListeners.delete(traceId); }); }); } @@ -128,7 +130,7 @@ export class NativeFramesInstrumentation { ): FramesMeasurements | null { let finalFinishFrames: NativeFramesResponse | undefined; - const finish = this._finishFrames.get(traceId); + const finish = _finishFrames.get(traceId); if ( finish && finish.nativeFrames && @@ -178,12 +180,12 @@ export class NativeFramesInstrumentation { finishFrames = await NATIVE.fetchNativeFrames(); } - this._finishFrames.set(transaction.traceId, { + _finishFrames.set(transaction.traceId, { nativeFrames: finishFrames, timestamp, }); - this._framesListeners.get(transaction.traceId)?.(); + _framesListeners.get(transaction.traceId)?.(); setTimeout(() => this._cancelFinishFrames(transaction), 2000); } @@ -192,8 +194,8 @@ export class NativeFramesInstrumentation { * On a finish frames failure, we cancel the await. */ private _cancelFinishFrames(transaction: Transaction): void { - if (this._finishFrames.has(transaction.traceId)) { - this._finishFrames.delete(transaction.traceId); + if (_finishFrames.has(transaction.traceId)) { + _finishFrames.delete(transaction.traceId); logger.log( `[NativeFrames] Native frames timed out for ${transaction.op} transaction ${transaction.name}. Not adding native frames measurements.`, @@ -243,7 +245,7 @@ export class NativeFramesInstrumentation { ...measurements, }; - this._finishFrames.delete(traceId); + _finishFrames.delete(traceId); } delete traceContext.data.__startFrames; diff --git a/test/mocks/client.ts b/test/mocks/client.ts index 2008f288af..70864fe1f3 100644 --- a/test/mocks/client.ts +++ b/test/mocks/client.ts @@ -1,4 +1,12 @@ -import { BaseClient, createTransport, initAndBind } from '@sentry/core'; +import { + BaseClient, + createTransport, + getCurrentScope, + getGlobalScope, + getIsolationScope, + initAndBind, + setCurrentClient, +} from '@sentry/core'; import type { ClientOptions, Event, @@ -11,6 +19,8 @@ import type { } from '@sentry/types'; import { resolvedSyncPromise } from '@sentry/utils'; +import { _addTracingExtensions } from '../../src/js/tracing/addTracingExtensions'; + export function getDefaultTestClientOptions(options: Partial = {}): TestClientOptions { return { dsn: 'https://1234@some-domain.com/4505526893805568', @@ -103,3 +113,17 @@ export class TestClient extends BaseClient { export function init(options: TestClientOptions): void { initAndBind(TestClient, options); } + +export function setupTestClient(options: Partial = {}): TestClient { + _addTracingExtensions(); + + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + const finalOptions = getDefaultTestClientOptions({ tracesSampleRate: 1.0, ...options }); + const client = new TestClient(finalOptions); + setCurrentClient(client); + client.init(); + return client; +} diff --git a/test/tracing/addTracingExtensions.test.ts b/test/tracing/addTracingExtensions.test.ts index 36dc5c21ac..849260af55 100644 --- a/test/tracing/addTracingExtensions.test.ts +++ b/test/tracing/addTracingExtensions.test.ts @@ -1,58 +1,79 @@ -import type { Carrier, Transaction } from '@sentry/core'; -import { getCurrentHub, getMainCarrier } from '@sentry/core'; -import type { Hub } from '@sentry/types'; +import { getCurrentScope, spanToJSON, startSpanManual } from '@sentry/core'; -import type { StartTransactionFunction } from '../../src/js/tracing/addTracingExtensions'; -import { _addTracingExtensions } from '../../src/js/tracing/addTracingExtensions'; +import { ReactNativeTracing } from '../../src/js'; +import { type TestClient, setupTestClient } from '../mocks/client'; describe('Tracing extensions', () => { - let hub: Hub; - let carrier: Carrier; - let startTransaction: StartTransactionFunction | undefined; + let client: TestClient; beforeEach(() => { - _addTracingExtensions(); - hub = getCurrentHub(); - carrier = getMainCarrier(); - startTransaction = carrier.__SENTRY__?.extensions?.startTransaction as StartTransactionFunction | undefined; + client = setupTestClient({ + integrations: [new ReactNativeTracing()], + }); }); test('transaction has default op', async () => { - const transaction: Transaction = startTransaction?.apply(hub, [{}]); + const transaction = startSpanManual({ name: 'parent' }, span => span); - expect(transaction).toEqual(expect.objectContaining({ op: 'default' })); + expect(spanToJSON(transaction!)).toEqual( + expect.objectContaining({ + op: 'default', + }), + ); }); test('transaction does not overwrite custom op', async () => { - const transaction: Transaction = startTransaction?.apply(hub, [{ op: 'custom' }]); + const transaction = startSpanManual({ name: 'parent', op: 'custom' }, span => span); - expect(transaction).toEqual(expect.objectContaining({ op: 'custom' })); + expect(spanToJSON(transaction!)).toEqual( + expect.objectContaining({ + op: 'custom', + }), + ); }); test('transaction start span creates default op', async () => { - const transaction: Transaction = startTransaction?.apply(hub, [{ op: 'custom' }]); - const span = transaction?.startChild(); + // TODO: add event listener to spanStart and add default op if not set + startSpanManual({ name: 'parent', scope: getCurrentScope() }, () => {}); + const span = startSpanManual({ name: 'child', scope: getCurrentScope() }, span => span); - expect(span).toEqual(expect.objectContaining({ op: 'default' })); + expect(spanToJSON(span!)).toEqual( + expect.objectContaining({ + op: 'default', + }), + ); }); test('transaction start span keeps custom op', async () => { - const transaction: Transaction = startTransaction?.apply(hub, [{ op: 'custom' }]); - const span = transaction?.startChild({ op: 'custom' }); + startSpanManual({ name: 'parent', op: 'custom', scope: getCurrentScope() }, () => {}); + const span = startSpanManual({ name: 'child', op: 'custom', scope: getCurrentScope() }, span => span); - expect(span).toEqual(expect.objectContaining({ op: 'custom' })); + expect(spanToJSON(span!)).toEqual( + expect.objectContaining({ + op: 'custom', + }), + ); }); test('transaction start span passes correct values to the child', async () => { - const transaction: Transaction = startTransaction?.apply(hub, [{ op: 'custom' }]); - const span = transaction?.startChild({ op: 'custom' }); + const transaction = startSpanManual({ name: 'parent', op: 'custom', scope: getCurrentScope() }, span => span); + const span = startSpanManual({ name: 'child', scope: getCurrentScope() }, span => span); + span!.end(); + transaction!.end(); - expect(span).toEqual( + await client.flush(); + expect(client.event).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + trace_id: transaction!.spanContext().traceId, + }), + }), + }), + ); + expect(spanToJSON(span!)).toEqual( expect.objectContaining({ - transaction, - parentSpanId: transaction.spanId, - sampled: transaction.sampled, - traceId: transaction.traceId, + parent_span_id: transaction!.spanContext().spanId, }), ); }); diff --git a/test/tracing/gesturetracing.test.ts b/test/tracing/gesturetracing.test.ts index 5b62181dd9..e939b25945 100644 --- a/test/tracing/gesturetracing.test.ts +++ b/test/tracing/gesturetracing.test.ts @@ -1,8 +1,5 @@ -import { BrowserClient } from '@sentry/browser'; -import type { BrowserClientOptions } from '@sentry/browser/types/client'; -import { Hub } from '@sentry/core'; -import type { IntegrationIndex } from '@sentry/core/types/integration'; -import type { Breadcrumb, Scope, Transaction, User } from '@sentry/types'; +import { addGlobalEventProcessor, getActiveSpan, getCurrentHub, spanToJSON, startSpan } from '@sentry/core'; +import type { Breadcrumb } from '@sentry/types'; import { UI_ACTION } from '../../src/js/tracing'; import { @@ -11,11 +8,9 @@ import { sentryTraceGesture, } from '../../src/js/tracing/gesturetracing'; import { ReactNativeTracing } from '../../src/js/tracing/reactnativetracing'; +import { type TestClient, setupTestClient } from '../mocks/client'; import type { MockedRoutingInstrumentation } from './mockedrountinginstrumention'; -import { - createMockedRoutingInstrumentation, - mockedConfirmedRouteTransactionContext, -} from './mockedrountinginstrumention'; +import { createMockedRoutingInstrumentation } from './mockedrountinginstrumention'; jest.mock('../../src/js/wrapper', () => { return { @@ -28,47 +23,6 @@ jest.mock('../../src/js/wrapper', () => { }; }); -const getMockScope = () => { - let scopeTransaction: unknown; - let scopeUser: User | undefined; - - return { - getTransaction: () => scopeTransaction, - setSpan: jest.fn((span: unknown) => { - scopeTransaction = span; - }), - setTag(_tag: unknown) { - // Placeholder - }, - setContext(_context: unknown) { - // Placeholder - }, - addBreadcrumb(_breadcrumb: unknown) { - // Placeholder - }, - getUser: () => scopeUser, - captureEvent(_event: unknown) { - // Placeholder - }, - }; -}; - -const mockAddBreadcrumb = jest.fn(); - -const getMockHub = () => { - const mockHub = new Hub(new BrowserClient({ tracesSampleRate: 1 } as BrowserClientOptions)); - const mockScope = getMockScope(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mockHub.getScope = () => mockScope as any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mockHub.configureScope = jest.fn(callback => callback(mockScope as any)); - - mockHub.addBreadcrumb = mockAddBreadcrumb; - - return mockHub; -}; - interface MockGesture { handlers?: { onBegin?: jest.Mock; @@ -93,8 +47,7 @@ describe('GestureTracing', () => { }); describe('traces gestures', () => { - let mockedScope: Scope; - let mockedHub: Hub; + let client: TestClient; let tracing: ReactNativeTracing; let mockedRoutingInstrumentation: MockedRoutingInstrumentation; let mockedGesture: MockGesture; @@ -102,18 +55,22 @@ describe('GestureTracing', () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); - mockedHub = getMockHub(); - mockedScope = mockedHub.getScope()!; + client = setupTestClient(); mockedRoutingInstrumentation = createMockedRoutingInstrumentation(); tracing = new ReactNativeTracing({ routingInstrumentation: mockedRoutingInstrumentation, enableUserInteractionTracing: true, }); - tracing.setupOnce(jest.fn(), jest.fn().mockReturnValue(mockedHub)); - // client.addIntegration uses global getCurrentHub, so we don't use it to keep the mockedHub - (mockedHub.getClient() as unknown as { _integrations: IntegrationIndex })._integrations[ReactNativeTracing.name] = - tracing; - mockedRoutingInstrumentation.registeredOnConfirmRoute!(mockedConfirmedRouteTransactionContext); + client.addIntegration(tracing); + tracing.setupOnce(addGlobalEventProcessor, getCurrentHub); + mockedRoutingInstrumentation.registeredOnConfirmRoute!({ + name: 'mockedScreenName', + data: { + route: { + name: 'mockedScreenName', + }, + }, + }); mockedGesture = { handlers: { onBegin: jest.fn(), @@ -128,16 +85,16 @@ describe('GestureTracing', () => { jest.useRealTimers(); }); - it('gesture creates interaction transaction', () => { - sentryTraceGesture('mockedGesture', mockedGesture, { getCurrentHub: () => mockedHub }); + it('gesture creates interaction transaction', async () => { + sentryTraceGesture('mockedGesture', mockedGesture); mockedGesture.handlers!.onBegin!(); - const transaction = mockedScope.getTransaction() as Transaction | undefined; + const transaction = getActiveSpan(); jest.runAllTimers(); - const transactionContext = transaction?.toContext(); - expect(transactionContext).toEqual( + expect(transaction).toBeDefined(); + expect(spanToJSON(transaction!)).toEqual( expect.objectContaining({ - endTimestamp: expect.any(Number), + timestamp: expect.any(Number), op: `${UI_ACTION}.mock`, }), ); @@ -145,56 +102,58 @@ describe('GestureTracing', () => { it('gesture interaction transaction falls back on invalid handler name', () => { mockedGesture.handlerName = 'Invalid'; - sentryTraceGesture('mockedGesture', mockedGesture, { getCurrentHub: () => mockedHub }); + sentryTraceGesture('mockedGesture', mockedGesture); mockedGesture.handlers!.onBegin!(); - const transaction = mockedScope.getTransaction() as Transaction | undefined; + const transaction = getActiveSpan(); jest.runAllTimers(); - const transactionContext = transaction?.toContext(); - expect(transactionContext).toEqual( + expect(transaction).toBeDefined(); + expect(spanToJSON(transaction!)).toEqual( expect.objectContaining({ - endTimestamp: expect.any(Number), + timestamp: expect.any(Number), op: `${UI_ACTION}.gesture`, }), ); }); - it('gesture cancel previous interaction transaction', () => { + it('gesture cancel previous interaction transaction', async () => { const timeoutCloseToActualIdleTimeoutMs = 800; - sentryTraceGesture('mockedGesture', mockedGesture, { getCurrentHub: () => mockedHub }); + sentryTraceGesture('mockedGesture', mockedGesture); const mockedTouchInteractionId = { elementId: 'mockedElementId', op: 'mocked.op' }; tracing.startUserInteractionTransaction(mockedTouchInteractionId); - const touchTransaction = mockedScope.getTransaction() as Transaction | undefined; - touchTransaction?.startChild({ op: 'child.op' }).finish(); - jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); + startChildSpan(); + await jest.advanceTimersByTimeAsync(timeoutCloseToActualIdleTimeoutMs); mockedGesture.handlers?.onBegin?.(); + startChildSpan(); - const gestureTransaction = mockedScope.getTransaction() as Transaction | undefined; - jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); - jest.runAllTimers(); + await jest.advanceTimersByTimeAsync(timeoutCloseToActualIdleTimeoutMs); + await jest.runAllTimersAsync(); - const touchTransactionContext = touchTransaction?.toContext(); - const gestureTransactionContext = gestureTransaction?.toContext(); - expect(touchTransactionContext).toEqual( + const touchTransactionEvent = client.eventQueue[0]; + const gestureTransactionEvent = client.eventQueue[1]; + expect(touchTransactionEvent).toEqual( expect.objectContaining({ - endTimestamp: expect.any(Number), - op: 'mocked.op', - sampled: true, + timestamp: expect.any(Number), + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'mocked.op', + }), + }), }), ); - expect(gestureTransactionContext).toEqual( + expect(gestureTransactionEvent).toEqual( expect.objectContaining({ - endTimestamp: expect.any(Number), + timestamp: expect.any(Number), }), ); }); it('gesture original on begin handler is called', () => { const original = mockedGesture.handlers?.onBegin; - sentryTraceGesture('mockedGesture', mockedGesture, { getCurrentHub: () => mockedHub }); + sentryTraceGesture('mockedGesture', mockedGesture); mockedGesture.handlers!.onBegin!(); jest.runAllTimers(); @@ -203,7 +162,7 @@ describe('GestureTracing', () => { it('creates gesture on begin handled if non exists', () => { delete mockedGesture.handlers?.onBegin; - sentryTraceGesture('mockedGesture', mockedGesture, { getCurrentHub: () => mockedHub }); + sentryTraceGesture('mockedGesture', mockedGesture); mockedGesture.handlers!.onBegin!(); jest.runAllTimers(); @@ -212,7 +171,7 @@ describe('GestureTracing', () => { it('gesture original on end handler is called', () => { const original = mockedGesture.handlers?.onEnd; - sentryTraceGesture('mockedGesture', mockedGesture, { getCurrentHub: () => mockedHub }); + sentryTraceGesture('mockedGesture', mockedGesture); mockedGesture.handlers!.onEnd!(); jest.runAllTimers(); @@ -221,7 +180,7 @@ describe('GestureTracing', () => { it('creates gesture on end handled if non exists', () => { delete mockedGesture.handlers?.onEnd; - sentryTraceGesture('mockedGesture', mockedGesture, { getCurrentHub: () => mockedHub }); + sentryTraceGesture('mockedGesture', mockedGesture); mockedGesture.handlers!.onEnd!(); jest.runAllTimers(); @@ -230,57 +189,76 @@ describe('GestureTracing', () => { it('creates gesture on begin handled if non exists', () => { delete mockedGesture.handlers?.onBegin; - sentryTraceGesture('mockedGesture', mockedGesture, { getCurrentHub: () => mockedHub }); + sentryTraceGesture('mockedGesture', mockedGesture); mockedGesture.handlers!.onBegin!(); jest.runAllTimers(); expect(mockedGesture.handlers?.onBegin).toBeDefined(); }); - it('wrapped gesture creates breadcrumb on begin', () => { - sentryTraceGesture('mockedGesture', mockedGesture, { getCurrentHub: () => mockedHub }); + it('wrapped gesture creates breadcrumb on begin', async () => { + sentryTraceGesture('mockedGesture', mockedGesture); mockedGesture.handlers!.onBegin!(); - jest.runAllTimers(); + startChildSpan(); - expect(mockAddBreadcrumb).toHaveBeenCalledTimes(1); - expect(mockAddBreadcrumb).toHaveBeenCalledWith( - expect.objectContaining({ - category: DEFAULT_GESTURE_BREADCRUMB_CATEGORY, - type: DEFAULT_GESTURE_BREADCRUMB_TYPE, - level: 'info', + await jest.runAllTimersAsync(); + + expect(client.event).toEqual( + expect.objectContaining({ + breadcrumbs: expect.arrayContaining([ + expect.objectContaining({ + category: DEFAULT_GESTURE_BREADCRUMB_CATEGORY, + type: DEFAULT_GESTURE_BREADCRUMB_TYPE, + level: 'info', + }), + ]), }), ); }); - it('wrapped gesture creates breadcrumb on end', () => { - sentryTraceGesture('mockedGesture', mockedGesture, { getCurrentHub: () => mockedHub }); + it('wrapped gesture creates breadcrumb on end', async () => { + sentryTraceGesture('mockedGesture', mockedGesture); mockedGesture.handlers!.onEnd!(); - jest.runAllTimers(); + startChildSpan(); + + await jest.runAllTimersAsync(); - expect(mockAddBreadcrumb).toHaveBeenCalledTimes(1); - expect(mockAddBreadcrumb).toHaveBeenCalledWith( - expect.objectContaining({ - category: DEFAULT_GESTURE_BREADCRUMB_CATEGORY, - type: DEFAULT_GESTURE_BREADCRUMB_TYPE, - level: 'info', + expect(client.event).toEqual( + expect.objectContaining({ + breadcrumbs: expect.arrayContaining([ + expect.objectContaining({ + category: DEFAULT_GESTURE_BREADCRUMB_CATEGORY, + type: DEFAULT_GESTURE_BREADCRUMB_TYPE, + level: 'info', + }), + ]), }), ); }); - it('wrapped gesture creates breadcrumb only with selected event keys', () => { - sentryTraceGesture('mockedGesture', mockedGesture, { getCurrentHub: () => mockedHub }); + it('wrapped gesture creates breadcrumb only with selected event keys', async () => { + sentryTraceGesture('mockedGesture', mockedGesture); mockedGesture.handlers!.onBegin!({ notSelectedKey: 'notSelectedValue', scale: 1 }); - jest.runAllTimers(); + startChildSpan(); - expect(mockAddBreadcrumb).toHaveBeenCalledTimes(1); - expect(mockAddBreadcrumb).toHaveBeenCalledWith( - expect.objectContaining({ - data: { - scale: 1, - gesture: 'mock', - }, + await jest.runAllTimersAsync(); + + expect(client.event).toEqual( + expect.objectContaining({ + breadcrumbs: expect.arrayContaining([ + expect.objectContaining({ + data: { + scale: 1, + gesture: 'mock', + }, + }), + ]), }), ); }); }); }); + +function startChildSpan() { + startSpan({ name: 'child', op: 'child.op' }, () => {}); +} diff --git a/test/tracing/mockedrountinginstrumention.ts b/test/tracing/mockedrountinginstrumention.ts index a3797e5030..53f0d68f74 100644 --- a/test/tracing/mockedrountinginstrumention.ts +++ b/test/tracing/mockedrountinginstrumention.ts @@ -22,12 +22,3 @@ export const createMockedRoutingInstrumentation = (): MockedRoutingInstrumentati }; return mock; }; - -export const mockedConfirmedRouteTransactionContext = { - name: 'mockedRouteName', - data: { - route: { - name: 'mockedRouteName', - }, - }, -}; diff --git a/test/tracing/nativeframes.test.ts b/test/tracing/nativeframes.test.ts index c26ca4e365..821e92d94e 100644 --- a/test/tracing/nativeframes.test.ts +++ b/test/tracing/nativeframes.test.ts @@ -1,49 +1,64 @@ -import { Transaction } from '@sentry/core'; -import type { EventProcessor } from '@sentry/types'; - -import { NativeFramesInstrumentation } from '../../src/js/tracing/nativeframes'; +import { + addGlobalEventProcessor, + getCurrentHub, + getCurrentScope, + getGlobalScope, + getIsolationScope, + setCurrentClient, + startSpan, +} from '@sentry/core'; +import type { Event, Measurements } from '@sentry/types'; + +import { ReactNativeTracing } from '../../src/js'; +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: { - fetchNativeFrames: jest.fn(), + fetchNativeFrames: jest.fn().mockResolvedValue(null), disableNativeFramesTracking: jest.fn(), enableNative: true, + enableNativeFramesTracking: jest.fn(), }, }; }); -describe('NativeFramesInstrumentation', () => { - it('Sets start frames to trace context on transaction start.', done => { - const startFrames = { - totalFrames: 100, - slowFrames: 20, - frozenFrames: 5, - }; - mockFunction(NATIVE.fetchNativeFrames).mockResolvedValue(startFrames); - - const instance = new NativeFramesInstrumentation( - // eslint-disable-next-line @typescript-eslint/no-empty-function - _eventProcessor => {}, - () => true, - ); - - const transaction = new Transaction({ name: 'test' }); +jest.useFakeTimers({ advanceTimers: true }); - instance.onTransactionStart(transaction); - - setImmediate(() => { - expect(transaction.data.__startFrames).toMatchObject(startFrames); +describe('NativeFramesInstrumentation', () => { + let client: TestClient; - expect(transaction.getTraceContext().data?.__startFrames).toMatchObject(startFrames); + beforeEach(() => { + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + RN_GLOBAL_OBJ.__SENTRY__.globalEventProcessors = []; // resets integrations - done(); + const integration = new ReactNativeTracing({ + enableNativeFramesTracking: true, + }); + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1.0, + integrations: [integration], }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + addGlobalEventProcessor(async event => { + await wait(10); + return event; + }); + integration.setupOnce(addGlobalEventProcessor, getCurrentHub); + }); + + afterEach(() => { + jest.clearAllMocks(); }); - it('Sets measurements on the transaction event and removes startFrames from trace context.', done => { + it('sets native frames measurements on a transaction event', async () => { const startFrames = { totalFrames: 100, slowFrames: 20, @@ -54,294 +69,124 @@ describe('NativeFramesInstrumentation', () => { slowFrames: 40, frozenFrames: 10, }; - mockFunction(NATIVE.fetchNativeFrames).mockResolvedValue(startFrames); - - let eventProcessor: EventProcessor; - const instance = new NativeFramesInstrumentation( - // eslint-disable-next-line @typescript-eslint/no-empty-function - _eventProcessor => { - eventProcessor = _eventProcessor; - }, - () => true, - ); - - const transaction = new Transaction({ name: 'test' }); - - instance.onTransactionStart(transaction); + mockFunction(NATIVE.fetchNativeFrames).mockResolvedValueOnce(startFrames).mockResolvedValueOnce(finishFrames); - setImmediate(() => { - mockFunction(NATIVE.fetchNativeFrames).mockResolvedValue(finishFrames); - - const finishTimestamp = Date.now() / 1000; - instance.onTransactionFinish(transaction); - - setImmediate(async () => { - try { - expect(eventProcessor).toBeDefined(); - if (eventProcessor) { - const event = await eventProcessor( - { - event_id: '0', - type: 'transaction', - transaction: transaction.name, - contexts: { - trace: transaction.getTraceContext(), - }, - start_timestamp: finishTimestamp - 10, - timestamp: finishTimestamp, - }, - {}, - ); - - // This setImmediate needs to be here for the assertions to not be caught by the promise handler. - - expect(event).toBeDefined(); - - if (event) { - expect(event.measurements).toBeDefined(); - - if (event.measurements) { - expect(event.measurements.frames_total.value).toBe(finishFrames.totalFrames - startFrames.totalFrames); - expect(event.measurements.frames_total.unit).toBe('none'); - - expect(event.measurements.frames_slow.value).toBe(finishFrames.slowFrames - startFrames.slowFrames); - expect(event.measurements.frames_slow.unit).toBe('none'); - - expect(event.measurements.frames_frozen.value).toBe( - finishFrames.frozenFrames - startFrames.frozenFrames, - ); - expect(event.measurements.frames_frozen.unit).toBe('none'); - } - - expect(event.contexts?.trace?.data).toBeDefined(); - - if (event.contexts?.trace?.data) { - expect( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (event.contexts.trace.data as any).__startFrames, - ).toBeUndefined(); - } - } - } - done(); - } catch (e) { - done(e); - } - }); + await startSpan({ name: 'test' }, async () => { + await Promise.resolve(); // native frames fetch is async call this will flush the start frames fetch promise }); + + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expect(client.event!).toEqual( + expect.objectContaining>({ + measurements: expect.objectContaining({ + frames_total: { + value: 100, + unit: 'none', + }, + frames_slow: { + value: 20, + unit: 'none', + }, + frames_frozen: { + value: 5, + unit: 'none', + }, + }), + }), + ); }); - it('Does not set measurements on transactions without startFrames.', done => { + it('does not set measurements on transactions without startFrames', async () => { + const startFrames = null; const finishFrames = { totalFrames: 200, slowFrames: 40, frozenFrames: 10, }; - mockFunction(NATIVE.fetchNativeFrames).mockResolvedValue(finishFrames); - - let eventProcessor: EventProcessor; - const instance = new NativeFramesInstrumentation( - // eslint-disable-next-line @typescript-eslint/no-empty-function - _eventProcessor => { - eventProcessor = _eventProcessor; - }, - () => true, - ); - - const transaction = new Transaction({ name: 'test' }); - - transaction.setData('test', {}); - - setImmediate(() => { - const finishTimestamp = Date.now() / 1000; - instance.onTransactionFinish(transaction); - - setImmediate(async () => { - expect(eventProcessor).toBeDefined(); - if (eventProcessor) { - const event = await eventProcessor( - { - event_id: '0', - type: 'transaction', - transaction: transaction.name, - contexts: { - trace: transaction.getTraceContext(), - }, - start_timestamp: finishTimestamp - 10, - timestamp: finishTimestamp, - measurements: {}, - }, - {}, - ); - - // This setImmediate needs to be here for the assertions to not be caught by the promise handler. - setImmediate(() => { - expect(event).toBeDefined(); + mockFunction(NATIVE.fetchNativeFrames).mockResolvedValueOnce(startFrames).mockResolvedValueOnce(finishFrames); - if (event) { - expect(event.measurements).toBeDefined(); - - if (event.measurements) { - expect(event.measurements.frames_total).toBeUndefined(); - expect(event.measurements.frames_slow).toBeUndefined(); - expect(event.measurements.frames_frozen).toBeUndefined(); - } - - expect(event.contexts?.trace?.data).toBeDefined(); - - if (event.contexts?.trace?.data) { - expect( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (event.contexts.trace.data as any).__startFrames, - ).toBeUndefined(); - } - } - - done(); - }); - } - }); + await startSpan({ name: 'test' }, async () => { + await Promise.resolve(); // native frames fetch is async call this will flush the start frames fetch promise }); + + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expect(client.event!).toEqual( + expect.objectContaining>({ + measurements: expect.not.objectContaining({ + frames_total: {}, + frames_slow: {}, + frames_frozen: {}, + }), + }), + ); }); - it('Sets measurements on the transaction event and removes startFrames if finishFrames is null.', done => { + it('does not set measurements on transactions without finishFrames', async () => { const startFrames = { totalFrames: 100, slowFrames: 20, frozenFrames: 5, }; const finishFrames = null; - mockFunction(NATIVE.fetchNativeFrames).mockResolvedValue(startFrames); - - let eventProcessor: EventProcessor; - const instance = new NativeFramesInstrumentation( - // eslint-disable-next-line @typescript-eslint/no-empty-function - _eventProcessor => { - eventProcessor = _eventProcessor; - }, - () => true, - ); - - const transaction = new Transaction({ name: 'test' }); - - instance.onTransactionStart(transaction); + mockFunction(NATIVE.fetchNativeFrames).mockResolvedValueOnce(startFrames).mockResolvedValueOnce(finishFrames); - setImmediate(() => { - mockFunction(NATIVE.fetchNativeFrames).mockResolvedValue(finishFrames); - - const finishTimestamp = Date.now() / 1000; - instance.onTransactionFinish(transaction); - - setImmediate(async () => { - try { - expect(eventProcessor).toBeDefined(); - if (eventProcessor) { - const event = await eventProcessor( - { - event_id: '0', - type: 'transaction', - transaction: transaction.name, - contexts: { - trace: transaction.getTraceContext(), - }, - start_timestamp: finishTimestamp - 10, - timestamp: finishTimestamp, - }, - {}, - ); - - expect(event).toBeDefined(); - - if (event) { - expect(event.measurements).toBeUndefined(); - - expect(event.contexts?.trace?.data).toBeDefined(); - - if (event.contexts?.trace?.data) { - expect( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (event.contexts.trace.data as any).__startFrames, - ).toBeUndefined(); - } - } - } - - done(); - } catch (e) { - done(e); - } - }); + await startSpan({ name: 'test' }, async () => { + await Promise.resolve(); // native frames fetch is async call this will flush the start frames fetch promise }); + + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expect(client.event!).toEqual( + expect.objectContaining>({ + measurements: expect.not.objectContaining({ + frames_total: {}, + frames_slow: {}, + frames_frozen: {}, + }), + }), + ); }); - it('Does not set measurements on the transaction event and removes startFrames if finishFrames times out.', done => { + it('does not set measurements on a transaction event for which finishFrames times out.', async () => { const startFrames = { totalFrames: 100, slowFrames: 20, frozenFrames: 5, }; - mockFunction(NATIVE.fetchNativeFrames).mockResolvedValue(startFrames); - - let eventProcessor: EventProcessor; - const instance = new NativeFramesInstrumentation( - // eslint-disable-next-line @typescript-eslint/no-empty-function - _eventProcessor => { - eventProcessor = _eventProcessor; - }, - () => true, - ); - - const transaction = new Transaction({ name: 'test' }); - - instance.onTransactionStart(transaction); - - setImmediate(() => { - mockFunction(NATIVE.fetchNativeFrames).mockImplementation( - // eslint-disable-next-line @typescript-eslint/no-empty-function - async () => new Promise(() => {}), - ); - - const finishTimestamp = Date.now() / 1000; - instance.onTransactionFinish(transaction); - - setImmediate(async () => { - try { - expect(eventProcessor).toBeDefined(); - if (eventProcessor) { - const event = await eventProcessor( - { - event_id: '0', - type: 'transaction', - transaction: transaction.name, - contexts: { - trace: transaction.getTraceContext(), - }, - start_timestamp: finishTimestamp - 10, - timestamp: finishTimestamp, - }, - {}, - ); - - expect(event).toBeDefined(); - - if (event) { - expect(event.measurements).toBeUndefined(); - - expect(event.contexts?.trace?.data).toBeDefined(); + const finishFrames = { + totalFrames: 200, + slowFrames: 40, + frozenFrames: 10, + }; + mockFunction(NATIVE.fetchNativeFrames).mockResolvedValueOnce(startFrames).mockResolvedValueOnce(finishFrames); - if (event.contexts?.trace?.data) { - expect( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (event.contexts.trace.data as any).__startFrames, - ).toBeUndefined(); - } - } - } - done(); - } catch (e) { - done(e); - } - }); + await startSpan({ name: 'test' }, async () => { + await Promise.resolve(); // native frames fetch is async call this will flush the start frames fetch promise }); + + await jest.runOnlyPendingTimersAsync(); + await jest.advanceTimersByTimeAsync(2100); // hardcoded final frames timeout 2000ms + await client.flush(); + + expect(client.event!).toEqual( + expect.objectContaining>({ + measurements: expect.not.objectContaining({ + frames_total: {}, + frames_slow: {}, + frames_frozen: {}, + }), + }), + ); }); }); + +function wait(ms) { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} diff --git a/test/tracing/reactnativenavigation.test.ts b/test/tracing/reactnativenavigation.test.ts index b30f8d2a2d..8bdd65e770 100644 --- a/test/tracing/reactnativenavigation.test.ts +++ b/test/tracing/reactnativenavigation.test.ts @@ -1,17 +1,31 @@ /* eslint-disable @typescript-eslint/no-empty-function */ -import { spanToJSON } from '@sentry/core'; -import type { SpanJSON, Transaction, TransactionContext } from '@sentry/types'; +import { + addGlobalEventProcessor, + getActiveSpan, + getCurrentHub, + getCurrentScope, + getGlobalScope, + getIsolationScope, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + setCurrentClient, + spanToJSON, +} from '@sentry/core'; +import type { Event } from '@sentry/types'; import type { EmitterSubscription } from 'react-native'; +import { ReactNativeTracing } from '../../src/js'; import type { BottomTabPressedEvent, ComponentWillAppearEvent, EventsRegistry, - NavigationDelegate, } from '../../src/js/tracing/reactnativenavigation'; import { ReactNativeNavigationInstrumentation } from '../../src/js/tracing/reactnativenavigation'; -import type { TransactionCreator } from '../../src/js/tracing/routingInstrumentation'; -import { getMockTransaction } from '../testutils'; +import type { BeforeNavigate } from '../../src/js/tracing/types'; +import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; +import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; interface MockEventsRegistry extends EventsRegistry { componentWillAppearListener?: (event: ComponentWillAppearEvent) => void; @@ -22,71 +36,21 @@ interface MockEventsRegistry extends EventsRegistry { onBottomTabPressed(event: BottomTabPressedEvent): void; } -const mockEventsRegistry: MockEventsRegistry = { - onComponentWillAppear(event: ComponentWillAppearEvent): void { - this.componentWillAppearListener?.(event); - }, - onCommand(name: string, params: unknown): void { - this.commandListener?.(name, params); - }, - onBottomTabPressed(event) { - this.bottomTabPressedListener?.(event); - }, - registerComponentWillAppearListener(callback: (event: ComponentWillAppearEvent) => void) { - this.componentWillAppearListener = callback; - return { - // eslint-disable-next-line @typescript-eslint/no-empty-function - remove() {}, - } as EmitterSubscription; - }, - registerCommandListener(callback: (name: string, params: unknown) => void) { - this.commandListener = callback; - return { - // eslint-disable-next-line @typescript-eslint/no-empty-function - remove() {}, - }; - }, - registerBottomTabPressedListener(callback: (event: BottomTabPressedEvent) => void) { - this.bottomTabPressedListener = callback; - return { - // eslint-disable-next-line @typescript-eslint/no-empty-function - remove() {}, - } as EmitterSubscription; - }, -}; - -const mockNavigationDelegate: NavigationDelegate = { - events() { - return mockEventsRegistry; - }, -}; +jest.useFakeTimers({ advanceTimers: true }); describe('React Native Navigation Instrumentation', () => { - let instrumentation: ReactNativeNavigationInstrumentation; - let tracingListener: jest.Mock, Parameters>; - let mockTransaction: Transaction; + let mockEventsRegistry: MockEventsRegistry; + let client: TestClient; beforeEach(() => { - instrumentation = new ReactNativeNavigationInstrumentation(mockNavigationDelegate); - - mockTransaction = getMockTransaction(ReactNativeNavigationInstrumentation.instrumentationName); - - tracingListener = jest.fn((_context: TransactionContext) => mockTransaction); - instrumentation.registerRoutingInstrumentation( - tracingListener, - context => context, - () => {}, - ); - }); - - afterEach(() => { - jest.resetAllMocks(); + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + RN_GLOBAL_OBJ.__SENTRY__.globalEventProcessors = []; // resets integrations }); - test('Correctly instruments a route change', () => { - mockEventsRegistry.onCommand('root', {}); - - expect(spanToJSON(mockTransaction).description).toBe('Route Change'); + test('Correctly instruments a route change', async () => { + setupTestClient(); const mockEvent: ComponentWillAppearEvent = { componentId: '0', @@ -94,49 +58,47 @@ describe('React Native Navigation Instrumentation', () => { componentType: 'Component', passProps: {}, }; + + mockEventsRegistry.onCommand('root', {}); mockEventsRegistry.onComponentWillAppear(mockEvent); - expect(spanToJSON(mockTransaction)).toEqual( - expect.objectContaining(>{ - description: 'Test', - tags: { - 'routing.instrumentation': 'react-native-navigation', - 'routing.route.name': 'Test', - }, - data: { - route: { - componentId: '0', - componentName: 'Test', - componentType: 'Component', - passProps: {}, - name: 'Test', - hasBeenSeen: false, - }, - previousRoute: null, - 'sentry.op': 'navigation', - 'sentry.origin': 'manual', - 'sentry.source': 'component', - }, + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expect(client.event).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'Test', + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + data: { + route: { + name: 'Test', + componentName: 'Test', + componentId: '0', + componentType: 'Component', + hasBeenSeen: false, + passProps: {}, + }, + previousRoute: null, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }, + }), + }), }), ); }); - test('Transaction context is changed with beforeNavigate', () => { - instrumentation.registerRoutingInstrumentation( - tracingListener, - context => { - context.sampled = false; - context.description = 'Description'; - context.name = 'New Name'; - - return context; + test('Transaction context is changed with beforeNavigate', async () => { + setupTestClient({ + beforeNavigate: span => { + span.name = 'New Name'; + return span; }, - () => {}, - ); - - mockEventsRegistry.onCommand('root', {}); - - expect(mockTransaction.name).toBe('Route Change'); + }); const mockEvent: ComponentWillAppearEvent = { componentId: '0', @@ -144,57 +106,60 @@ describe('React Native Navigation Instrumentation', () => { componentType: 'Component', passProps: {}, }; + + mockEventsRegistry.onCommand('root', {}); mockEventsRegistry.onComponentWillAppear(mockEvent); - expect(spanToJSON(mockTransaction)).toEqual( - expect.objectContaining(>{ - description: 'New Name', - tags: { - 'routing.instrumentation': 'react-native-navigation', - 'routing.route.name': 'Test', - }, - data: { - route: { - componentId: '0', - componentName: 'Test', - componentType: 'Component', - passProps: {}, - name: 'Test', - hasBeenSeen: false, - }, - previousRoute: null, - 'sentry.op': 'navigation', - 'sentry.origin': 'manual', - 'sentry.source': 'custom', - }, + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expect(client.event).toEqual( + expect.objectContaining(>{ + type: 'transaction', + transaction: 'New Name', + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + data: { + route: { + name: 'Test', + componentName: 'Test', + componentId: '0', + componentType: 'Component', + hasBeenSeen: false, + passProps: {}, + }, + previousRoute: null, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }, + }), + }), }), ); }); - test('Transaction not sent on a cancelled route change', () => { - jest.useFakeTimers(); + test('Transaction not sent on a cancelled route change', async () => { + setupTestClient(); mockEventsRegistry.onCommand('root', {}); - expect(mockTransaction.name).toBe('Route Change'); - expect(mockTransaction.sampled).toBe(true); - - jest.runAllTimers(); + await jest.runAllTimersAsync(); + await client.flush(); - expect(mockTransaction.sampled).toBe(false); - - jest.useRealTimers(); + expect(client.event).toBeUndefined(); }); - test('Transaction not sent if route change timeout is passed', () => { - jest.useFakeTimers(); + test('Transaction not sent if route change timeout is passed', async () => { + setupTestClient(); mockEventsRegistry.onCommand('root', {}); - expect(mockTransaction.name).toBe('Route Change'); - expect(mockTransaction.sampled).toBe(true); + expect(spanToJSON(getActiveSpan()!).description).toEqual('Route Change'); + expect(getActiveSpan()!.isRecording()).toBe(true); - jest.runAllTimers(); + await jest.runAllTimersAsync(); const mockEvent: ComponentWillAppearEvent = { componentId: '0', @@ -204,25 +169,18 @@ describe('React Native Navigation Instrumentation', () => { }; mockEventsRegistry.onComponentWillAppear(mockEvent); - expect(mockTransaction.sampled).toBe(false); - expect(mockTransaction.name).not.toBe('Test'); + await jest.runAllTimersAsync(); + await client.flush(); - jest.useRealTimers(); + expect(client.event).toBeUndefined(); }); describe('tab change', () => { - beforeEach(() => { - instrumentation = new ReactNativeNavigationInstrumentation(mockNavigationDelegate, { + test('correctly instruments a tab change', async () => { + setupTestClient({ enableTabsInstrumentation: true, }); - instrumentation.registerRoutingInstrumentation( - tracingListener, - context => context, - () => {}, - ); - }); - test('correctly instruments a tab change', () => { mockEventsRegistry.onBottomTabPressed({ tabIndex: 0 }); mockEventsRegistry.onComponentWillAppear({ componentId: '0', @@ -231,43 +189,40 @@ describe('React Native Navigation Instrumentation', () => { passProps: {}, }); - expect(spanToJSON(mockTransaction)).toEqual( - expect.objectContaining(>{ - description: 'TestScreenName', - tags: { - 'routing.instrumentation': 'react-native-navigation', - 'routing.route.name': 'TestScreenName', - }, - data: { - route: { - componentId: '0', - componentName: 'TestScreenName', - componentType: 'Component', - passProps: {}, - name: 'TestScreenName', - hasBeenSeen: false, - }, - previousRoute: null, - 'sentry.op': 'navigation', - 'sentry.origin': 'manual', - 'sentry.source': 'component', - }, + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expect(client.event).toEqual( + expect.objectContaining(>{ + type: 'transaction', + transaction: 'TestScreenName', + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + data: { + route: { + name: 'TestScreenName', + componentName: 'TestScreenName', + componentId: '0', + componentType: 'Component', + hasBeenSeen: false, + passProps: {}, + }, + previousRoute: null, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }, + }), + }), }), ); }); - test('not instrument tabs if disabled', () => { - jest.useFakeTimers(); - - instrumentation = new ReactNativeNavigationInstrumentation(mockNavigationDelegate, { + test('not instrument tabs if disabled', async () => { + setupTestClient({ enableTabsInstrumentation: false, }); - tracingListener = jest.fn((_context: TransactionContext) => mockTransaction); - instrumentation.registerRoutingInstrumentation( - tracingListener, - context => context, - () => {}, - ); mockEventsRegistry.onBottomTabPressed({ tabIndex: 0 }); mockEventsRegistry.onComponentWillAppear({ @@ -276,32 +231,32 @@ describe('React Native Navigation Instrumentation', () => { componentType: 'Component', }); - expect(tracingListener).not.toBeCalled(); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expect(client.event).toBeUndefined(); + }); + + test('tabs instrumentation is disabled by default', async () => { + setupTestClient(); - jest.runAllTimers(); - expect(mockTransaction.sampled).toBe(false); + mockEventsRegistry.onBottomTabPressed({ tabIndex: 0 }); + mockEventsRegistry.onComponentWillAppear({ + componentId: '0', + componentName: 'TestScreenName', + componentType: 'Component', + }); - jest.useRealTimers(); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expect(client.event).toBeUndefined(); }); }); describe('onRouteConfirmed', () => { - let confirmedContext: TransactionContext | undefined; - - beforeEach(() => { - instrumentation.registerRoutingInstrumentation( - tracingListener, - context => context, - context => { - confirmedContext = context; - }, - ); - }); - - test('onRouteConfirmed called with correct route data', () => { - mockEventsRegistry.onCommand('root', {}); - - expect(mockTransaction.name).toBe('Route Change'); + test('onRouteConfirmed called with correct route data', async () => { + setupTestClient(); const mockEvent1: ComponentWillAppearEvent = { componentId: '1', @@ -309,37 +264,58 @@ describe('React Native Navigation Instrumentation', () => { componentType: 'Component', passProps: {}, }; - mockEventsRegistry.onComponentWillAppear(mockEvent1); - - mockEventsRegistry.onCommand('root', {}); - const mockEvent2: ComponentWillAppearEvent = { componentId: '2', componentName: 'Test 2', componentType: 'Component', passProps: {}, }; + + mockEventsRegistry.onCommand('root', {}); + mockEventsRegistry.onComponentWillAppear(mockEvent1); + + mockEventsRegistry.onCommand('root', {}); mockEventsRegistry.onComponentWillAppear(mockEvent2); - expect(confirmedContext).toEqual( - expect.objectContaining(>{ - name: 'Test 2', - data: expect.objectContaining({ - route: expect.objectContaining({ - name: 'Test 2', - }), - previousRoute: expect.objectContaining({ - name: 'Test 1', + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expect(client.eventQueue.length).toEqual(2); + expect(client.event).toEqual( + expect.objectContaining(>{ + type: 'transaction', + transaction: 'Test 2', + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + data: { + route: { + name: 'Test 2', + componentName: 'Test 2', + componentId: '2', + componentType: 'Component', + hasBeenSeen: false, + passProps: {}, + }, + previousRoute: { + name: 'Test 1', + componentName: 'Test 1', + componentId: '1', + componentType: 'Component', + passProps: {}, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }, }), }), }), ); }); - test('onRouteConfirmed clears transaction', () => { - mockEventsRegistry.onCommand('root', {}); - - expect(mockTransaction.name).toBe('Route Change'); + test('onRouteConfirmed clears transaction', async () => { + setupTestClient(); const mockEvent1: ComponentWillAppearEvent = { componentId: '1', @@ -347,21 +323,120 @@ describe('React Native Navigation Instrumentation', () => { componentType: 'Component', passProps: {}, }; - mockEventsRegistry.onComponentWillAppear(mockEvent1); - const mockEvent2: ComponentWillAppearEvent = { componentId: '2', componentName: 'Test 2', componentType: 'Component', passProps: {}, }; + + mockEventsRegistry.onCommand('root', {}); + mockEventsRegistry.onComponentWillAppear(mockEvent1); + mockEventsRegistry.onComponentWillAppear(mockEvent2); - expect(confirmedContext).toEqual( - expect.objectContaining(>{ - name: 'Test 1', + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expect(client.eventQueue.length).toEqual(1); + expect(client.event).toEqual( + expect.objectContaining(>{ + type: 'transaction', + transaction: 'Test 1', + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + data: { + route: { + name: 'Test 1', + componentName: 'Test 1', + componentId: '1', + componentType: 'Component', + hasBeenSeen: false, + passProps: {}, + }, + previousRoute: null, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }, + }), + }), }), ); }); }); + + function setupTestClient( + setupOptions: { + beforeNavigate?: BeforeNavigate; + enableTabsInstrumentation?: boolean; + } = {}, + ) { + createMockNavigation(); + const rNavigation = new ReactNativeNavigationInstrumentation( + { + events() { + return mockEventsRegistry; + }, + }, + { + routeChangeTimeoutMs: 200, + enableTabsInstrumentation: setupOptions.enableTabsInstrumentation, + }, + ); + + const rnTracing = new ReactNativeTracing({ + routingInstrumentation: rNavigation, + enableStallTracking: false, + enableNativeFramesTracking: false, + enableAppStartTracking: false, + beforeNavigate: setupOptions.beforeNavigate || (span => span), + }); + + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1.0, + integrations: [rnTracing], + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + rnTracing.setupOnce(addGlobalEventProcessor, getCurrentHub); + } + + function createMockNavigation() { + mockEventsRegistry = { + onComponentWillAppear(event: ComponentWillAppearEvent): void { + this.componentWillAppearListener?.(event); + }, + onCommand(name: string, params: unknown): void { + this.commandListener?.(name, params); + }, + onBottomTabPressed(event) { + this.bottomTabPressedListener?.(event); + }, + registerComponentWillAppearListener(callback: (event: ComponentWillAppearEvent) => void) { + this.componentWillAppearListener = callback; + return { + // eslint-disable-next-line @typescript-eslint/no-empty-function + remove() {}, + } as EmitterSubscription; + }, + registerCommandListener(callback: (name: string, params: unknown) => void) { + this.commandListener = callback; + return { + // eslint-disable-next-line @typescript-eslint/no-empty-function + remove() {}, + }; + }, + registerBottomTabPressedListener(callback: (event: BottomTabPressedEvent) => void) { + this.bottomTabPressedListener = callback; + return { + // eslint-disable-next-line @typescript-eslint/no-empty-function + remove() {}, + } as EmitterSubscription; + }, + }; + } }); diff --git a/test/tracing/reactnativetracing.test.ts b/test/tracing/reactnativetracing.test.ts index 183f963660..ff1dfd9b6a 100644 --- a/test/tracing/reactnativetracing.test.ts +++ b/test/tracing/reactnativetracing.test.ts @@ -1,14 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { SpanStatusType, User } from '@sentry/browser'; import * as SentryBrowser from '@sentry/browser'; -import type { IdleTransaction } from '@sentry/core'; -import { addGlobalEventProcessor, Hub, Transaction } from '@sentry/core'; +import type { Event } from '@sentry/types'; import type { NativeAppStartResponse } from '../../src/js/NativeRNSentry'; import { RoutingInstrumentation } from '../../src/js/tracing/routingInstrumentation'; -const BrowserClient = SentryBrowser.BrowserClient; - jest.mock('../../src/js/wrapper', () => { return { NATIVE: { @@ -53,55 +49,8 @@ const mockedAppState: AppState & MockAppState = { }; jest.mock('react-native/Libraries/AppState/AppState', () => mockedAppState); -const getMockScope = () => { - let scopeTransaction: Transaction | undefined; - let scopeUser: User | undefined; - - return { - getTransaction: () => scopeTransaction, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - setSpan: jest.fn((span: any) => { - scopeTransaction = span; - }), - setTag(_tag: any) { - // Placeholder - }, - setContext(_context: any) { - // Placeholder - }, - addBreadcrumb(_breadcrumb: any) { - // Placeholder - }, - getUser: () => scopeUser, - captureEvent(_event: any) { - // Placeholder - }, - }; -}; - -const getMockHub = () => { - const mockHub = new Hub( - new BrowserClient({ - tracesSampleRate: 1, - integrations: [], - transport: () => ({ - send: jest.fn(), - flush: jest.fn(), - }), - stackParser: () => [], - }), - ); - const mockScope = getMockScope(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mockHub.getScope = () => mockScope as any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mockHub.configureScope = jest.fn(callback => callback(mockScope as any)); - - return mockHub; -}; - -import type { Event, Scope } from '@sentry/types'; +import { getActiveSpan, startSpanManual } from '@sentry/browser'; +import { addGlobalEventProcessor, getCurrentHub, getCurrentScope, spanToJSON, startInactiveSpan } from '@sentry/core'; import type { AppState, AppStateStatus } from 'react-native'; import { APP_START_COLD, APP_START_WARM } from '../../src/js/measurements'; @@ -113,19 +62,19 @@ import { 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 { firstArg, mockFunction } from '../testutils'; +import type { TestClient } from '../mocks/client'; +import { setupTestClient } from '../mocks/client'; +import { mockFunction } from '../testutils'; import type { MockedRoutingInstrumentation } from './mockedrountinginstrumention'; -import { - createMockedRoutingInstrumentation, - mockedConfirmedRouteTransactionContext, -} from './mockedrountinginstrumention'; +import { createMockedRoutingInstrumentation } from './mockedrountinginstrumention'; const DEFAULT_IDLE_TIMEOUT = 1000; describe('ReactNativeTracing', () => { beforeEach(() => { - jest.useFakeTimers(); + jest.useFakeTimers({ advanceTimers: true }); NATIVE.enableNative = true; mockedAppState.isAvailable = true; mockedAppState.addEventListener = (_, listener) => { @@ -140,47 +89,21 @@ describe('ReactNativeTracing', () => { jest.runOnlyPendingTimers(); jest.useRealTimers(); jest.clearAllMocks(); + RN_GLOBAL_OBJ.__SENTRY__.globalEventProcessors = []; // resets integrations }); describe('trace propagation targets', () => { - it('uses tracingOrigins', () => { - const instrumentOutgoingRequests = jest.spyOn(SentryBrowser, 'instrumentOutgoingRequests'); - const mockedHub = { - getClient: () => ({ - getOptions: () => ({}), - }), - }; - - const integration = new ReactNativeTracing({ - tracingOrigins: ['test1', 'test2'], - }); - integration.setupOnce( - () => {}, - () => mockedHub as unknown as Hub, - ); - - expect(instrumentOutgoingRequests).toBeCalledWith( - expect.objectContaining({ - tracePropagationTargets: ['test1', 'test2'], - }), - ); - }); - it('uses tracePropagationTargets', () => { const instrumentOutgoingRequests = jest.spyOn(SentryBrowser, 'instrumentOutgoingRequests'); - const mockedHub = { - getClient: () => ({ - getOptions: () => ({}), - }), - }; - const integration = new ReactNativeTracing({ + enableStallTracking: false, tracePropagationTargets: ['test1', 'test2'], }); - integration.setupOnce( - () => {}, - () => mockedHub as unknown as Hub, - ); + setupTestClient({ + integrations: [integration], + }); + + setup(integration); expect(instrumentOutgoingRequests).toBeCalledWith( expect.objectContaining({ @@ -191,19 +114,13 @@ describe('ReactNativeTracing', () => { it('uses tracePropagationTargets from client options', () => { const instrumentOutgoingRequests = jest.spyOn(SentryBrowser, 'instrumentOutgoingRequests'); - const mockedHub = { - getClient: () => ({ - getOptions: () => ({ - tracePropagationTargets: ['test1', 'test2'], - }), - }), - }; + const integration = new ReactNativeTracing({ enableStallTracking: false }); + setupTestClient({ + tracePropagationTargets: ['test1', 'test2'], + integrations: [integration], + }); - const integration = new ReactNativeTracing({}); - integration.setupOnce( - () => {}, - () => mockedHub as unknown as Hub, - ); + setup(integration); expect(instrumentOutgoingRequests).toBeCalledWith( expect.objectContaining({ @@ -214,17 +131,12 @@ describe('ReactNativeTracing', () => { it('uses defaults', () => { const instrumentOutgoingRequests = jest.spyOn(SentryBrowser, 'instrumentOutgoingRequests'); - const mockedHub = { - getClient: () => ({ - getOptions: () => ({}), - }), - }; + const integration = new ReactNativeTracing({ enableStallTracking: false }); + setupTestClient({ + integrations: [integration], + }); - const integration = new ReactNativeTracing({}); - integration.setupOnce( - () => {}, - () => mockedHub as unknown as Hub, - ); + setup(integration); expect(instrumentOutgoingRequests).toBeCalledWith( expect.objectContaining({ @@ -235,22 +147,16 @@ describe('ReactNativeTracing', () => { it('client tracePropagationTargets takes priority over integration options', () => { const instrumentOutgoingRequests = jest.spyOn(SentryBrowser, 'instrumentOutgoingRequests'); - const mockedHub = { - getClient: () => ({ - getOptions: () => ({ - tracePropagationTargets: ['test1', 'test2'], - }), - }), - }; - const integration = new ReactNativeTracing({ + enableStallTracking: false, tracePropagationTargets: ['test3', 'test4'], - tracingOrigins: ['test5', 'test6'], }); - integration.setupOnce( - () => {}, - () => mockedHub as unknown as Hub, - ); + setupTestClient({ + tracePropagationTargets: ['test1', 'test2'], + integrations: [integration], + }); + + setup(integration); expect(instrumentOutgoingRequests).toBeCalledWith( expect.objectContaining({ @@ -258,34 +164,16 @@ describe('ReactNativeTracing', () => { }), ); }); + }); - it('integration tracePropagationTargets takes priority over tracingOrigins', () => { - const instrumentOutgoingRequests = jest.spyOn(SentryBrowser, 'instrumentOutgoingRequests'); - const mockedHub = { - getClient: () => ({ - getOptions: () => ({}), - }), - }; - - const integration = new ReactNativeTracing({ - tracePropagationTargets: ['test3', 'test4'], - tracingOrigins: ['test5', 'test6'], - }); - integration.setupOnce( - () => {}, - () => mockedHub as unknown as Hub, - ); + describe('App Start Tracing Instrumentation', () => { + let client: TestClient; - expect(instrumentOutgoingRequests).toBeCalledWith( - expect.objectContaining({ - tracePropagationTargets: ['test3', 'test4'], - }), - ); + beforeEach(() => { + client = setupTestClient(); }); - }); - describe('App Start', () => { - describe('Without routing instrumentation', () => { + describe('App Start without routing instrumentation', () => { it('Starts route transaction (cold)', async () => { const integration = new ReactNativeTracing({ enableNativeFramesTracking: false, @@ -293,29 +181,22 @@ describe('ReactNativeTracing', () => { const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStartResponse({ cold: true }); - const mockHub = getMockHub(); - integration.setupOnce(addGlobalEventProcessor, () => mockHub); + setup(integration); integration.onAppStartFinish(Date.now() / 1000); await jest.advanceTimersByTimeAsync(500); + await jest.runOnlyPendingTimersAsync(); - const transaction = mockHub.getScope()?.getTransaction(); + const transaction = client.event; expect(transaction).toBeDefined(); + expect(transaction?.start_timestamp).toBe(appStartTimeMilliseconds / 1000); + expect(transaction?.contexts?.trace?.op).toBe(UI_LOAD); - if (transaction) { - expect(transaction.startTimestamp).toBe(appStartTimeMilliseconds / 1000); - expect(transaction.op).toBe(UI_LOAD); - - expect( - // @ts-expect-error access private for test - transaction._measurements[APP_START_COLD].value, - ).toEqual(timeOriginMilliseconds - appStartTimeMilliseconds); - expect( - // @ts-expect-error access private for test - transaction._measurements[APP_START_COLD].unit, - ).toBe('millisecond'); - } + 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 () => { @@ -323,27 +204,21 @@ describe('ReactNativeTracing', () => { const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStartResponse({ cold: false }); - const mockHub = getMockHub(); - integration.setupOnce(addGlobalEventProcessor, () => mockHub); + setup(integration); await jest.advanceTimersByTimeAsync(500); - const transaction = mockHub.getScope()?.getTransaction(); + 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); - if (transaction) { - expect(transaction.startTimestamp).toBe(appStartTimeMilliseconds / 1000); - expect(transaction.op).toBe(UI_LOAD); - - expect( - // @ts-expect-error access private for test - transaction._measurements[APP_START_WARM].value, - ).toEqual(timeOriginMilliseconds - appStartTimeMilliseconds); - expect( - // @ts-expect-error access private for test - transaction._measurements[APP_START_WARM].unit, - ).toBe('millisecond'); - } + 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 () => { @@ -351,16 +226,16 @@ describe('ReactNativeTracing', () => { mockAppStartResponse({ cold: false }); - const mockHub = getMockHub(); - integration.setupOnce(addGlobalEventProcessor, () => mockHub); + setup(integration); await jest.advanceTimersByTimeAsync(500); - const transaction = mockHub.getScope()?.getTransaction(); mockedAppState.setState('background'); - jest.runAllTimers(); + await jest.runAllTimersAsync(); + await client.flush(); - expect(transaction?.status).toBe('cancelled'); + const transaction = client.event; + expect(transaction?.contexts?.trace?.status).toBe('cancelled'); expect(mockedAppState.removeSubscription).toBeCalledTimes(1); }); @@ -369,19 +244,23 @@ describe('ReactNativeTracing', () => { mockedAppState.addEventListener = (() => { return undefined; }) as unknown as (typeof mockedAppState)['addEventListener']; // RN Web can return undefined + const integration = new ReactNativeTracing(); + setupTestClient({ + integrations: [integration], + }); mockAppStartResponse({ cold: false }); - const mockHub = getMockHub(); - integration.setupOnce(addGlobalEventProcessor, () => mockHub); + setup(integration); await jest.advanceTimersByTimeAsync(500); - const transaction = mockHub.getScope()?.getTransaction(); + const transaction = getActiveSpan(); - jest.runAllTimers(); + await jest.runAllTimersAsync(); + await client.flush(); - expect(transaction?.endTimestamp).toBeDefined(); + expect(spanToJSON(transaction!).timestamp).toBeDefined(); }); it('Does not add app start measurement if more than 60s', async () => { @@ -398,26 +277,16 @@ describe('ReactNativeTracing', () => { mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds); mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse); - const mockHub = getMockHub(); - integration.setupOnce(addGlobalEventProcessor, () => mockHub); + setup(integration); await jest.advanceTimersByTimeAsync(500); + await jest.runOnlyPendingTimersAsync(); - const transaction = mockHub.getScope()?.getTransaction(); + const transaction = client.event; expect(transaction).toBeDefined(); - - if (transaction) { - expect( - // @ts-expect-error access private for test - transaction._measurements[APP_START_WARM], - ).toBeUndefined(); - - expect( - // @ts-expect-error access private for test - transaction._measurements[APP_START_COLD], - ).toBeUndefined(); - } + 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 () => { @@ -434,27 +303,16 @@ describe('ReactNativeTracing', () => { mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds); mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse); - const mockHub = getMockHub(); - integration.setupOnce(addGlobalEventProcessor, () => mockHub); + setup(integration); await jest.advanceTimersByTimeAsync(500); + await jest.runOnlyPendingTimersAsync(); - const transaction = mockHub.getScope()?.getTransaction(); + const transaction = client.event; expect(transaction).toBeDefined(); - - if (transaction) { - expect( - // @ts-expect-error access private for test - transaction.spanRecorder, - ).toBeDefined(); - - expect( - // @ts-expect-error access private for test - transaction.spanRecorder.spans.some(span => span.op == APP_SPAN_START_WARM), - ).toBe(false); - expect(transaction.startTimestamp).toBeGreaterThanOrEqual(timeOriginMilliseconds / 1000); - } + expect(transaction?.spans?.some(span => span.op == APP_SPAN_START_WARM)).toBeFalse(); + expect(transaction?.start_timestamp).toBeGreaterThanOrEqual(timeOriginMilliseconds / 1000); }); it('Does not create app start transaction if didFetchAppStart == true', async () => { @@ -462,13 +320,12 @@ describe('ReactNativeTracing', () => { mockAppStartResponse({ cold: false, didFetchAppStart: true }); - const mockHub = getMockHub(); - integration.setupOnce(addGlobalEventProcessor, () => mockHub); + setup(integration); await jest.advanceTimersByTimeAsync(500); + await jest.runOnlyPendingTimersAsync(); - const transaction = mockHub.getScope()?.getTransaction(); - + const transaction = client.event; expect(transaction).toBeUndefined(); }); }); @@ -482,20 +339,20 @@ describe('ReactNativeTracing', () => { mockAppStartResponse({ cold: true }); - const mockHub = getMockHub(); - integration.setupOnce(addGlobalEventProcessor, () => mockHub); + setup(integration); // wait for internal promises to resolve, fetch app start data from mocked native await Promise.resolve(); const routeTransaction = routingInstrumentation.onRouteWillChange({ name: 'test', - }) as IdleTransaction; + }); mockedAppState.setState('background'); jest.runAllTimers(); - expect(routeTransaction.status).toBe('cancelled'); + expect(routeTransaction).toBeDefined(); + expect(spanToJSON(routeTransaction!).status).toBe('cancelled'); expect(mockedAppState.removeSubscription).toBeCalledTimes(1); }); @@ -507,43 +364,37 @@ describe('ReactNativeTracing', () => { const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStartResponse({ cold: true }); - const mockHub = getMockHub(); - integration.setupOnce(addGlobalEventProcessor, () => mockHub); + setup(integration); // wait for internal promises to resolve, fetch app start data from mocked native await Promise.resolve(); - const transaction = mockHub.getScope()?.getTransaction(); - expect(transaction).toBeUndefined(); + expect(getActiveSpan()).toBeUndefined(); - const routeTransaction = routingInstrumentation.onRouteWillChange({ - name: 'test', - }) as IdleTransaction; - routeTransaction.initSpanRecorder(10); + routingInstrumentation.onRouteWillChange({ + name: 'Route Change', + }); - expect(routeTransaction).toBeDefined(); - expect(routeTransaction.spanId).toEqual(mockHub.getScope()?.getTransaction()?.spanId); + 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); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); - // @ts-expect-error access private for test - expect(routeTransaction._measurements[APP_START_COLD].value).toBe( + const routeTransactionEvent = client.event; + expect(routeTransactionEvent!.measurements![APP_START_COLD].value).toBe( timeOriginMilliseconds - appStartTimeMilliseconds, ); - expect(routeTransaction.op).toBe(UI_LOAD); - expect(routeTransaction.startTimestamp).toBe(appStartTimeMilliseconds / 1000); - - const spanRecorder = routeTransaction.spanRecorder; - expect(spanRecorder).toBeDefined(); - expect(spanRecorder?.spans.length).toBeGreaterThan(1); - - const span = spanRecorder?.spans[spanRecorder?.spans.length - 1]; + expect(routeTransactionEvent!.contexts!.trace!.op).toBe(UI_LOAD); + expect(routeTransactionEvent!.start_timestamp).toBe(appStartTimeMilliseconds / 1000); - expect(span?.op).toBe(APP_START_COLD_OP); - expect(span?.description).toBe('Cold App Start'); - expect(span?.startTimestamp).toBe(appStartTimeMilliseconds / 1000); - expect(span?.endTimestamp).toBe(timeOriginMilliseconds / 1000); + const span = spanToJSON(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 () => { @@ -554,81 +405,71 @@ describe('ReactNativeTracing', () => { const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStartResponse({ cold: false }); - const mockHub = getMockHub(); - integration.setupOnce(addGlobalEventProcessor, () => mockHub); + setup(integration); // wait for internal promises to resolve, fetch app start data from mocked native await Promise.resolve(); - const transaction = mockHub.getScope()?.getTransaction(); - expect(transaction).toBeUndefined(); + expect(getActiveSpan()).toBeUndefined(); - const routeTransaction = routingInstrumentation.onRouteWillChange({ - name: 'test', - }) as IdleTransaction; - routeTransaction.initSpanRecorder(10); + routingInstrumentation.onRouteWillChange({ + name: 'Route Change', + }); - expect(routeTransaction).toBeDefined(); - expect(routeTransaction).toBe(mockHub.getScope()?.getTransaction()); + 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); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); - // @ts-expect-error access private for test - expect(routeTransaction._measurements[APP_START_WARM].value).toBe( + const routeTransaction = client.event; + expect(routeTransaction!.measurements![APP_START_WARM].value).toBe( timeOriginMilliseconds - appStartTimeMilliseconds, ); - expect(routeTransaction.op).toBe(UI_LOAD); - expect(routeTransaction.startTimestamp).toBe(appStartTimeMilliseconds / 1000); - - const spanRecorder = routeTransaction.spanRecorder; - expect(spanRecorder).toBeDefined(); - expect(spanRecorder?.spans.length).toBeGreaterThan(1); - - const span = spanRecorder?.spans[spanRecorder?.spans.length - 1]; + expect(routeTransaction!.contexts!.trace!.op).toBe(UI_LOAD); + expect(routeTransaction!.start_timestamp).toBe(appStartTimeMilliseconds / 1000); - expect(span?.op).toBe(APP_START_WARM_OP); - expect(span?.description).toBe('Warm App Start'); - expect(span?.startTimestamp).toBe(appStartTimeMilliseconds / 1000); - expect(span?.endTimestamp).toBe(timeOriginMilliseconds / 1000); + const span = spanToJSON(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 update route transaction if didFetchAppStart == true', async () => { const routingInstrumentation = new RoutingInstrumentation(); const integration = new ReactNativeTracing({ + enableStallTracking: false, routingInstrumentation, }); const [, appStartTimeMilliseconds] = mockAppStartResponse({ cold: false, didFetchAppStart: true }); - const mockHub = getMockHub(); - integration.setupOnce(addGlobalEventProcessor, () => mockHub); + setup(integration); // wait for internal promises to resolve, fetch app start data from mocked native await Promise.resolve(); - const transaction = mockHub.getScope()?.getTransaction(); - expect(transaction).toBeUndefined(); + expect(getActiveSpan()).toBeUndefined(); - const routeTransaction = routingInstrumentation.onRouteWillChange({ - name: 'test', - }) as IdleTransaction; - routeTransaction.initSpanRecorder(10); + routingInstrumentation.onRouteWillChange({ + name: 'Route Change', + }); - expect(routeTransaction).toBeDefined(); - expect(routeTransaction).toBe(mockHub.getScope()?.getTransaction()); + 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); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); - // @ts-expect-error access private for test - expect(routeTransaction._measurements).toMatchObject({}); - - expect(routeTransaction.op).not.toBe(UI_LOAD); - expect(routeTransaction.startTimestamp).not.toBe(appStartTimeMilliseconds / 1000); - - const spanRecorder = routeTransaction.spanRecorder; - expect(spanRecorder).toBeDefined(); - expect(spanRecorder?.spans.length).toBe(2); + 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); // TODO: check why originally was 2 }); }); @@ -636,58 +477,55 @@ describe('ReactNativeTracing', () => { const integration = new ReactNativeTracing({ enableAppStartTracking: false, }); - const mockHub = getMockHub(); - integration.setupOnce(addGlobalEventProcessor, () => mockHub); + setup(integration); await jest.advanceTimersByTimeAsync(500); + await jest.runOnlyPendingTimersAsync(); - expect(NATIVE.fetchNativeAppStart).not.toBeCalled(); - - const transaction = mockHub.getScope()?.getTransaction(); - + 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(); - const mockHub = getMockHub(); - integration.setupOnce(addGlobalEventProcessor, () => mockHub); + setup(integration); await jest.advanceTimersByTimeAsync(500); + await jest.runOnlyPendingTimersAsync(); - expect(NATIVE.fetchNativeAppStart).not.toBeCalled(); - - const transaction = mockHub.getScope()?.getTransaction(); - + 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(); - const mockHub = getMockHub(); - integration.setupOnce(addGlobalEventProcessor, () => mockHub); + setup(integration); await jest.advanceTimersByTimeAsync(500); + await jest.runOnlyPendingTimersAsync(); - expect(NATIVE.fetchNativeAppStart).toBeCalledTimes(1); - - const transaction = mockHub.getScope()?.getTransaction(); - + const transaction = client.event; expect(transaction).toBeUndefined(); + expect(NATIVE.fetchNativeAppStart).toBeCalledTimes(1); }); }); describe('Native Frames', () => { + beforeEach(() => { + setupTestClient(); + }); + it('Initialize native frames instrumentation if flag is true', async () => { const integration = new ReactNativeTracing({ enableNativeFramesTracking: true, }); - const mockHub = getMockHub(); - integration.setupOnce(addGlobalEventProcessor, () => mockHub); + setup(integration); await jest.advanceTimersByTimeAsync(500); @@ -698,8 +536,8 @@ describe('ReactNativeTracing', () => { const integration = new ReactNativeTracing({ enableNativeFramesTracking: false, }); - const mockHub = getMockHub(); - integration.setupOnce(addGlobalEventProcessor, () => mockHub); + + setup(integration); await jest.advanceTimersByTimeAsync(500); @@ -710,78 +548,34 @@ describe('ReactNativeTracing', () => { }); describe('Routing Instrumentation', () => { + let client: TestClient; + + beforeEach(() => { + client = setupTestClient(); + }); + describe('_onConfirmRoute', () => { - it('Sets app context, tag and adds breadcrumb', () => { + it('Sets app context', async () => { const routing = new RoutingInstrumentation(); const integration = new ReactNativeTracing({ routingInstrumentation: routing, }); - let mockEvent: Event | null = { contexts: {} }; - const mockScope = { - addBreadcrumb: jest.fn(), - setTag: jest.fn(), - setContext: jest.fn(), - - // Not relevant to test - setSpan: () => {}, - getTransaction: () => {}, - clearTransaction: () => {}, - }; - const mockHub = { - configureScope: (callback: (scope: any) => void) => { - callback(mockScope); - }, + client.addIntegration(integration); + setup(integration); - // Not relevant to test - getScope: () => mockScope, - getClient: () => ({ - getOptions: () => ({}), - recordDroppedEvent: () => {}, - }), - }; - integration.setupOnce( - () => {}, - () => mockHub as any, - ); + routing.onRouteWillChange({ name: 'First Route' }); + await jest.advanceTimersByTimeAsync(500); + await jest.runOnlyPendingTimersAsync(); - const routeContext = { - name: 'Route', - data: { - route: { - name: 'Route', - }, - previousRoute: { - name: 'Previous Route', - }, - }, - }; - routing.onRouteWillChange(routeContext); - - mockEvent = integration['_getCurrentViewEventProcessor'](mockEvent); - - if (!mockEvent) { - throw new Error('mockEvent was not defined'); - } - expect(mockEvent.contexts?.app).toBeDefined(); - // Only required to mark app as defined. - if (mockEvent.contexts?.app) { - expect(mockEvent.contexts.app['view_names']).toEqual([routeContext.name]); - } - - /** - * @deprecated tag routing.route.name will be removed in the future. - */ - expect(mockScope.setTag).toBeCalledWith('routing.route.name', routeContext.name); - expect(mockScope.addBreadcrumb).toBeCalledWith({ - type: 'navigation', - category: 'navigation', - message: `Navigation to ${routeContext.name}`, - data: { - from: routeContext.data.previousRoute.name, - to: routeContext.data.route.name, - }, - }); + routing.onRouteWillChange({ name: 'Second Route' }); + await jest.advanceTimersByTimeAsync(500); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + const transaction = client.event; + expect(transaction!.contexts!.app).toBeDefined(); + expect(transaction!.contexts!.app!['view_names']).toEqual(['Second Route']); }); describe('View Names event processor', () => { @@ -885,27 +679,25 @@ describe('ReactNativeTracing', () => { }); describe('User Interaction Tracing', () => { - let mockedScope: Scope; - let mockedHub: Hub; + let client: TestClient; let tracing: ReactNativeTracing; let mockedUserInteractionId: { elementId: string | undefined; op: string }; let mockedRoutingInstrumentation: MockedRoutingInstrumentation; beforeEach(() => { mockedUserInteractionId = { elementId: 'mockedElementId', op: 'mocked.op' }; - mockedHub = getMockHub(); - mockedScope = mockedHub.getScope()!; + client = setupTestClient(); mockedRoutingInstrumentation = createMockedRoutingInstrumentation(); }); describe('disabled user interaction', () => { test('User interaction tracing is disabled by default', () => { tracing = new ReactNativeTracing(); - tracing.setupOnce(jest.fn(), () => mockedHub); + setup(tracing); tracing.startUserInteractionTransaction(mockedUserInteractionId); expect(tracing.options.enableUserInteractionTracing).toBeFalsy(); - expect(mockedScope.setSpan).not.toBeCalled(); + expect(getActiveSpan()).toBeUndefined(); }); }); @@ -915,19 +707,26 @@ describe('ReactNativeTracing', () => { routingInstrumentation: mockedRoutingInstrumentation, enableUserInteractionTracing: true, }); - tracing.setupOnce(jest.fn(), () => mockedHub); - mockedRoutingInstrumentation.registeredOnConfirmRoute!(mockedConfirmedRouteTransactionContext); + setup(tracing); + mockedRoutingInstrumentation.registeredOnConfirmRoute!({ + name: 'mockedTransactionName', + data: { + route: { + name: 'mockedRouteName', + }, + }, + }); }); test('user interaction tracing is enabled and transaction is bound to scope', () => { tracing.startUserInteractionTransaction(mockedUserInteractionId); - const actualTransaction = mockFunction(mockedScope.setSpan).mock.calls[0][firstArg]; - const actualTransactionContext = actualTransaction?.toContext(); + const actualTransaction = getActiveSpan(); + const actualTransactionContext = spanToJSON(actualTransaction!); expect(tracing.options.enableUserInteractionTracing).toBeTruthy(); expect(actualTransactionContext).toEqual( expect.objectContaining({ - name: 'mockedRouteName.mockedElementId', + description: 'mockedRouteName.mockedElementId', op: 'mocked.op', }), ); @@ -935,26 +734,25 @@ describe('ReactNativeTracing', () => { test('UI event transaction not sampled if no child spans', () => { tracing.startUserInteractionTransaction(mockedUserInteractionId); + const actualTransaction = getActiveSpan(); jest.runAllTimers(); - const actualTransaction = mockFunction(mockedScope.setSpan).mock.calls[0][firstArg]; - const actualTransactionContext = actualTransaction?.toContext(); - expect(actualTransactionContext?.sampled).toEqual(false); + expect(actualTransaction).toBeDefined(); + expect(client.event).toBeUndefined(); }); test('does cancel UI event transaction when app goes to background', () => { tracing.startUserInteractionTransaction(mockedUserInteractionId); - - const actualTransaction = mockedScope.getTransaction() as Transaction | undefined; + const actualTransaction = getActiveSpan(); mockedAppState.setState('background'); jest.runAllTimers(); - const actualTransactionContext = actualTransaction?.toContext(); + const actualTransactionContext = spanToJSON(actualTransaction!); expect(actualTransactionContext).toEqual( expect.objectContaining({ - endTimestamp: expect.any(Number), + timestamp: expect.any(Number), status: 'cancelled', }), ); @@ -963,16 +761,16 @@ describe('ReactNativeTracing', () => { test('do not overwrite existing status of UI event transactions', () => { tracing.startUserInteractionTransaction(mockedUserInteractionId); + const actualTransaction = getActiveSpan(); - const actualTransaction = mockedScope.getTransaction() as Transaction | undefined; - actualTransaction?.setStatus('mocked_status' as SpanStatusType); + actualTransaction?.setStatus('mocked_status'); jest.runAllTimers(); - const actualTransactionContext = actualTransaction?.toContext(); + const actualTransactionContext = spanToJSON(actualTransaction!); expect(actualTransactionContext).toEqual( expect.objectContaining({ - endTimestamp: expect.any(Number), + timestamp: expect.any(Number), status: 'mocked_status', }), ); @@ -981,97 +779,113 @@ describe('ReactNativeTracing', () => { test('same UI event and same element does not reschedule idle timeout', () => { const timeoutCloseToActualIdleTimeoutMs = 800; tracing.startUserInteractionTransaction(mockedUserInteractionId); - const actualTransaction = mockedScope.getTransaction() as Transaction | undefined; + const actualTransaction = getActiveSpan(); jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); tracing.startUserInteractionTransaction(mockedUserInteractionId); jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); - expect(actualTransaction?.toContext().endTimestamp).toEqual(expect.any(Number)); + expect(spanToJSON(actualTransaction!).timestamp).toEqual(expect.any(Number)); }); - test('different UI event and same element finish first and start new transaction', () => { + test('different UI event and same element finish first and start new transaction', async () => { const timeoutCloseToActualIdleTimeoutMs = 800; tracing.startUserInteractionTransaction(mockedUserInteractionId); - const firstTransaction = mockedScope.getTransaction() as Transaction | undefined; + const firstTransaction = getActiveSpan(); jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); - const childFirstTransaction = firstTransaction?.startChild({ op: 'child.op' }); + const childFirstTransaction = startInactiveSpan({ name: 'Child Span of the first Tx', op: 'child.op' }); tracing.startUserInteractionTransaction({ ...mockedUserInteractionId, op: 'different.op' }); - const secondTransaction = mockedScope.getTransaction() as Transaction | undefined; + const secondTransaction = getActiveSpan(); jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); - childFirstTransaction?.finish(); - jest.runAllTimers(); + childFirstTransaction?.end(); + await jest.runAllTimersAsync(); + await client.flush(); - const firstTransactionContext = firstTransaction?.toContext(); - const secondTransactionContext = secondTransaction?.toContext(); - expect(firstTransactionContext).toEqual( + const firstTransactionEvent = client.eventQueue[0]; + expect(firstTransaction).toBeDefined(); + expect(firstTransactionEvent).toEqual( expect.objectContaining({ - endTimestamp: expect.any(Number), - op: 'mocked.op', - sampled: true, + timestamp: expect.any(Number), + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'mocked.op', + }), + }), }), ); - expect(secondTransactionContext).toEqual( + + expect(secondTransaction).toBeDefined(); + expect(spanToJSON(secondTransaction!)).toEqual( expect.objectContaining({ - endTimestamp: expect.any(Number), + timestamp: expect.any(Number), op: 'different.op', }), ); - expect(firstTransactionContext!.endTimestamp).toBeGreaterThanOrEqual(secondTransactionContext!.startTimestamp!); + expect(firstTransactionEvent!.timestamp).toBeGreaterThanOrEqual( + spanToJSON(secondTransaction!).start_timestamp!, + ); }); - test('different UI event and same element finish first transaction with last span', () => { + test('different UI event and same element finish first transaction with last span', async () => { const timeoutCloseToActualIdleTimeoutMs = 800; tracing.startUserInteractionTransaction(mockedUserInteractionId); - const firstTransaction = mockedScope.getTransaction() as Transaction | undefined; + const firstTransaction = getActiveSpan(); jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); - const childFirstTransaction = firstTransaction?.startChild({ op: 'child.op' }); + const childFirstTransaction = startInactiveSpan({ name: 'Child Span of the first Tx', op: 'child.op' }); tracing.startUserInteractionTransaction({ ...mockedUserInteractionId, op: 'different.op' }); jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); - childFirstTransaction?.finish(); + childFirstTransaction?.end(); + await jest.runAllTimersAsync(); + await client.flush(); - const firstTransactionContext = firstTransaction?.toContext(); - expect(firstTransactionContext).toEqual( + const firstTransactionEvent = client.eventQueue[0]; + expect(firstTransaction).toBeDefined(); + expect(firstTransactionEvent).toEqual( expect.objectContaining({ - endTimestamp: expect.any(Number), - op: 'mocked.op', - sampled: true, + timestamp: expect.any(Number), + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'mocked.op', + }), + }), }), ); }); test('same ui event after UI event transaction finished', () => { tracing.startUserInteractionTransaction(mockedUserInteractionId); - const firstTransaction = mockedScope.getTransaction() as Transaction | undefined; + const firstTransaction = getActiveSpan(); jest.runAllTimers(); tracing.startUserInteractionTransaction(mockedUserInteractionId); - const secondTransaction = mockedScope.getTransaction() as Transaction | undefined; + const secondTransaction = getActiveSpan(); jest.runAllTimers(); - const firstTransactionContext = firstTransaction?.toContext(); - const secondTransactionContext = secondTransaction?.toContext(); - expect(firstTransactionContext!.endTimestamp).toEqual(expect.any(Number)); - expect(secondTransactionContext!.endTimestamp).toEqual(expect.any(Number)); - expect(firstTransactionContext!.spanId).not.toEqual(secondTransactionContext!.spanId); + 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 = new Transaction({ name: 'activeTransactionOnScope' }, mockedHub); - mockedScope.setSpan(activeTransaction); + const activeTransaction = startSpanManual( + { name: 'activeTransactionOnScope', scope: getCurrentScope() }, + span => span, + ); + expect(activeTransaction).toBeDefined(); + expect(activeTransaction).toBe(getActiveSpan()); tracing.startUserInteractionTransaction(mockedUserInteractionId); - - expect(mockedScope.setSpan).toBeCalledTimes(1); - expect(mockedScope.setSpan).toBeCalledWith(activeTransaction); + expect(activeTransaction).toBe(getActiveSpan()); }); test('UI event transaction is canceled when routing transaction starts', () => { const timeoutCloseToActualIdleTimeoutMs = 800; tracing.startUserInteractionTransaction(mockedUserInteractionId); - const interactionTransaction = mockedScope.getTransaction() as Transaction | undefined; + const interactionTransaction = getActiveSpan(); jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); const routingTransaction = mockedRoutingInstrumentation.registeredListener!({ @@ -1079,36 +893,23 @@ describe('ReactNativeTracing', () => { }); jest.runAllTimers(); - const interactionTransactionContext = interactionTransaction?.toContext(); - const routingTransactionContext = routingTransaction?.toContext(); + const interactionTransactionContext = spanToJSON(interactionTransaction!); + const routingTransactionContext = spanToJSON(routingTransaction!); expect(interactionTransactionContext).toEqual( expect.objectContaining({ - endTimestamp: expect.any(Number), + timestamp: expect.any(Number), status: 'cancelled', }), ); expect(routingTransactionContext).toEqual( expect.objectContaining({ - endTimestamp: expect.any(Number), + timestamp: expect.any(Number), }), ); - expect(interactionTransactionContext!.endTimestamp).toBeLessThanOrEqual( - routingTransactionContext!.startTimestamp!, + expect(interactionTransactionContext!.timestamp).toBeLessThanOrEqual( + routingTransactionContext!.start_timestamp!, ); }); - - test('UI event transaction calls lifecycle callbacks', () => { - tracing.onTransactionStart = jest.fn(tracing.onTransactionStart.bind(tracing)); - tracing.onTransactionFinish = jest.fn(tracing.onTransactionFinish.bind(tracing)); - tracing.startUserInteractionTransaction(mockedUserInteractionId); - const actualTransaction = mockedScope.getTransaction() as Transaction | undefined; - jest.runAllTimers(); - - expect(tracing.onTransactionStart).toBeCalledTimes(1); - expect(tracing.onTransactionFinish).toBeCalledTimes(1); - expect(tracing.onTransactionStart).toBeCalledWith(actualTransaction); - expect(tracing.onTransactionFinish).toBeCalledWith(actualTransaction); - }); }); }); }); @@ -1127,3 +928,7 @@ function mockAppStartResponse({ cold, didFetchAppStart }: { cold: boolean; didFe return [timeOriginMilliseconds, appStartTimeMilliseconds]; } + +function setup(integration: ReactNativeTracing) { + integration.setupOnce(addGlobalEventProcessor, getCurrentHub); +} diff --git a/test/tracing/reactnavigation.test.ts b/test/tracing/reactnavigation.test.ts index 141b65ae8d..f405df4d0c 100644 --- a/test/tracing/reactnavigation.test.ts +++ b/test/tracing/reactnavigation.test.ts @@ -1,17 +1,34 @@ /* eslint-disable deprecation/deprecation */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { Transaction } from '@sentry/core'; -import type { TransactionContext } from '@sentry/types'; - +import { + addGlobalEventProcessor, + getCurrentHub, + getCurrentScope, + getGlobalScope, + getIsolationScope, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + setCurrentClient, + Transaction, +} from '@sentry/core'; + +import { ReactNativeTracing } from '../../src/js'; import type { NavigationRoute } from '../../src/js/tracing/reactnavigation'; -import { BLANK_TRANSACTION_CONTEXT, ReactNavigationInstrumentation } from '../../src/js/tracing/reactnavigation'; +import { ReactNavigationInstrumentation } from '../../src/js/tracing/reactnavigation'; +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'; const dummyRoute = { name: 'Route', key: '0', }; +jest.useFakeTimers({ advanceTimers: true }); + class MockNavigationContainer { currentRoute: NavigationRoute | undefined = dummyRoute; listeners: Record void> = {}; @@ -23,251 +40,236 @@ class MockNavigationContainer { } } -const getMockTransaction = () => { - const transaction = new Transaction(BLANK_TRANSACTION_CONTEXT); - - // Assume it's sampled - transaction.sampled = true; - - return transaction; -}; - describe('ReactNavigationInstrumentation', () => { - afterEach(() => { + let client: TestClient; + let mockNavigation: ReturnType; + + beforeEach(() => { RN_GLOBAL_OBJ.__sentry_rn_v5_registered = false; - jest.resetAllMocks(); + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); }); - test('transaction set on initialize', () => { - const instrumentation = new ReactNavigationInstrumentation(); - - const mockTransaction = getMockTransaction(); - const tracingListener = jest.fn(() => mockTransaction); - instrumentation.registerRoutingInstrumentation( - tracingListener as any, - context => context, - () => {}, + test('transaction set on initialize', async () => { + setupTestClient(); + jest.runOnlyPendingTimers(); // Flush the init transaction + + await client.flush(); + + const actualEvent = client.event; + expect(actualEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'Initial Screen', + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + data: { + route: { + hasBeenSeen: false, + key: 'initial_screen', + name: 'Initial Screen', + params: {}, + }, + previousRoute: null, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }, + op: 'navigation', + origin: 'manual', + tags: expect.objectContaining({ + 'routing.instrumentation': 'react-navigation-v5', + 'routing.route.name': 'Initial Screen', + }), + }), + }), + }), ); - - const mockNavigationContainerRef = { - current: new MockNavigationContainer(), - }; - - instrumentation.registerNavigationContainer(mockNavigationContainerRef as any); - - expect(mockTransaction.name).toBe(dummyRoute.name); - expect(mockTransaction.tags).toStrictEqual({ - ...BLANK_TRANSACTION_CONTEXT.tags, - 'routing.route.name': dummyRoute.name, - }); - expect(mockTransaction.data).toStrictEqual({ - route: { - name: dummyRoute.name, - key: dummyRoute.key, - params: {}, - hasBeenSeen: false, - }, - previousRoute: null, - 'sentry.op': 'navigation', - 'sentry.origin': 'manual', - }); - expect(mockTransaction.metadata.source).toBe('component'); }); test('transaction sent on navigation', async () => { - const instrumentation = new ReactNavigationInstrumentation(); - - // Need a dummy transaction as the instrumentation will start a transaction right away when the first navigation container is attached. - const mockTransactionDummy = getMockTransaction(); - const transactionRef = { - current: mockTransactionDummy, - }; - const tracingListener = jest.fn(() => transactionRef.current); - instrumentation.registerRoutingInstrumentation( - tracingListener as any, - context => context, - () => {}, + setupTestClient(); + jest.runOnlyPendingTimers(); // Flush the init transaction + + mockNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); // Flush the navigation transaction + + await client.flush(); + + const actualEvent = client.event; + expect(actualEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'New Screen', + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + data: { + route: { + hasBeenSeen: false, + key: 'new_screen', + name: 'New Screen', + params: {}, + }, + previousRoute: { + key: 'initial_screen', + name: 'Initial Screen', + params: {}, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }, + op: 'navigation', + origin: 'manual', + tags: expect.objectContaining({ + 'routing.instrumentation': 'react-navigation-v5', + 'routing.route.name': 'New Screen', + }), + }), + }), + }), ); + }); - const mockNavigationContainerRef = { - current: new MockNavigationContainer(), - }; - - instrumentation.registerNavigationContainer(mockNavigationContainerRef as any); - - const mockTransaction = getMockTransaction(); - transactionRef.current = mockTransaction; - - mockNavigationContainerRef.current.listeners['__unsafe_action__']({}); - - await new Promise(resolve => { - setTimeout(() => { - const route = { - name: 'New Route', - key: '1', - params: { - someParam: 42, - }, - }; - // If .getCurrentRoute() is undefined, ignore state change - mockNavigationContainerRef.current.currentRoute = undefined; - mockNavigationContainerRef.current.listeners['state']({}); - - mockNavigationContainerRef.current.currentRoute = route; - mockNavigationContainerRef.current.listeners['state']({}); - - expect(mockTransaction.name).toBe(route.name); - expect(mockTransaction.tags).toStrictEqual({ - ...BLANK_TRANSACTION_CONTEXT.tags, - 'routing.route.name': route.name, - }); - expect(mockTransaction.data).toStrictEqual({ - route: { - name: route.name, - key: route.key, - params: {}, // expect the data to be stripped - hasBeenSeen: false, - }, - previousRoute: { - name: dummyRoute.name, - key: dummyRoute.key, - params: {}, - }, - 'sentry.op': 'navigation', - 'sentry.origin': 'manual', - }); - expect(mockTransaction.metadata.source).toBe('component'); - - resolve(); - }, 50); - }); + test('transaction has correct metadata after multiple navigations', async () => { + setupTestClient(); + jest.runOnlyPendingTimers(); // Flush the init transaction + + mockNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); // Flush the navigation transaction + + mockNavigation.navigateToSecondScreen(); + jest.runOnlyPendingTimers(); // Flush the navigation transaction + + await client.flush(); + + const actualEvent = client.event; + expect(actualEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'Second Screen', + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + data: { + route: { + hasBeenSeen: false, + key: 'second_screen', + name: 'Second Screen', + params: {}, + }, + previousRoute: { + key: 'new_screen', + name: 'New Screen', + params: {}, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }, + op: 'navigation', + origin: 'manual', + tags: expect.objectContaining({ + 'routing.instrumentation': 'react-navigation-v5', + 'routing.route.name': 'Second Screen', + }), + }), + }), + }), + ); }); test('transaction context changed with beforeNavigate', async () => { - const instrumentation = new ReactNavigationInstrumentation(); - - // Need a dummy transaction as the instrumentation will start a transaction right away when the first navigation container is attached. - const mockTransactionDummy = getMockTransaction(); - const transactionRef = { - current: mockTransactionDummy, - }; - const tracingListener = jest.fn(() => transactionRef.current); - instrumentation.registerRoutingInstrumentation( - tracingListener as any, - context => { - context.sampled = false; - context.name = 'New Name'; - - return context; + setupTestClient({ + beforeNavigate: span => { + span.name = 'New Span Name'; + return span; }, - () => {}, - ); - - const mockNavigationContainerRef = { - current: new MockNavigationContainer(), - }; - - instrumentation.registerNavigationContainer(mockNavigationContainerRef as any); - - const mockTransaction = getMockTransaction(); - transactionRef.current = mockTransaction; - - mockNavigationContainerRef.current.listeners['__unsafe_action__']({}); - - await new Promise(resolve => { - setTimeout(() => { - const route = { - name: 'DoNotSend', - key: '1', - }; - mockNavigationContainerRef.current.currentRoute = route; - mockNavigationContainerRef.current.listeners['state']({}); - - expect(mockTransaction.sampled).toBe(false); - expect(mockTransaction.name).toBe('New Name'); - expect(mockTransaction.description).toBe('New Name'); - expect(mockTransaction.metadata.source).toBe('custom'); - resolve(); - }, 50); }); + jest.runOnlyPendingTimers(); // Flush the init transaction + + mockNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); // Flush the navigation transaction + + await client.flush(); + + const actualEvent = client.event; + expect(actualEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'New Span Name', + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + data: { + route: { + hasBeenSeen: false, + key: 'new_screen', + name: 'New Screen', + params: {}, + }, + previousRoute: { + key: 'initial_screen', + name: 'Initial Screen', + params: {}, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }, + op: 'navigation', + origin: 'manual', + tags: expect.objectContaining({ + 'routing.instrumentation': 'react-navigation-v5', + 'routing.route.name': 'New Screen', + }), + }), + }), + }), + ); }); test('transaction not sent on a cancelled navigation', async () => { - const instrumentation = new ReactNavigationInstrumentation(); - - // Need a dummy transaction as the instrumentation will start a transaction right away when the first navigation container is attached. - const mockTransactionDummy = getMockTransaction(); - const transactionRef = { - current: mockTransactionDummy, - }; - const tracingListener = jest.fn(() => transactionRef.current); - instrumentation.registerRoutingInstrumentation( - tracingListener as any, - context => context, - () => {}, - ); - - const mockNavigationContainerRef = { - current: new MockNavigationContainer(), - }; - - instrumentation.registerNavigationContainer(mockNavigationContainerRef as any); + setupTestClient(); + jest.runOnlyPendingTimers(); // Flush the init transaction - const mockTransaction = getMockTransaction(); - transactionRef.current = mockTransaction; + mockNavigation.emitCancelledNavigation(); + jest.runOnlyPendingTimers(); // Flush the cancelled navigation - mockNavigationContainerRef.current.listeners['__unsafe_action__']({}); + await client.flush(); - await new Promise(resolve => { - setTimeout(() => { - expect(mockTransaction.sampled).toBe(false); - expect(mockTransaction.name).toStrictEqual(BLANK_TRANSACTION_CONTEXT.name); - expect(mockTransaction.tags).toStrictEqual(BLANK_TRANSACTION_CONTEXT.tags); - expect(mockTransaction.data).toStrictEqual({}); - resolve(); - }, 1100); - }); + expect(client.eventQueue.length).toBe(1); + expect(client.event).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'Initial Screen', + }), + ); }); test('transaction not sent on multiple cancelled navigations', async () => { - const instrumentation = new ReactNavigationInstrumentation(); - - // Need a dummy transaction as the instrumentation will start a transaction right away when the first navigation container is attached. - const mockTransactionDummy = getMockTransaction(); - const transactionRef = { - current: mockTransactionDummy, - }; - const tracingListener = jest.fn(() => transactionRef.current); - instrumentation.registerRoutingInstrumentation( - tracingListener as any, - context => context, - () => {}, - ); - - const mockNavigationContainerRef = { - current: new MockNavigationContainer(), - }; - - instrumentation.registerNavigationContainer(mockNavigationContainerRef as any); + setupTestClient(); + jest.runOnlyPendingTimers(); // Flush the init transaction - const mockTransaction1 = getMockTransaction(); - transactionRef.current = mockTransaction1; + mockNavigation.emitCancelledNavigation(); + jest.runOnlyPendingTimers(); // Flush the cancelled navigation - mockNavigationContainerRef.current.listeners['__unsafe_action__']({}); + mockNavigation.emitCancelledNavigation(); + jest.runOnlyPendingTimers(); // Flush the cancelled navigation - const mockTransaction2 = getMockTransaction(); - transactionRef.current = mockTransaction2; + await client.flush(); - mockNavigationContainerRef.current.listeners['__unsafe_action__']({}); - - await new Promise(resolve => { - setTimeout(() => { - expect(mockTransaction1.sampled).toBe(false); - expect(mockTransaction2.sampled).toBe(false); - resolve(); - }, 1100); - }); + expect(client.eventQueue.length).toBe(1); + expect(client.event).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'Initial Screen', + }), + ); }); describe('navigation container registration', () => { @@ -316,7 +318,7 @@ describe('ReactNavigationInstrumentation', () => { const mockNavigationContainer = new MockNavigationContainer(); instrumentation.registerNavigationContainer(mockNavigationContainer); - const mockTransaction = getMockTransaction(); + const mockTransaction = new Transaction({ name: 'Test' }); const tracingListener = jest.fn(() => mockTransaction); instrumentation.registerRoutingInstrumentation( tracingListener as any, @@ -324,22 +326,19 @@ describe('ReactNavigationInstrumentation', () => { () => {}, ); - await new Promise(resolve => { - setTimeout(() => { - expect(mockTransaction.sampled).not.toBe(false); - resolve(); - }, 500); - }); + await jest.runOnlyPendingTimersAsync(); + + expect(mockTransaction['_sampled']).not.toBe(false); }); }); describe('options', () => { - test('waits until routeChangeTimeoutMs', async () => { + test('waits until routeChangeTimeoutMs', () => { const instrumentation = new ReactNavigationInstrumentation({ routeChangeTimeoutMs: 200, }); - const mockTransaction = getMockTransaction(); + const mockTransaction = new Transaction({ name: 'Test', sampled: true }); const tracingListener = jest.fn(() => mockTransaction); instrumentation.registerRoutingInstrumentation( tracingListener as any, @@ -351,113 +350,47 @@ describe('ReactNavigationInstrumentation', () => { current: new MockNavigationContainer(), }; - return new Promise(resolve => { - setTimeout(() => { - instrumentation.registerNavigationContainer(mockNavigationContainerRef as any); - - expect(mockTransaction.sampled).toBe(true); - expect(mockTransaction.name).toBe(dummyRoute.name); - - resolve(); - }, 190); - }); - }); - - test('discards if after routeChangeTimeoutMs', async () => { - const instrumentation = new ReactNavigationInstrumentation({ - routeChangeTimeoutMs: 200, - }); + instrumentation.registerNavigationContainer(mockNavigationContainerRef as any); + mockNavigationContainerRef.current.listeners['__unsafe_action__']({}); - const mockTransaction = getMockTransaction(); - const tracingListener = jest.fn(() => mockTransaction); - instrumentation.registerRoutingInstrumentation( - tracingListener as any, - context => context, - () => {}, - ); + jest.advanceTimersByTime(190); - const mockNavigationContainerRef = { - current: new MockNavigationContainer(), - }; + expect(mockTransaction['_sampled']).toBe(true); + expect(mockTransaction['_name']).toBe('Route'); - return new Promise(resolve => { - setTimeout(() => { - instrumentation.registerNavigationContainer(mockNavigationContainerRef as any); + jest.advanceTimersByTime(20); - expect(mockTransaction.sampled).toBe(false); - resolve(); - }, 210); - }); + expect(mockTransaction['_sampled']).toBe(false); }); }); - describe('onRouteConfirmed', () => { - test('onRouteConfirmed called with correct route data', () => { - const instrumentation = new ReactNavigationInstrumentation(); - - // Need a dummy transaction as the instrumentation will start a transaction right away when the first navigation container is attached. - const mockTransactionDummy = getMockTransaction(); - const transactionRef = { - current: mockTransactionDummy, - }; - let confirmedContext: TransactionContext | undefined; - const tracingListener = jest.fn(() => transactionRef.current); - instrumentation.registerRoutingInstrumentation( - tracingListener as any, - context => context, - context => { - confirmedContext = context; - }, - ); - - const mockNavigationContainerRef = { - current: new MockNavigationContainer(), - }; - - instrumentation.registerNavigationContainer(mockNavigationContainerRef as any); - - const mockTransaction = getMockTransaction(); - transactionRef.current = mockTransaction; - - mockNavigationContainerRef.current.listeners['__unsafe_action__']({}); - - const route1 = { - name: 'New Route 1', - key: '1', - params: { - someParam: 42, - }, - }; - - mockNavigationContainerRef.current.currentRoute = route1; - mockNavigationContainerRef.current.listeners['state']({}); - - mockNavigationContainerRef.current.listeners['__unsafe_action__']({}); - - const route2 = { - name: 'New Route 2', - key: '2', - params: { - someParam: 42, - }, - }; + function setupTestClient( + setupOptions: { + beforeNavigate?: BeforeNavigate; + } = {}, + ) { + const rNavigation = new ReactNavigationInstrumentation({ + routeChangeTimeoutMs: 200, + }); + mockNavigation = createMockNavigationAndAttachTo(rNavigation); + + const rnTracing = new ReactNativeTracing({ + routingInstrumentation: rNavigation, + enableStallTracking: false, + enableNativeFramesTracking: false, + enableAppStartTracking: false, + beforeNavigate: setupOptions.beforeNavigate || (span => span), + }); - mockNavigationContainerRef.current.currentRoute = route2; - mockNavigationContainerRef.current.listeners['state']({}); - - expect(confirmedContext).toBeDefined(); - if (confirmedContext) { - expect(confirmedContext.name).toBe(route2.name); - expect(confirmedContext.metadata).toBeUndefined(); - expect(confirmedContext.data).toBeDefined(); - if (confirmedContext.data) { - expect(confirmedContext.data.route.name).toBe(route2.name); - expect(confirmedContext.data.previousRoute).toBeDefined(); - if (confirmedContext.data.previousRoute) { - expect(confirmedContext.data.previousRoute.name).toBe(route1.name); - } - } - } + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1.0, + integrations: [rnTracing], }); - }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + // We have to call this manually as setupOnce is executed once per runtime (global var check) + rnTracing.setupOnce(addGlobalEventProcessor, getCurrentHub); + } }); diff --git a/test/tracing/reactnavigationutils.ts b/test/tracing/reactnavigationutils.ts index ef278b6c11..5bdfa5f199 100644 --- a/test/tracing/reactnavigationutils.ts +++ b/test/tracing/reactnavigationutils.ts @@ -3,6 +3,11 @@ import type { NavigationRoute, ReactNavigationInstrumentation } from '../../src/ export function createMockNavigationAndAttachTo(sut: ReactNavigationInstrumentation) { const mockedNavigationContained = mockNavigationContainer(); const mockedNavigation = { + emitCancelledNavigation: () => { + mockedNavigationContained.listeners['__unsafe_action__']({ + // this object is not used by the instrumentation + }); + }, navigateToNewScreen: () => { mockedNavigationContained.listeners['__unsafe_action__']({ // this object is not used by the instrumentation @@ -15,6 +20,18 @@ export function createMockNavigationAndAttachTo(sut: ReactNavigationInstrumentat // this object is not used by the instrumentation }); }, + navigateToSecondScreen: () => { + mockedNavigationContained.listeners['__unsafe_action__']({ + // this object is not used by the instrumentation + }); + mockedNavigationContained.currentRoute = { + key: 'second_screen', + name: 'Second Screen', + }; + mockedNavigationContained.listeners['state']({ + // this object is not used by the instrumentation + }); + }, navigateToInitialScreen: () => { mockedNavigationContained.listeners['__unsafe_action__']({ // this object is not used by the instrumentation diff --git a/test/tracing/stalltracking.test.ts b/test/tracing/stalltracking.test.ts index 093682e1ea..dab30e0259 100644 --- a/test/tracing/stalltracking.test.ts +++ b/test/tracing/stalltracking.test.ts @@ -7,7 +7,6 @@ import { setCurrentClient, startSpan, startSpanManual, - startTransaction, } from '@sentry/core'; import type { Span } from '@sentry/types'; import { timestampInSeconds } from '@sentry/utils'; @@ -110,15 +109,15 @@ describe('StallTracking', () => { it('Stall tracking timeout is stopped after finishing all transactions (multiple)', async () => { // new `startSpan` API doesn't allow creation of multiple transactions - const t0 = startTransaction({ name: 'Test Transaction 0' }); - const t1 = startTransaction({ name: 'Test Transaction 1' }); - const t2 = startTransaction({ name: 'Test Transaction 2' }); + const t0 = startSpanManual({ name: 'Test Transaction 0', forceTransaction: true }, span => span); + const t1 = startSpanManual({ name: 'Test Transaction 1', forceTransaction: true }, span => span); + const t2 = startSpanManual({ name: 'Test Transaction 2', forceTransaction: true }, span => span); - t0.end(); + t0!.end(); jest.runOnlyPendingTimers(); - t1.end(); + t1!.end(); jest.runOnlyPendingTimers(); - t2.end(); + t2!.end(); jest.runOnlyPendingTimers(); await client.flush(); @@ -144,9 +143,9 @@ describe('StallTracking', () => { expectStallMeasurements(client.event?.measurements); }); - it("Stall tracking returns null on a custom endTimestamp that is not a span's", async () => { - startSpanManual({ name: 'Stall will happen during this span', trimEnd: false }, (rootSpan: Span | undefined) => { - rootSpan!.end(timestampInSeconds()); + it('Stall tracking returns null on a custom endTimestamp that is not near now', async () => { + startSpanManual({ name: 'Stall will happen during this span' }, (rootSpan: Span | undefined) => { + rootSpan!.end(timestampInSeconds() - 1); }); await client.flush(); @@ -171,6 +170,9 @@ describe('StallTracking', () => { expectStallMeasurements(client.event?.measurements); }); + /** + * @deprecated This behavior will be removed in the future. Replaced by close time proximity check. + **/ it('Stall tracking rejects endTimestamp that is from the last span if trimEnd is false (trimEnd case)', async () => { startSpanManual({ name: 'Stall will happen during this span', trimEnd: false }, (rootSpan: Span | undefined) => { let childSpanEnd: number | undefined = undefined; @@ -188,6 +190,9 @@ describe('StallTracking', () => { expect(client.event?.measurements).toBeUndefined(); }); + /** + * @deprecated This behavior will be removed in the future. Replaced by close time proximity check. + **/ it('Stall tracking rejects endTimestamp even if it is a span time (custom endTimestamp case)', async () => { startSpanManual({ name: 'Stall will happen during this span', trimEnd: false }, (rootSpan: Span | undefined) => { let childSpanEnd: number | undefined = undefined; @@ -206,7 +211,7 @@ describe('StallTracking', () => { }); it('Stall tracking ignores unfinished spans in normal transactions', async () => { - startSpan({ name: 'Stall will happen during this span', trimEnd: true }, () => { + startSpan({ name: 'Stall will happen during this span' }, () => { startSpan({ name: 'This child span will finish' }, () => { jest.runOnlyPendingTimers(); }); @@ -244,10 +249,10 @@ describe('StallTracking', () => { new Array(11) .fill(undefined) .map((_, i) => { - return startTransaction({ name: `Test Transaction ${i}` }); + return startSpanManual({ name: `Test Transaction ${i}`, forceTransaction: true }, span => span); }) .forEach(t => { - t.end(); + t!.end(); }); await client.flush(); diff --git a/test/tracing/timetodisplay.test.tsx b/test/tracing/timetodisplay.test.tsx index b5da8dfd81..42db2c115b 100644 --- a/test/tracing/timetodisplay.test.tsx +++ b/test/tracing/timetodisplay.test.tsx @@ -1,16 +1,16 @@ import * as mockedtimetodisplaynative from './mockedtimetodisplaynative'; jest.mock('../../src/js/tracing/timetodisplaynative', () => mockedtimetodisplaynative); -import type { Span as SpanClass } from '@sentry/core'; +import type { Span as SpanClass} from '@sentry/core'; import { getCurrentScope, getGlobalScope, getIsolationScope, setCurrentClient, spanToJSON, startSpanManual} from '@sentry/core'; -import type { Measurements, Span, SpanJSON} from '@sentry/types'; +import type { Event, Measurements, Span, SpanJSON} from '@sentry/types'; import React from "react"; import TestRenderer from 'react-test-renderer'; import { _addTracingExtensions } from '../../src/js/tracing/addTracingExtensions'; import { startTimeToFullDisplaySpan, startTimeToInitialDisplaySpan, TimeToFullDisplay, TimeToInitialDisplay } from '../../src/js/tracing/timetodisplay'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; -import { asObjectWithMeasurements, secondAgoTimestampMs, secondInFutureTimestampMs } from '../testutils'; +import { secondAgoTimestampMs, secondInFutureTimestampMs } from '../testutils'; import { emitNativeFullDisplayEvent, emitNativeInitialDisplayEvent } from './mockedtimetodisplaynative'; jest.useFakeTimers({advanceTimers: true}); @@ -25,7 +25,9 @@ describe('TimeToDisplay', () => { getIsolationScope().clear(); getGlobalScope().clear(); - const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0 }); + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1.0, + }); client = new TestClient(options); setCurrentClient(client); client.init(); @@ -35,11 +37,11 @@ describe('TimeToDisplay', () => { jest.clearAllMocks(); }); - test('creates manual initial display', () => { + test('creates manual initial display', async () => { const [testSpan, activeSpan] = startSpanManual( { name: 'Root Manual Span', - startTimestamp: secondAgoTimestampMs(), + startTime: secondAgoTimestampMs(), }, (activeSpan: Span | undefined) => { const testSpan = startTimeToInitialDisplaySpan(); @@ -49,20 +51,23 @@ describe('TimeToDisplay', () => { activeSpan?.end(); - return [testSpan, activeSpan]; + return [testSpan, activeSpan]; }, ); - expectInitialDisplayMeasurementOnSpan(activeSpan); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expectInitialDisplayMeasurementOnSpan(client.event!); expectFinishedInitialDisplaySpan(testSpan, activeSpan); expect(spanToJSON(testSpan!).start_timestamp).toEqual(spanToJSON(activeSpan!).start_timestamp); }); - test('creates manual full display', () => { + test('creates manual full display', async () => { const [testSpan, activeSpan] = startSpanManual( { name: 'Root Manual Span', - startTimestamp: secondAgoTimestampMs(), + startTime: secondAgoTimestampMs(), }, (activeSpan: Span | undefined) => { startTimeToInitialDisplaySpan(); @@ -79,16 +84,19 @@ describe('TimeToDisplay', () => { }, ); - expectFullDisplayMeasurementOnSpan(activeSpan); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expectFullDisplayMeasurementOnSpan(client.event!); expectFinishedFullDisplaySpan(testSpan, activeSpan); expect(spanToJSON(testSpan!).start_timestamp).toEqual(spanToJSON(activeSpan!).start_timestamp); }); - test('does not create full display when initial display is missing', () => { + test('does not create full display when initial display is missing', async () => { const [activeSpan] = startSpanManual( { name: 'Root Manual Span', - startTimestamp: secondAgoTimestampMs(), + startTime: secondAgoTimestampMs(), }, (activeSpan: Span | undefined) => { startTimeToFullDisplaySpan(); @@ -101,17 +109,20 @@ describe('TimeToDisplay', () => { }, ); - expectNoInitialDisplayMeasurementOnSpan(activeSpan); - expectNoFullDisplayMeasurementOnSpan(activeSpan); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expectNoInitialDisplayMeasurementOnSpan(client.event!); + expectNoFullDisplayMeasurementOnSpan(client.event!); expectNoTimeToDisplaySpans(activeSpan); }); - test('creates initial display for active span without initial display span', () => { + test('creates initial display for active span without initial display span', async () => { const [activeSpan] = startSpanManual( { name: 'Root Manual Span', - startTimestamp: secondAgoTimestampMs(), + startTime: secondAgoTimestampMs(), }, (activeSpan: Span | undefined) => { TestRenderer.create(); @@ -123,15 +134,18 @@ describe('TimeToDisplay', () => { }, ); - expectInitialDisplayMeasurementOnSpan(activeSpan); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expectInitialDisplayMeasurementOnSpan(client.event!); expectFinishedInitialDisplaySpan(getInitialDisplaySpan(activeSpan), activeSpan); }); - test('creates full display for active span without full display span', () => { + test('creates full display for active span without full display span', async () => { const [activeSpan] = startSpanManual( { name: 'Root Manual Span', - startTimestamp: secondAgoTimestampMs(), + startTime: secondAgoTimestampMs(), }, (activeSpan: Span | undefined) => { startTimeToInitialDisplaySpan(); @@ -148,15 +162,18 @@ describe('TimeToDisplay', () => { }, ); - expectFullDisplayMeasurementOnSpan(activeSpan); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expectFullDisplayMeasurementOnSpan(client.event!); expectFinishedFullDisplaySpan(getFullDisplaySpan(activeSpan), activeSpan); }); - test('cancels full display spans longer than 30s', () => { + test('cancels full display spans longer than 30s', async () => { const [activeSpan] = startSpanManual( { name: 'Root Manual Span', - startTimestamp: secondAgoTimestampMs(), + startTime: secondAgoTimestampMs(), }, (activeSpan: Span | undefined) => { startTimeToInitialDisplaySpan(); @@ -170,26 +187,30 @@ describe('TimeToDisplay', () => { jest.advanceTimersByTime(40_000); + activeSpan?.end(); return [activeSpan]; }, ); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + expectFinishedInitialDisplaySpan(getInitialDisplaySpan(activeSpan), activeSpan); expectDeadlineExceededFullDisplaySpan(getFullDisplaySpan(activeSpan), activeSpan); - expectInitialDisplayMeasurementOnSpan(activeSpan); - expectFullDisplayMeasurementOnSpan(activeSpan); - expect(asObjectWithMeasurements(activeSpan)._measurements!.time_to_full_display.value) - .toEqual(asObjectWithMeasurements(activeSpan)._measurements!.time_to_initial_display.value); + expectInitialDisplayMeasurementOnSpan(client.event!); + expectFullDisplayMeasurementOnSpan(client.event!); + expect(client.event!.measurements!.time_to_full_display.value) + .toEqual(client.event!.measurements!.time_to_initial_display.value); }); - test('full display which ended before initial display is extended to initial display end', () => { + test('full display which ended before initial display is extended to initial display end', async () => { const fullDisplayEndTimestampMs = secondInFutureTimestampMs(); const initialDisplayEndTimestampMs = secondInFutureTimestampMs() + 500; const [initialDisplaySpan, fullDisplaySpan, activeSpan] = startSpanManual( { name: 'Root Manual Span', - startTimestamp: secondAgoTimestampMs(), + startTime: secondAgoTimestampMs(), }, (activeSpan: Span | undefined) => { const initialDisplaySpan = startTimeToInitialDisplaySpan(); @@ -202,27 +223,31 @@ describe('TimeToDisplay', () => { emitNativeFullDisplayEvent(fullDisplayEndTimestampMs + 10); emitNativeInitialDisplayEvent(initialDisplayEndTimestampMs); + activeSpan?.end(); return [initialDisplaySpan, fullDisplaySpan, activeSpan]; }, ); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + expectFinishedInitialDisplaySpan(initialDisplaySpan, activeSpan); expectFinishedFullDisplaySpan(fullDisplaySpan, activeSpan); - expectInitialDisplayMeasurementOnSpan(activeSpan); - expectFullDisplayMeasurementOnSpan(activeSpan); + expectInitialDisplayMeasurementOnSpan(client.event!); + expectFullDisplayMeasurementOnSpan(client.event!); expect(spanToJSON(initialDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); expect(spanToJSON(fullDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); }); - test('consequent renders do not update display end', () => { + test('consequent renders do not update display end', async () => { const initialDisplayEndTimestampMs = secondInFutureTimestampMs(); const fullDisplayEndTimestampMs = secondInFutureTimestampMs() + 500; const [initialDisplaySpan, fullDisplaySpan, activeSpan] = startSpanManual( { name: 'Root Manual Span', - startTimestamp: secondAgoTimestampMs(), + startTime: secondAgoTimestampMs(), }, (activeSpan: Span | undefined) => { const initialDisplaySpan = startTimeToInitialDisplaySpan(); @@ -240,15 +265,19 @@ describe('TimeToDisplay', () => { timeToDisplayComponent.update(<>); emitNativeFullDisplayEvent(fullDisplayEndTimestampMs + 20); + activeSpan?.end(); return [initialDisplaySpan, fullDisplaySpan, activeSpan]; }, ); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + expectFinishedInitialDisplaySpan(initialDisplaySpan, activeSpan); expectFinishedFullDisplaySpan(fullDisplaySpan, activeSpan); - expectInitialDisplayMeasurementOnSpan(activeSpan); - expectFullDisplayMeasurementOnSpan(activeSpan); + expectInitialDisplayMeasurementOnSpan(client.event!); + expectFullDisplayMeasurementOnSpan(client.event!); expect(spanToJSON(initialDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); expect(spanToJSON(fullDisplaySpan!).timestamp).toEqual(fullDisplayEndTimestampMs / 1_000); @@ -256,17 +285,11 @@ describe('TimeToDisplay', () => { }); function getInitialDisplaySpan(span?: Span) { - return getSpanDescendants(span)?.find(s => s.op === 'ui.load.initial_display'); + return getSpanDescendants(span!)?.find(s => spanToJSON(s).op === 'ui.load.initial_display'); } function getFullDisplaySpan(span?: Span) { - return getSpanDescendants(span)?.find(s => s.op === 'ui.load.full_display'); -} - -// Will be replaced by https://github.com/getsentry/sentry-javascript/blob/99d8390f667e8ad31a9b1fd62fbd4941162fab04/packages/core/src/tracing/utils.ts#L54 -// after JS v8 upgrade -function getSpanDescendants(span?: Span) { - return (span as SpanClass)?.spanRecorder?.spans; + return getSpanDescendants(span!)?.find(s => spanToJSON(s).op === 'ui.load.full_display'); } function expectFinishedInitialDisplaySpan(actualSpan?: Span, expectedParentSpan?: Span) { @@ -277,7 +300,7 @@ function expectFinishedInitialDisplaySpan(actualSpan?: Span, expectedParentSpan? }, description: 'Time To Initial Display', op: 'ui.load.initial_display', - parent_span_id: expectedParentSpan?.spanId, + parent_span_id: expectedParentSpan ? spanToJSON(expectedParentSpan).span_id : undefined, start_timestamp: expect.any(Number), status: 'ok', timestamp: expect.any(Number), @@ -292,7 +315,7 @@ function expectFinishedFullDisplaySpan(actualSpan?: Span, expectedParentSpan?: S }, description: 'Time To Full Display', op: 'ui.load.full_display', - parent_span_id: expectedParentSpan?.spanId, + parent_span_id: expectedParentSpan ? spanToJSON(expectedParentSpan).span_id : undefined, start_timestamp: expect.any(Number), status: 'ok', timestamp: expect.any(Number), @@ -308,7 +331,7 @@ function expectDeadlineExceededFullDisplaySpan(actualSpan?: Span, expectedParent }, description: 'Time To Full Display', op: 'ui.load.full_display', - parent_span_id: expectedParentSpan?.spanId, + parent_span_id: expectedParentSpan ? spanToJSON(expectedParentSpan).span_id : undefined, start_timestamp: expect.any(Number), status: 'deadline_exceeded', timestamp: expect.any(Number), @@ -316,14 +339,14 @@ function expectDeadlineExceededFullDisplaySpan(actualSpan?: Span, expectedParent } function expectNoTimeToDisplaySpans(span?: Span) { - expect(getSpanDescendants(span)).toEqual(expect.not.arrayContaining([ - expect.objectContaining>({ op: 'ui.load.initial_display' }), - expect.objectContaining>({ op: 'ui.load.full_display' }), + expect(getSpanDescendants(span!).map(spanToJSON)).toEqual(expect.not.arrayContaining([ + expect.objectContaining>({ op: 'ui.load.initial_display' }), + expect.objectContaining>({ op: 'ui.load.full_display' }), ])); } -function expectInitialDisplayMeasurementOnSpan(span?: Span) { - expect(asObjectWithMeasurements(span)._measurements).toEqual(expect.objectContaining({ +function expectInitialDisplayMeasurementOnSpan(event: Event) { + expect(event.measurements).toEqual(expect.objectContaining({ time_to_initial_display: { value: expect.any(Number), unit: 'millisecond', @@ -331,8 +354,8 @@ function expectInitialDisplayMeasurementOnSpan(span?: Span) { })); } -function expectFullDisplayMeasurementOnSpan(span?: Span) { - expect(asObjectWithMeasurements(span)._measurements).toEqual(expect.objectContaining({ +function expectFullDisplayMeasurementOnSpan(event: Event) { + expect(event.measurements).toEqual(expect.objectContaining({ time_to_full_display: { value: expect.any(Number), unit: 'millisecond', @@ -340,16 +363,22 @@ function expectFullDisplayMeasurementOnSpan(span?: Span) { })); } -function expectNoInitialDisplayMeasurementOnSpan(span?: Span) { - expect(asObjectWithMeasurements(span)._measurements).toBeOneOf([ +function expectNoInitialDisplayMeasurementOnSpan(event: Event) { + expect(event.measurements).toBeOneOf([ undefined, expect.not.objectContaining({ time_to_initial_display: expect.anything() }), ]); } -function expectNoFullDisplayMeasurementOnSpan(span?: Span) { - expect(asObjectWithMeasurements(span)._measurements).toBeOneOf([ +function expectNoFullDisplayMeasurementOnSpan(event: Event) { + expect(event.measurements).toBeOneOf([ undefined, expect.not.objectContaining({ time_to_full_display: expect.anything() }), ]); } + +// Will be replaced by https://github.com/getsentry/sentry-javascript/blob/99d8390f667e8ad31a9b1fd62fbd4941162fab04/packages/core/src/tracing/utils.ts#L54 +// after JS v8 upgrade +function getSpanDescendants(span?: Span) { + return (span as SpanClass)?.spanRecorder?.spans; +}