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};