diff --git a/android/app/build.gradle b/android/app/build.gradle index 0db4b032ec9d..9c07d9d0a500 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -2,12 +2,20 @@ apply plugin: "com.android.application" apply plugin: "org.jetbrains.kotlin.android" apply plugin: "com.facebook.react" apply plugin: "com.google.firebase.firebase-perf" +apply plugin: "fullstory" apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle" /** * This is the configuration block to customize your React Native Android app. * By default you don't need to apply any configuration, just uncomment the lines you need. */ + +/* Fullstory settings */ +fullstory { + org 'o-1WN56P-na1' + enabledVariants 'all' +} + react { /* Folders */ // The root of your project, i.e. where "package.json" lives. Default is '..' @@ -162,7 +170,7 @@ android { signingConfig null // buildTypes take precedence over productFlavors when it comes to the signing configuration, // thus we need to manually set the signing config, so that the e2e uses the debug config again. - // In other words, the signingConfig setting above will be ignored when we build the flavor in release mode. + // In other words, the signingConfig setting above will be ignored when we build the flavor in release mode. productFlavors.all { flavor -> // All release builds should be signed with the release config ... flavor.signingConfig signingConfigs.release diff --git a/android/build.gradle b/android/build.gradle index 10600480d8bb..7ecd482b38f0 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -20,6 +20,7 @@ buildscript { repositories { google() mavenCentral() + maven {url "https://maven.fullstory.com"} } dependencies { classpath("com.android.tools.build:gradle") @@ -27,6 +28,9 @@ buildscript { classpath("com.google.gms:google-services:4.3.4") classpath("com.google.firebase:firebase-crashlytics-gradle:2.7.1") classpath("com.google.firebase:perf-plugin:1.4.1") + // Fullstory integration + classpath ("com.fullstory:gradle-plugin-local:1.45.1") + // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") @@ -70,7 +74,7 @@ allprojects { // 'mapbox' is the fixed username for Mapbox's Maven repository. username = 'mapbox' - // The value for password is read from the 'MAPBOX_DOWNLOADS_TOKEN' gradle property. + // The value for password is read from the 'MAPBOX_DOWNLOADS_TOKEN' gradle property. // Run "npm run setup-mapbox-sdk" to set this property in «USER_HOME»/.gradle/gradle.properties // Example gradle.properties entry: diff --git a/babel.config.js b/babel.config.js index 9f8b7a711d78..0660cdb452fb 100644 --- a/babel.config.js +++ b/babel.config.js @@ -14,6 +14,23 @@ const defaultPlugins = [ // source code transformation as we do not use class property assignment. 'transform-class-properties', + /* Fullstory */ + [ + '@fullstory/react-native', + { + version: '1.4.0', + org: 'o-1WN56P-na1', + enabledVariants: 'all', + }, + ], + [ + '@fullstory/babel-plugin-annotate-react', + { + native: true, + setFSTagName: true, + }, + ], + // Keep it last 'react-native-reanimated/plugin', ]; diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj index 7f50db5da85a..e7ce320f65d6 100644 --- a/ios/NewExpensify.xcodeproj/project.pbxproj +++ b/ios/NewExpensify.xcodeproj/project.pbxproj @@ -681,6 +681,7 @@ "${PODS_ROOT}/Target Support Files/Pods-NewExpensify-NewExpensifyTests/Pods-NewExpensify-NewExpensifyTests-frameworks.sh", "${BUILT_PRODUCTS_DIR}/MapboxMaps/MapboxMaps.framework", "${BUILT_PRODUCTS_DIR}/Turf/Turf.framework", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/FullStory/FullStory.framework/FullStory", "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxCommon/MapboxCommon.framework/MapboxCommon", "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxCoreMaps/MapboxCoreMaps.framework/MapboxCoreMaps", "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxMobileEvents/MapboxMobileEvents.framework/MapboxMobileEvents", @@ -692,6 +693,7 @@ outputPaths = ( "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxMaps.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Turf.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FullStory.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxCommon.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxCoreMaps.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxMobileEvents.framework", @@ -735,6 +737,7 @@ "${PODS_ROOT}/Target Support Files/Pods-NewExpensify/Pods-NewExpensify-frameworks.sh", "${BUILT_PRODUCTS_DIR}/MapboxMaps/MapboxMaps.framework", "${BUILT_PRODUCTS_DIR}/Turf/Turf.framework", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/FullStory/FullStory.framework/FullStory", "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxCommon/MapboxCommon.framework/MapboxCommon", "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxCoreMaps/MapboxCoreMaps.framework/MapboxCoreMaps", "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxMobileEvents/MapboxMobileEvents.framework/MapboxMobileEvents", @@ -746,6 +749,7 @@ outputPaths = ( "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxMaps.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Turf.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FullStory.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxCommon.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxCoreMaps.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxMobileEvents.framework", diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index ddcc64604581..8f7fa5605164 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -104,6 +104,11 @@ UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown + FullStory + + OrgId + o-1WN56P-na1 + UISupportedInterfaceOrientations~ipad UIInterfaceOrientationPortrait diff --git a/ios/Podfile.lock b/ios/Podfile.lock index f564bfd931e4..404fdfd3ffaf 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -138,6 +138,27 @@ PODS: - GoogleUtilities/Environment (~> 7.7) - "GoogleUtilities/NSData+zlib (~> 7.7)" - fmt (6.2.1) + - FullStory (1.43.1) + - fullstory_react-native (1.4.2): + - FullStory (~> 1.14) + - glog + - hermes-engine + - RCT-Folly (= 2022.05.16.00) + - RCTRequired + - RCTTypeSafety + - React-Codegen + - React-Core + - React-debug + - React-Fabric + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - glog (0.3.5) - GoogleAppMeasurement (8.8.0): - GoogleAppMeasurement/AdIdSupport (= 8.8.0) @@ -2196,6 +2217,7 @@ SPEC REPOS: - FirebasePerformance - FirebaseRemoteConfig - fmt + - FullStory - GoogleAppMeasurement - GoogleDataTransport - GoogleSignIn @@ -2244,6 +2266,10 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-modules-core" FBLazyVector: :path: "../node_modules/react-native/Libraries/FBLazyVector" + fullstory_react-native: + :path: "../node_modules/@fullstory/react-native" + FBReactNativeSpec: + :path: "../node_modules/react-native/React/FBReactNativeSpec" glog: :podspec: "../node_modules/react-native/third-party-podspecs/glog.podspec" hermes-engine: @@ -2461,6 +2487,8 @@ SPEC CHECKSUMS: FirebasePerformance: 0c01a7a496657d7cea86d40c0b1725259d164c6c FirebaseRemoteConfig: 2d6e2cfdb49af79535c8af8a80a4a5009038ec2b fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 + FullStory: e035758fef275fb59c6471f61b179652aeca452b + fullstory_react-native: a56e2bb52753b69f01aab3ae876087db08488034 glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2 GoogleAppMeasurement: 5ba1164e3c844ba84272555e916d0a6d3d977e91 GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a diff --git a/jest/setup.ts b/jest/setup.ts index 488e3e36a1d3..174e59a7e493 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -2,9 +2,11 @@ import '@shopify/flash-list/jestSetup'; import 'react-native-gesture-handler/jestSetup'; import mockStorage from 'react-native-onyx/dist/storage/__mocks__'; import 'setimmediate'; +import mockFSLibrary from './setupMockFullstoryLib'; import setupMockImages from './setupMockImages'; setupMockImages(); +mockFSLibrary(); // This mock is required as per setup instructions for react-navigation testing // https://reactnavigation.org/docs/testing/#mocking-native-modules diff --git a/jest/setupMockFullstoryLib.ts b/jest/setupMockFullstoryLib.ts new file mode 100644 index 000000000000..9edfccab9441 --- /dev/null +++ b/jest/setupMockFullstoryLib.ts @@ -0,0 +1,24 @@ +type FSPageInterface = { + start: jest.Mock; +}; + +export default function mockFSLibrary() { + jest.mock('@fullstory/react-native', () => { + class Fullstory { + consent = jest.fn(); + + anonymize = jest.fn(); + + identify = jest.fn(); + } + + return { + FSPage(): FSPageInterface { + return { + start: jest.fn(), + }; + }, + default: Fullstory, + }; + }); +} diff --git a/package-lock.json b/package-lock.json index 920fefc8242b..902f571b31f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,8 @@ "@formatjs/intl-locale": "^3.3.0", "@formatjs/intl-numberformat": "^8.5.0", "@formatjs/intl-pluralrules": "^5.2.2", + "@fullstory/browser": "^2.0.3", + "@fullstory/react-native": "^1.4.0", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.2.2", "@kie/act-js": "^2.6.0", @@ -5590,6 +5592,52 @@ "tslib": "^2.4.0" } }, + "node_modules/@fullstory/babel-plugin-annotate-react": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@fullstory/babel-plugin-annotate-react/-/babel-plugin-annotate-react-2.3.0.tgz", + "integrity": "sha512-gYLUL6Tu0exbvTIhK9nSCaztmqBlQAm07Fvtl/nKTc+lxwFkcX9vR8RrdTbyjJZKbPaA5EMlExQ6GeLCXkfm5g==" + }, + "node_modules/@fullstory/babel-plugin-react-native": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@fullstory/babel-plugin-react-native/-/babel-plugin-react-native-1.1.0.tgz", + "integrity": "sha512-BqfSUdyrrYrZM286GzdHd3qCdbitxUAIM0Z+HpoOTGWVTLDpkFNNaRw5juq8YhYbcPm6BAtK0RMGY7CvcMNarA==", + "dependencies": { + "@babel/parser": "^7.0.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@fullstory/browser": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@fullstory/browser/-/browser-2.0.3.tgz", + "integrity": "sha512-usjH8FB1O2LiSWoblsuKhFhlYDGpIPuyQVOx4JXtxm9QmQARdKZdNq1vPijxuDvOGjhwtVZa4JmhvByRRuDPnQ==", + "dependencies": { + "@fullstory/snippet": "2.0.3" + } + }, + "node_modules/@fullstory/react-native": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@fullstory/react-native/-/react-native-1.4.2.tgz", + "integrity": "sha512-Ig85ghn5UN+Tc1JWL/y4hY9vleeaVHL3f6qH9W4odDNP4XAv29+G82nIYQhBOQGoVnIQ4oQFQftir/dqAbidSw==", + "dependencies": { + "@fullstory/babel-plugin-annotate-react": "^2.2.0", + "@fullstory/babel-plugin-react-native": "^1.1.0" + }, + "peerDependencies": { + "expo": ">=47.0.0", + "react": "*", + "react-native": ">=0.61.0" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/@fullstory/snippet": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@fullstory/snippet/-/snippet-2.0.3.tgz", + "integrity": "sha512-EaCuTQSLv5FvnjHLbTxErn3sS1+nLqf1p6sA/c4PV49stBtkUakA0eLhJJdaw0WLdXyEzZXf86lRNsjEzrgGPw==" + }, "node_modules/@gar/promisify": { "version": "1.1.3", "license": "MIT" diff --git a/package.json b/package.json index 20d066eabebe..832bcd94dfab 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,8 @@ "@formatjs/intl-locale": "^3.3.0", "@formatjs/intl-numberformat": "^8.5.0", "@formatjs/intl-pluralrules": "^5.2.2", + "@fullstory/browser": "^2.0.3", + "@fullstory/react-native": "^1.4.0", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.2.2", "@kie/act-js": "^2.6.0", diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index 506eae2bdfd2..5760959c1b02 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -6,6 +6,7 @@ import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useCurrentReportID from '@hooks/useCurrentReportID'; import useTheme from '@hooks/useTheme'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import {FSPage} from '@libs/fullstory'; import Log from '@libs/Log'; import {getPathFromURL} from '@libs/Url'; import {updateLastVisitedPath} from '@userActions/App'; @@ -57,6 +58,8 @@ function parseAndLogRoute(state: NavigationState) { } Navigation.setIsNavigationReady(); + // Fullstory Page navigation tracking + new FSPage(focusedRoute?.name ?? '', {path: currentPath}).start(); } function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: NavigationRootProps) { diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 7f7531a094fa..52cd4469f253 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -23,6 +23,7 @@ import type SignInUserParams from '@libs/API/parameters/SignInUserParams'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as Authentication from '@libs/Authentication'; import * as ErrorUtils from '@libs/ErrorUtils'; +import Fullstory from '@libs/fullstory'; import HttpUtils from '@libs/HttpUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; @@ -63,6 +64,11 @@ Onyx.connect({ }, }); +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: Fullstory.consentAndIdentify, +}); + let stashedSession: Session = {}; Onyx.connect({ key: ONYXKEYS.STASHED_SESSION, diff --git a/src/libs/fullstory/index.native.ts b/src/libs/fullstory/index.native.ts new file mode 100644 index 000000000000..e1781e6fed2d --- /dev/null +++ b/src/libs/fullstory/index.native.ts @@ -0,0 +1,60 @@ +import FullStory, {FSPage} from '@fullstory/react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import type Session from '@src/types/onyx/Session'; +import type {UserSession} from './types'; + +/** + * Fullstory React-Native lib adapter + * Proxy function calls to React-Native lib + * */ +const FS = { + /** + * Sets the identity as anonymous using the FullStory library. + */ + anonymize: () => FullStory.anonymize(), + + /** + * Sets the identity consent status using the FullStory library. + */ + consent: (c: boolean) => FullStory.consent(c), + + /** + * Initializes the FullStory session with the provided session information. + */ + consentAndIdentify: (value: OnyxEntry) => { + try { + const session: UserSession = { + email: value?.email, + accountID: value?.accountID, + }; + // set consent + FullStory.consent(true); + FS.fsIdentify(session); + } catch (e) { + // error handler + } + }, + + /** + * Sets the FullStory user identity based on the provided session information. + * If the session is null or the email is 'undefined', the user identity is anonymized. + * If the session contains an email, the user identity is defined with the email and account ID. + */ + fsIdentify: (session: UserSession) => { + if (!session || session.email === 'undefined') { + // anonymize FullStory user identity session + FullStory.anonymize(); + } else { + // define FullStory user identity + FullStory.identify(String(session.accountID), { + properties: { + displayName: session.email, + email: session.email, + }, + }); + } + }, +}; + +export default FS; +export {FSPage}; diff --git a/src/libs/fullstory/index.ts b/src/libs/fullstory/index.ts new file mode 100644 index 000000000000..3f42005e859b --- /dev/null +++ b/src/libs/fullstory/index.ts @@ -0,0 +1,90 @@ +import {FullStory, init, isInitialized} from '@fullstory/browser'; +import type {OnyxEntry} from 'react-native-onyx'; +import type Session from '@src/types/onyx/Session'; +import type {NavigationProperties, UserSession} from './types'; + +// Placeholder Browser API does not support Manual Page definition +class FSPage { + private pageName; + + private properties; + + constructor(name: string, properties: NavigationProperties) { + this.pageName = name; + this.properties = properties; + } + + start() {} +} + +/** + * Web does not use Fullstory React-Native lib + * Proxy function calls to Browser Snippet instance + * */ +const FS = { + /** + * Executes a function when the FullStory library is ready, either by initialization or by observing the start event. + */ + onReady: () => + new Promise((resolve) => { + // Initialised via HEAD snippet + if (isInitialized()) { + init({orgId: ''}, resolve); + } else { + FullStory('observe', {type: 'start', callback: resolve}); + } + }), + + /** + * Sets the identity as anonymous using the FullStory library. + */ + anonymize: () => FullStory('setIdentity', {anonymous: true}), + + /** + * Sets the identity consent status using the FullStory library. + */ + consent: (c: boolean) => FullStory('setIdentity', {consent: c}), + + /** + * Initializes the FullStory session with the provided session information. + */ + consentAndIdentify: (value: OnyxEntry) => { + try { + FS.onReady().then(() => { + const session: UserSession = { + email: value?.email, + accountID: value?.accountID, + }; + // set consent + FS.consent(true); + FS.fsIdentify(session); + }); + } catch (e) { + // error handler + } + }, + + /** + * Sets the FullStory user identity based on the provided session information. + * If the session does not contain an email, the user identity is anonymized. + * If the session contains an email, the user identity is defined with the email and account ID. + */ + fsIdentify: (session: UserSession) => { + if (typeof session.email === 'undefined') { + // anonymize FullStory user identity session + FS.anonymize(); + } else { + // define FullStory user identity + FullStory('setIdentity', { + uid: String(session.accountID), + properties: { + displayName: session.email, + email: session.email, + }, + }); + } + }, +}; + +export default FS; +export {FSPage}; diff --git a/src/libs/fullstory/types.ts b/src/libs/fullstory/types.ts new file mode 100644 index 000000000000..386e35536d97 --- /dev/null +++ b/src/libs/fullstory/types.ts @@ -0,0 +1,10 @@ +type UserSession = { + email: string | undefined; + accountID: number | undefined; +}; + +type NavigationProperties = { + path: string; +}; + +export type {UserSession, NavigationProperties};