From cb3722b5992a25468fbb5aed181a91e46580634e Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Thu, 3 Apr 2025 16:35:37 +0200 Subject: [PATCH] Fixed RumSessionStarted listener implementation --- example/ios/Podfile.lock | 106 +++++----- example/src/screens/MainScreen.tsx | 1 + packages/core/__mocks__/react-native.ts | 8 +- packages/core/src/DdSdkReactNative.tsx | 2 + .../src/__tests__/DdSdkReactNative.test.tsx | 45 ++++- packages/core/src/rum/DdRum.ts | 34 ++-- .../__tests__/sessionIdHelper.test.ts | 162 ++++++++++++++++ .../core/src/rum/sessionId/sessionIdHelper.ts | 59 ++++++ .../core/src/sdk/EventEmitter/EventEmitter.ts | 55 ++++++ .../__tests__/EventEmitter.test.ts | 181 ++++++++++++++++++ 10 files changed, 575 insertions(+), 78 deletions(-) create mode 100644 packages/core/src/rum/sessionId/__tests__/sessionIdHelper.test.ts create mode 100644 packages/core/src/rum/sessionId/sessionIdHelper.ts create mode 100644 packages/core/src/sdk/EventEmitter/EventEmitter.ts create mode 100644 packages/core/src/sdk/EventEmitter/__tests__/EventEmitter.test.ts diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 7a6e25c33..40ee8a348 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1893,9 +1893,9 @@ SPEC CHECKSUMS: DatadogInternal: a514ba01c9eb99dbe1ddd4452e218706bb0ddc2b DatadogLogs: 9352c58e4ab4a88f0ed7c20f27dc8d24442acc5e DatadogRUM: ab979c800d3fb7d7b2ff364a11d137b6a4be6831 - DatadogSDKReactNative: 25d1ef8cf3b8a9d5f1d2ceb311460ba99515f881 - DatadogSDKReactNativeSessionReplay: ce29bf5592cd01f56ffc92d1e905c37542ecb3f9 - DatadogSDKReactNativeWebView: 1e5f4236da00714f72a7fb97e3fbace23df92017 + DatadogSDKReactNative: 9aa3b8303f63e169e2d6b63ffe9e1d05f11ebf5a + DatadogSDKReactNativeSessionReplay: 861cfbfd31726df6445f0507ed2a5cc8ef3e9ca7 + DatadogSDKReactNativeWebView: 3e3375b728c767db90ddd844bb8cbb540f7d30f3 DatadogSessionReplay: 84f73417237cb02ad93889cd0a2fb564bce2ccab DatadogTrace: 23df8545911d219d4c15446a4c6c04862ff76175 DatadogWebViewTracking: e88b8057eb5ff06a68baf232ae37637b8bb7518b @@ -1907,69 +1907,69 @@ SPEC CHECKSUMS: HMSegmentedControl: 34c1f54d822d8308e7b24f5d901ec674dfa31352 OpenTelemetrySwiftApi: aaee576ed961e0c348af78df58b61300e95bd104 PLCrashReporter: db59ef96fa3d25f3650040d02ec2798cffee75f2 - RCT-Folly: 34124ae2e667a0e5f0ea378db071d27548124321 + RCT-Folly: 4464f4d875961fce86008d45f4ecf6cef6de0740 RCTDeprecation: 726d24248aeab6d7180dac71a936bbca6a994ed1 RCTRequired: a94e7febda6db0345d207e854323c37e3a31d93b RCTTypeSafety: 28e24a6e44f5cbf912c66dde6ab7e07d1059a205 React: c2830fa483b0334bda284e46a8579ebbe0c5447e React-callinvoker: 4aecde929540c26b841a4493f70ebf6016691eb8 - React-Core: 32a581847d74ce9b5f51d9d11a4e4d132ad61553 - React-CoreModules: f53e0674e1747fa41c83bc970e82add97b14ad87 - React-cxxreact: 86f3b1692081fd954a0cb27cc90d14674645b64b + React-Core: 9c059899f00d46b5cec3ed79251f77d9c469553d + React-CoreModules: 9fac2d31803c0ed03e4ddaa17f1481714f8633a5 + React-cxxreact: a979810a3ca4045ceb09407a17563046a7f71494 React-debug: 3d21f69d8def0656f8b8ec25c0f05954f4d862c5 - React-defaultsnativemodule: 2ed121c5a1edeab09cff382b8d9b538260f07848 - React-domnativemodule: 4393dd5dd7e13dbe42e69ebc791064a616990f91 - React-Fabric: cbf38ceefb1ac6236897abdb538130228e126695 - React-FabricComponents: dd4b01c4a60920d8dc15f3b5594c6fe9e7648a38 - React-FabricImage: 8b13aedfbd20f349b9b3314baf993c71c02995d9 + React-defaultsnativemodule: 2fa2bdb7bd03ff9764facc04aa8520ebf14febae + React-domnativemodule: 986e6fe7569e1383dce452a7b013b6c843a752df + React-Fabric: 3bc7be9e3a6b7581fc828dc2aa041e107fc8ffb8 + React-FabricComponents: 668e0cb02344c2942e4c8921a643648faa6dc364 + React-FabricImage: 3f44dd25a2b020ed5215d4438a1bb1f3461cd4f1 React-featureflags: ee1abd6f71555604a36cda6476e3c502ca9a48e5 - React-featureflagsnativemodule: 87b58caf3cd8eca1e53179453789def019af2a65 - React-graphics: f5c4cf3abc5aa083e28fe7a866bd95fb3bbbc1e0 - React-hermes: cad69ee9a53870cc38e5386889aa7ea81c75b6a1 - React-idlecallbacksnativemodule: 445390be0f533797ace18c419eb57110dbfe90d6 - React-ImageManager: cb78d7a24f45f8f9a5a1640b52fce4c9f637f98d - React-jserrorhandler: dfe9b96e99a93d4f4858bad66d5bc4813a87a21a - React-jsi: bc1f6073e203fb540edd6d26f926ad041809b443 - React-jsiexecutor: 1e8fc70dd9614c3e9d5c3c876b2ea3cd1d931ee4 - React-jsinspector: 7544a20e9beac390f1b65d9f0040d97cd55dc198 - React-jsitracing: cac972ccc097db399df8044e49add8e5b25cb34a - React-logger: 80d87daf2f98bf95ab668b79062c1e0c3f0c2f8a - React-Mapbuffer: acffb35a53a5f474ede09f082ac609b41aafab2e - React-microtasksnativemodule: 71ca9282bce93b319218d75362c0d646b376eb43 - react-native-crash-tester: 48bde9d6f5256c61ef2e0c52dfc74256b26e55eb - react-native-safe-area-context: e134b241010ebe2aacdcea013565963d13826faa - react-native-webview: f38fd87322055d2ce09058cf19f50f708fb174da + React-featureflagsnativemodule: 7ccc0cd666c2a6257401dceb7920818ac2b42803 + React-graphics: d7dd9c8d75cad5af19e19911fa370f78f2febd96 + React-hermes: 2069b08e965e48b7f8aa2c0ca0a2f383349ed55d + React-idlecallbacksnativemodule: e211b2099b6dced97959cb58257bab2b2de4d7ef + React-ImageManager: ab7a7d17dd0ff1ef1d4e1e88197d1119da9957ce + React-jserrorhandler: d9e867bb83b868472f3f7601883f0403b3e3942d + React-jsi: d68f1d516e5120a510afe356647a6a1e1f98f2db + React-jsiexecutor: 6366a08a0fc01c9b65736f8deacd47c4a397912a + React-jsinspector: 0ac947411f0c73b34908800cc7a6a31d8f93e1a8 + React-jsitracing: 0e8c0aadb1fcec6b1e4f2a66ee3b0da80f0f8615 + React-logger: d79b704bf215af194f5213a6b7deec50ba8e6a9b + React-Mapbuffer: b982d5bba94a8bc073bda48f0d27c9b28417fae3 + React-microtasksnativemodule: 2b73e68f0462f3175f98782db08896f8501afd20 + react-native-crash-tester: 3ffaa64141427ca362079cb53559fe9a532487ae + react-native-safe-area-context: 04803a01f39f31cc6605a5531280b477b48f8a88 + react-native-webview: 4cec209f46fca53257d987ac16a6a63974417f8b React-nativeconfig: 8c83d992b9cc7d75b5abe262069eaeea4349f794 - React-NativeModulesApple: 97f606f09fd9840b3868333984d6a0e7bcc425b5 + React-NativeModulesApple: 9f7920224a3b0c7d04d77990067ded14cee3c614 React-perflogger: 59e1a3182dca2cee7b9f1f7aab204018d46d1914 - React-performancetimeline: 3e3f5c5576fe1cc2dd5fcfb1ae2046d5dceda3d7 + React-performancetimeline: a9d05533ff834c6aa1f532e05e571f3fd2e3c1ed React-RCTActionSheet: d80e68d3baa163e4012a47c1f42ddd8bcd9672cc - React-RCTAnimation: 051f0781709c5ed80ba8aa2b421dfb1d72a03162 - React-RCTAppDelegate: 106d225d076988b06aa4834e68d1ab754f40cacf - React-RCTBlob: 895eaf8bca2e76ee1c95b479235c6ccebe586fc6 - React-RCTFabric: 8d01df202ee9e933f9b5dd44b72ec89a7ac6ee01 - React-RCTImage: b73149c0cd54b641dba2d6250aaf168fee784d9f - React-RCTLinking: 23e519712285427e50372fbc6e0265d422abf462 - React-RCTNetwork: a5d06d122588031989115f293654b13353753630 - React-RCTSettings: 87d03b5d94e6eadd1e8c1d16a62f790751aafb55 - React-RCTText: 75e9dd39684f4bcd1836134ac2348efaca7437b3 - React-RCTVibration: 033c161fe875e6fa096d0d9733c2e2501682e3d4 + React-RCTAnimation: bde981f6bd7f8493696564da9b3bd05721d3b3cc + React-RCTAppDelegate: 0176615c51476c88212bf3edbafb840d39ea7631 + React-RCTBlob: 520a0382bf8e89b9153d60e3c6293e51615834e9 + React-RCTFabric: c9da097b19b30017a99498b8c66a69c72f3ce689 + React-RCTImage: 90448d2882464af6015ed57c98f463f8748be465 + React-RCTLinking: 1bd95d0a704c271d21d758e0f0388cced768d77d + React-RCTNetwork: 218af6e63eb9b47935cc5a775b7a1396cf10ff91 + React-RCTSettings: e10b8e42b0fce8a70fbf169de32a2ae03243ef6b + React-RCTText: e7bf9f4997a1a0b45c052d4ad9a0fe653061cf29 + React-RCTVibration: 5b70b7f11e48d1c57e0d4832c2097478adbabe93 React-rendererconsistency: f620c6e003e3c4593e6349d8242b8aeb3d4633f0 - React-rendererdebug: 5be7b834677b2a7a263f4d2545f0d4966cafad82 + React-rendererdebug: e697680f4dd117becc5daf9ea9800067abcee91c React-rncore: c22bd84cc2f38947f0414fab6646db22ff4f80cd - React-RuntimeApple: 71160e6c02efa07d198b84ef5c3a52a7d9d0399d - React-RuntimeCore: f88f79ec995c12af56a265d7505c7630733d9d82 + React-RuntimeApple: de0976836b90b484305638616898cbc665c67c13 + React-RuntimeCore: 3c4a5aa63d9e7a3c17b7fb23f32a72a8bcfccf57 React-runtimeexecutor: ea90d8e3a9e0f4326939858dafc6ab17c031a5d3 - React-RuntimeHermes: 49f86328914021f50fd5a5b9756685f5f6d8b4da - React-runtimescheduler: fed70991b942c6df752a59a22081e45fc811b11c - React-utils: 02526ea15628a768b8db9517b6017a1785c734d2 - ReactCodegen: 8b5341ecb61898b8bd40a73ebc443c6bf2d14423 - ReactCommon: 36d48f542b4010786d6b2bcee615fe5f906b7105 - ReactNativeNavigation: 81b3f87d96a0b963f8bdf1f6fb6ac2abe9f653d1 - RNCAsyncStorage: b9f5f78da5d16a853fe3dc22e8268d932fc45a83 - RNCMaskedView: 4c5ee1c8667d56077246cc6d1977f77393923560 - RNGestureHandler: fc491c834d09b0538618a3f328ab75fe8f90f5b4 - RNScreens: df5b744737987bdfe5f04e05579b53ce1bcdbfa9 + React-RuntimeHermes: c6b0afdf1f493621214eeb6517fb859ce7b21b81 + React-runtimescheduler: 84f0d876d254bce6917a277b3930eb9bc29df6c7 + React-utils: cbe8b8b3d7b2ac282e018e46f0e7b25cdc87c5a0 + ReactCodegen: 4bcb34e6b5ebf6eef5cee34f55aa39991ea1c1f1 + ReactCommon: 6a952e50c2a4b694731d7682aaa6c79bc156e4ad + ReactNativeNavigation: 84cfcceb62947491beda20b96c5999c15ff5b959 + RNCAsyncStorage: addfc2cb6511dbe199c56c6b26ede383b6c38919 + RNCMaskedView: 0e1bc4bfa8365eba5fbbb71e07fbdc0555249489 + RNGestureHandler: 2e4971181218718ab9c5871ea5ef855ec5c389fe + RNScreens: b51f1a8be0dd7bb470b757f6cca8ba878acb2000 SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d Yoga: 055f92ad73f8c8600a93f0e25ac0b2344c3b07e6 diff --git a/example/src/screens/MainScreen.tsx b/example/src/screens/MainScreen.tsx index a2ae07ad2..7e2bafe55 100644 --- a/example/src/screens/MainScreen.tsx +++ b/example/src/screens/MainScreen.tsx @@ -14,6 +14,7 @@ import { APPLICATION_KEY, API_KEY } from '../../src/ddCredentials'; import { DdSdkReactNative, TrackingConsent } from '@datadog/mobile-react-native'; import { getTrackingConsent, saveTrackingConsent } from '../utils'; import { ConsentModal } from '../components/consent'; +import { DdRum } from '../../../packages/core/src/rum/DdRum'; const axios = require('../axiosConfig'); diff --git a/packages/core/__mocks__/react-native.ts b/packages/core/__mocks__/react-native.ts index 4a55d3500..fb7a1a706 100644 --- a/packages/core/__mocks__/react-native.ts +++ b/packages/core/__mocks__/react-native.ts @@ -44,7 +44,13 @@ actualRN.NativeModules.DdSdk = { ) as jest.MockedFunction, clearAllData: jest.fn().mockImplementation( () => new Promise(resolve => resolve()) - ) as jest.MockedFunction + ) as jest.MockedFunction, + addListener: jest.fn().mockImplementation((_: string) => { + /* empty */ + }) as jest.MockedFunction, + removeListeners: jest.fn().mockImplementation((_: number) => { + /* empty */ + }) as jest.MockedFunction }; actualRN.NativeModules.DdLogs = { diff --git a/packages/core/src/DdSdkReactNative.tsx b/packages/core/src/DdSdkReactNative.tsx index 78ca564df..41d57548e 100644 --- a/packages/core/src/DdSdkReactNative.tsx +++ b/packages/core/src/DdSdkReactNative.tsx @@ -29,6 +29,7 @@ import { DdRum } from './rum/DdRum'; import { DdRumErrorTracking } from './rum/instrumentation/DdRumErrorTracking'; import { DdRumUserInteractionTracking } from './rum/instrumentation/interactionTracking/DdRumUserInteractionTracking'; import { DdRumResourceTracking } from './rum/instrumentation/resourceTracking/DdRumResourceTracking'; +import { registerRumSessionIdListener } from './rum/sessionId/sessionIdHelper'; import { AttributesSingleton } from './sdk/AttributesSingleton/AttributesSingleton'; import type { Attributes } from './sdk/AttributesSingleton/types'; import { BufferSingleton } from './sdk/DatadogProvider/Buffer/BufferSingleton'; @@ -75,6 +76,7 @@ export class DdSdkReactNative { initializationModeForTelemetry: InitializationModeForTelemetry; } ): Promise => { + registerRumSessionIdListener(); if (GlobalState.instance.isInitialized) { InternalLog.log( "Can't initialize Datadog, SDK was already initialized", diff --git a/packages/core/src/__tests__/DdSdkReactNative.test.tsx b/packages/core/src/__tests__/DdSdkReactNative.test.tsx index 987ce322f..4728e9b5f 100644 --- a/packages/core/src/__tests__/DdSdkReactNative.test.tsx +++ b/packages/core/src/__tests__/DdSdkReactNative.test.tsx @@ -17,6 +17,7 @@ import { DdRum } from '../rum/DdRum'; import { DdRumErrorTracking } from '../rum/instrumentation/DdRumErrorTracking'; import { DdRumUserInteractionTracking } from '../rum/instrumentation/interactionTracking/DdRumUserInteractionTracking'; import { DdRumResourceTracking } from '../rum/instrumentation/resourceTracking/DdRumResourceTracking'; +import { registerRumSessionIdListener } from '../rum/sessionId/sessionIdHelper'; import { ErrorSource, PropagatorType, RumActionType } from '../rum/types'; import { AttributesSingleton } from '../sdk/AttributesSingleton/AttributesSingleton'; import { DdSdk } from '../sdk/DdSdk'; @@ -57,6 +58,15 @@ jest.mock('../rum/instrumentation/DdRumErrorTracking', () => { }; }); +jest.mock('../rum/sessionId/sessionIdHelper', () => { + return { + registerRumSessionIdListener: jest.fn(), + removeRumSessionIdListeners: jest.fn(), + getCachedRumSessionId: jest.fn(), + setCachedRumSessionId: jest.fn() + }; +}); + beforeEach(async () => { GlobalState.instance.isInitialized = false; DdSdkReactNative['wasAutoInstrumented'] = false; @@ -78,6 +88,10 @@ beforeEach(async () => { UserInfoSingleton.reset(); AttributesSingleton.reset(); + + (registerRumSessionIdListener as jest.MockedFunction< + () => void + >).mockClear(); }); describe('DdSdkReactNative', () => { @@ -156,11 +170,13 @@ describe('DdSdkReactNative', () => { }); expect(GlobalState.instance.isInitialized).toBe(false); - expect(DdRumUserInteractionTracking.startTracking).toBeCalledTimes( + expect( + DdRumUserInteractionTracking.startTracking + ).toHaveBeenCalledTimes(0); + expect(DdRumResourceTracking.startTracking).toHaveBeenCalledTimes( 0 ); - expect(DdRumResourceTracking.startTracking).toBeCalledTimes(0); - expect(DdRumErrorTracking.startTracking).toBeCalledTimes(0); + expect(DdRumErrorTracking.startTracking).toHaveBeenCalledTimes(0); }); it('initializes the SDK when initialize { explicit tracking consent }', async () => { @@ -1017,6 +1033,29 @@ describe('DdSdkReactNative', () => { logs: 'https://logs.example.com/' }); }); + + it('registers RUM Session ID listener when initialize', async () => { + // GIVEN + const fakeAppId = '1'; + const fakeClientToken = '2'; + const fakeEnvName = 'env'; + const configuration = new DdSdkReactNativeConfiguration( + fakeClientToken, + fakeEnvName, + fakeAppId, + false, + false, + true + ); + + NativeModules.DdSdk.initialize.mockResolvedValue(null); + + // WHEN + await DdSdkReactNative.initialize(configuration); + + // THEN + expect(registerRumSessionIdListener).toHaveBeenCalledTimes(1); + }); }); describe('setAttributes', () => { diff --git a/packages/core/src/rum/DdRum.ts b/packages/core/src/rum/DdRum.ts index 36fd30e2c..f30f7012f 100644 --- a/packages/core/src/rum/DdRum.ts +++ b/packages/core/src/rum/DdRum.ts @@ -4,7 +4,6 @@ * Copyright 2016-Present Datadog, Inc. */ import type { GestureResponderEvent } from 'react-native'; -import { NativeEventEmitter, NativeModules, Platform } from 'react-native'; import { InternalLog } from '../InternalLog'; import { SdkVerbosity } from '../SdkVerbosity'; @@ -30,6 +29,10 @@ import { getTracingContext, getTracingContextForPropagators } from './instrumentation/resourceTracking/distributedTracing/distributedTracingHeaders'; +import { + getCachedRumSessionId, + setCachedRumSessionId +} from './sessionId/sessionIdHelper'; import type { ErrorSource, DdRumType, @@ -41,11 +44,6 @@ import type { const generateEmptyPromise = () => new Promise(resolve => resolve()); -const nativeEventEmitter = - Platform.OS === 'android' - ? new NativeEventEmitter() - : new NativeEventEmitter(NativeModules.DdSdk); - class DdRumWrapper implements DdRumType { // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires private nativeRum: DdNativeRumType = require('../specs/NativeDdRum') @@ -56,19 +54,10 @@ class DdRumWrapper implements DdRumType { private actionEventMapper = generateActionEventMapper(undefined); private timeProvider: TimeProvider = new DefaultTimeProvider(); - private currentRumSessionId: string | null = null; - constructor() { - // listen to future events - nativeEventEmitter.addListener('RumSessionStarted', (e: Event) => { - const field = 'sessionId'; - const sessionId = e[field as keyof Event] as string | null; - this.currentRumSessionId = sessionId; - }); - - // fetch the current session if any (because we might have missed the first RumSessionStarted event) + // Fetch the current session if any (because we might have missed the first RumSessionStarted event) this.getCurrentSessionId().then(value => { - this.currentRumSessionId = value ?? null; + setCachedRumSessionId(value ?? null); }); } @@ -334,11 +323,14 @@ class DdRumWrapper implements DdRumType { if (!GlobalState.instance.isInitialized) { return undefined; } - return this.nativeRum.getCurrentSessionId(); + const sessionId = await this.nativeRum.getCurrentSessionId(); + setCachedRumSessionId(sessionId ?? null); + + return sessionId; } getCachedSessionId(): string | null { - return this.currentRumSessionId; + return getCachedRumSessionId(); } getTracingContext = ( @@ -350,7 +342,7 @@ class DdRumWrapper implements DdRumType { url, tracingSamplingRate, firstPartyHosts, - this.currentRumSessionId + getCachedRumSessionId() ); }; @@ -361,7 +353,7 @@ class DdRumWrapper implements DdRumType { return getTracingContextForPropagators( propagators, tracingSamplingRate, - this.currentRumSessionId + getCachedRumSessionId() ); }; diff --git a/packages/core/src/rum/sessionId/__tests__/sessionIdHelper.test.ts b/packages/core/src/rum/sessionId/__tests__/sessionIdHelper.test.ts new file mode 100644 index 000000000..8796f0cd9 --- /dev/null +++ b/packages/core/src/rum/sessionId/__tests__/sessionIdHelper.test.ts @@ -0,0 +1,162 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +/* eslint-disable @typescript-eslint/no-var-requires */ +/* eslint-disable global-require */ +import { createNativeEventEmitterForModule } from '../../../sdk/EventEmitter/EventEmitter'; + +describe('sessionIdHelper', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('M registerRumSessionIdListener correctly registers a listener for RumSessionStarted', () => { + // GIVEN + const { registerRumSessionIdListener } = require('../sessionIdHelper'); + const nativeModuleMock = { + addListener: jest.fn(), + removeListeners: jest.fn() + }; + + const eventEmitter = createNativeEventEmitterForModule( + nativeModuleMock + ); + + // WHEN + registerRumSessionIdListener(); + + // THEN + expect(eventEmitter.listenerCount('RumSessionStarted')).toBe(1); + }); + + it('M registerRumSessionIdListener does not register more than one listener for RumSessionStarted', () => { + // GIVEN + const { registerRumSessionIdListener } = require('../sessionIdHelper'); + + const nativeModuleMock = { + addListener: jest.fn(), + removeListeners: jest.fn() + }; + + const eventEmitter = createNativeEventEmitterForModule( + nativeModuleMock + ); + + // WHEN + registerRumSessionIdListener(); + registerRumSessionIdListener(); + registerRumSessionIdListener(); + + // THEN + expect(eventEmitter.listenerCount('RumSessionStarted')).toBe(1); + }); + + it('M removeRumSessionIdListeners should remove RumSessionStarted listener', () => { + // GIVEN + const { + registerRumSessionIdListener, + removeRumSessionIdListeners + } = require('../sessionIdHelper'); + + const nativeModuleMock = { + addListener: jest.fn(), + removeListeners: jest.fn() + }; + const eventEmitter = createNativeEventEmitterForModule( + nativeModuleMock + ); + + registerRumSessionIdListener(); + expect(eventEmitter.listenerCount('RumSessionStarted')).toBe(1); + + // WHEN + removeRumSessionIdListeners(); + + // THEN + expect(eventEmitter.listenerCount('RumSessionStarted')).toBe(0); + }); + + it('M getCachedRumSessionId should return null W { cachedRumSessionId has not been set }', () => { + // GIVEN + const { getCachedRumSessionId } = require('../sessionIdHelper'); + + // WHEN + const sessionId = getCachedRumSessionId(); + + // THEN + expect(sessionId).toBeNull(); + }); + + it('M getCachedRumSessionId should return cached RUM Session ID W { cachedRumSessionId has been set }', () => { + // GIVEN + const { + setCachedRumSessionId, + getCachedRumSessionId + } = require('../sessionIdHelper'); + + setCachedRumSessionId('TEST_SESSION_ID'); + + // WHEN + const sessionId = getCachedRumSessionId(); + + // THEN + expect(sessionId).toBe('TEST_SESSION_ID'); + }); + + it('M getCachedRumSessionId should return sessionID W { registered listener is called }', () => { + // GIVEN + const { + registerRumSessionIdListener, + getCachedRumSessionId + } = require('../sessionIdHelper'); + + const nativeModuleMock = { + addListener: jest.fn(), + removeListeners: jest.fn() + }; + const eventEmitter = createNativeEventEmitterForModule( + nativeModuleMock + ); + + registerRumSessionIdListener(); + + // WHEN + const event = { + sessionId: 'LISTENER_TEST_SESSION_ID' + }; + eventEmitter.emit('RumSessionStarted', event); + + // THEN + expect(getCachedRumSessionId()).toBe('LISTENER_TEST_SESSION_ID'); + }); + + it('M getCachedRumSessionId should be null W { registered listener is called with undefined value }', () => { + // GIVEN + const { + registerRumSessionIdListener, + getCachedRumSessionId + } = require('../sessionIdHelper'); + + const nativeModuleMock = { + addListener: jest.fn(), + removeListeners: jest.fn() + }; + const eventEmitter = createNativeEventEmitterForModule( + nativeModuleMock + ); + + registerRumSessionIdListener(); + + // WHEN + const event = { + sessionId: undefined + }; + eventEmitter.emit('RumSessionStarted', event); + + // THEN + expect(getCachedRumSessionId()).toBeNull(); + }); +}); diff --git a/packages/core/src/rum/sessionId/sessionIdHelper.ts b/packages/core/src/rum/sessionId/sessionIdHelper.ts new file mode 100644 index 000000000..504b66b87 --- /dev/null +++ b/packages/core/src/rum/sessionId/sessionIdHelper.ts @@ -0,0 +1,59 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ +import { NativeModules } from 'react-native'; + +import { createNativeEventEmitterForModule } from '../../sdk/EventEmitter/EventEmitter'; + +const RUM_SESSION_STARTED_EVENT_KEY = 'RumSessionStarted'; + +let cachedRumSessionId: string | null; + +/** + * [INTERNAL API] + * Registers a listener for the native 'RumSessionStarted' event, to update + * the cached Session ID. + */ +export const registerRumSessionIdListener = () => { + const eventEmitter = createNativeEventEmitterForModule(NativeModules.DdSdk); + if (eventEmitter.listenerCount(RUM_SESSION_STARTED_EVENT_KEY) > 0) { + return; + } + + eventEmitter.addListener(RUM_SESSION_STARTED_EVENT_KEY, (event: any) => { + const field = 'sessionId'; + const sessionId = event[field] as string | null; + cachedRumSessionId = sessionId; + }); +}; + +/** + * [INTERNAL API] + * Removes all listeners for the native 'RumSessionStarted' event (USEFUL FOR TESTING). + */ +export const removeRumSessionIdListeners = () => { + const eventEmitter = createNativeEventEmitterForModule(NativeModules.DdSdk); + eventEmitter.removeAllListeners(RUM_SESSION_STARTED_EVENT_KEY); +}; + +/** + * [INTERNAL API] + * Returns the cached RUM Session ID, updated either by: + * - `RUMSessionStarted` events from the native layer + * - Calls to `DdRum.getCurrentSessionId()` public API + * @returns The cached RUM Session ID. + */ +export const getCachedRumSessionId = (): string | null => { + return cachedRumSessionId ?? null; +}; + +/** + * [INTERNAL API] + * Manually overrides the cached RUM Session ID. + * @param sessionId The RUM Session ID to set (or null). + */ +export const setCachedRumSessionId = (sessionId: string | null) => { + cachedRumSessionId = sessionId; +}; diff --git a/packages/core/src/sdk/EventEmitter/EventEmitter.ts b/packages/core/src/sdk/EventEmitter/EventEmitter.ts new file mode 100644 index 000000000..4674731e6 --- /dev/null +++ b/packages/core/src/sdk/EventEmitter/EventEmitter.ts @@ -0,0 +1,55 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ +import { NativeEventEmitter, Platform } from 'react-native'; +import type { NativeModule } from 'react-native'; + +const EMPTY_NATIVE_MODULE: NativeModule = { + addListener(_: string): void { + /* empty */ + }, + removeListeners(_: number): void { + /* empty */ + } +}; + +const nativeEventEmitters = new Map(); + +/** + * Default implementation for creating a NativeEventEmitter. + * @param nativeModule The NativeModule for which the NativeEventEmitter is created for. + * @returns The NativeEventEmitter. + */ +const createDefaultNativeEventEmitter = ( + nativeModule?: NativeModule +): NativeEventEmitter => { + return new NativeEventEmitter(nativeModule); +}; + +/** + * [INTERNAL API] + * Creates a {@link NativeEventEmitter} from the given {@link NativeModule}. + * @param nativeModule The {@link NativeModule} to create the {@link NativeEventEmitter} for. + * @param createNativeEventEmitter Custom implementation for creating a {@link NativeEventEmitter}. + * Defaults to {@link createDefaultNativeEventEmitter} (USEFUL FOR TESTING) + * @returns The created {@link NativeEventEmitter}. + */ +export const createNativeEventEmitterForModule = ( + nativeModule: NativeModule, + createNativeEventEmitter: ( + nativeEventModule?: NativeModule + ) => NativeEventEmitter = createDefaultNativeEventEmitter +): NativeEventEmitter => { + const eventEmitter = nativeEventEmitters.get(nativeModule); + if (!eventEmitter) { + const newEventEmitter = + Platform.OS === 'android' + ? createNativeEventEmitter() + : createNativeEventEmitter(nativeModule ?? EMPTY_NATIVE_MODULE); + nativeEventEmitters.set(nativeModule, newEventEmitter); + return newEventEmitter; + } + return eventEmitter; +}; diff --git a/packages/core/src/sdk/EventEmitter/__tests__/EventEmitter.test.ts b/packages/core/src/sdk/EventEmitter/__tests__/EventEmitter.test.ts new file mode 100644 index 000000000..1aff3e5bd --- /dev/null +++ b/packages/core/src/sdk/EventEmitter/__tests__/EventEmitter.test.ts @@ -0,0 +1,181 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +/* eslint-disable @typescript-eslint/no-var-requires */ +/* eslint-disable global-require */ +import type { NativeModule } from 'react-native'; +import { NativeModules, Platform } from 'react-native'; + +const mockPlatform = (platform: typeof Platform.OS) => { + Object.defineProperty(Platform, 'OS', { + value: platform + }); +}; + +describe('EventEmitter', () => { + let defaultPlatform: typeof Platform.OS; + const createNativeEventEmitterMock = jest.fn(); + + beforeAll(() => { + createNativeEventEmitterMock.mockReturnValue({}); + }); + + beforeEach(() => { + defaultPlatform = Platform.OS; + createNativeEventEmitterMock.mockClear(); + jest.resetModules(); + }); + + afterEach(() => { + mockPlatform(defaultPlatform); + }); + + it('M createNativeEventEmitterForModule returns valid event emitter W { platform = Android, NativeModules = undefined }', () => { + // GIVEN + mockPlatform('android'); + const createNativeEventEmitterForModule = require('../EventEmitter') + .createNativeEventEmitterForModule; + + // WHEN + const eventEmitter = createNativeEventEmitterForModule( + NativeModules.DdSdk, + createNativeEventEmitterMock + ); + + // THEN + expect(eventEmitter).toBeDefined(); + expect(createNativeEventEmitterMock).toHaveBeenCalledWith(); + }); + + it('M createNativeEventEmitterForModule returns valid event emitter W { platform = iOS, NativeModules = undefined }', () => { + // GIVEN + mockPlatform('ios'); + const createNativeEventEmitterForModule = require('../EventEmitter') + .createNativeEventEmitterForModule; + + // WHEN + const eventEmitter = createNativeEventEmitterForModule( + NativeModules.DdSdk, + createNativeEventEmitterMock + ); + + // THEN + expect(eventEmitter).toBeDefined(); + expect(createNativeEventEmitterMock).toHaveBeenCalledWith( + // iOS expects an object of type NativeModule to be passed + expect.objectContaining({ + addListener: expect.any(Function), + removeListeners: expect.any(Function) + } as NativeModule) + ); + }); + + it('M createNativeEventEmitterForModule returns valid event emitter W { platform = Android, NativeModules != undefined }', () => { + // GIVEN + mockPlatform('android'); + const createNativeEventEmitterForModule = require('../EventEmitter') + .createNativeEventEmitterForModule; + + const nativeModuleMock = { + addListener: jest.fn(), + removeListeners: jest.fn() + }; + + // WHEN + const eventEmitter = createNativeEventEmitterForModule( + nativeModuleMock, + createNativeEventEmitterMock + ); + + // THEN + expect(eventEmitter).toBeDefined(); + expect(createNativeEventEmitterMock).toHaveBeenCalledWith(); + }); + + it('M createNativeEventEmitterForModule returns valid event emitter W { platform= iOS, NativeModules != undefined }', () => { + // GIVEN + mockPlatform('ios'); + const createNativeEventEmitterForModule = require('../EventEmitter') + .createNativeEventEmitterForModule; + + const nativeModuleMock = { + addListener: () => { + /* empty */ + }, + removeListeners: () => { + /* empty */ + }, + testFunction: () => { + /* empty */ + } + }; + + // WHEN + const eventEmitter = createNativeEventEmitterForModule( + nativeModuleMock, + createNativeEventEmitterMock + ); + + // THEN + expect(eventEmitter).toBeDefined(); + expect(createNativeEventEmitterMock).toHaveBeenCalledWith( + nativeModuleMock + ); + }); + + it('M createNativeEventEmitterForModule initializes NativeEventEmitter only once W { platform = Android }', () => { + // GIVEN + mockPlatform('android'); + const createNativeEventEmitterForModule = require('../EventEmitter') + .createNativeEventEmitterForModule; + + // WHEN + const eventEmitter1 = createNativeEventEmitterForModule( + NativeModules.DdSdk, + createNativeEventEmitterMock + ); + const eventEmitter2 = createNativeEventEmitterForModule( + NativeModules.DdSdk, + createNativeEventEmitterMock + ); + + // THEN + expect(eventEmitter1).toBeDefined(); + expect(eventEmitter2).toBeDefined(); + expect(eventEmitter1).toBe(eventEmitter2); + expect(createNativeEventEmitterMock).toHaveBeenCalledTimes(1); + expect(createNativeEventEmitterMock).toHaveBeenCalledWith(); + }); + + it('M createNativeEventEmitterForModule initializes NativeEventEmitter only once W { platform = iOS }', () => { + // GIVEN + mockPlatform('ios'); + const createNativeEventEmitterForModule = require('../EventEmitter') + .createNativeEventEmitterForModule; + + // WHEN + const eventEmitter1 = createNativeEventEmitterForModule( + NativeModules.DdSdk, + createNativeEventEmitterMock + ); + const eventEmitter2 = createNativeEventEmitterForModule( + NativeModules.DdSdk, + createNativeEventEmitterMock + ); + + // THEN + expect(eventEmitter1).toBeDefined(); + expect(eventEmitter2).toBeDefined(); + expect(eventEmitter1).toBe(eventEmitter2); + expect(createNativeEventEmitterMock).toHaveBeenCalledTimes(1); + expect(createNativeEventEmitterMock).toHaveBeenCalledWith( + expect.objectContaining({ + addListener: expect.any(Function), + removeListeners: expect.any(Function) + }) + ); + }); +});