diff --git a/android/app/build.gradle b/android/app/build.gradle index a792e6ab1..b045e780b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -2,6 +2,7 @@ apply plugin: "com.android.application" apply plugin: "org.jetbrains.kotlin.android" apply plugin: "com.facebook.react" apply plugin: "com.google.gms.google-services" +apply plugin: "com.google.firebase.firebase-perf" // set up different .env files so fastlane will automatically build using .env // and dev builds will use .env.staging diff --git a/android/build.gradle b/android/build.gradle index ceb6dda83..34249e940 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -22,6 +22,7 @@ buildscript { classpath("com.facebook.react:react-native-gradle-plugin") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin") classpath("com.google.gms:google-services:4.4.3") + classpath("com.google.firebase:perf-plugin:2.0.1") } } diff --git a/ios/Podfile.lock b/ios/Podfile.lock index be50bb65f..825d4698d 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -39,6 +39,11 @@ PODS: - FBLazyVector (0.80.2) - Firebase/CoreOnly (12.3.0): - FirebaseCore (~> 12.3.0) + - Firebase/Performance (12.3.0): + - Firebase/CoreOnly + - FirebasePerformance (~> 12.3.0) + - FirebaseABTesting (12.3.0): + - FirebaseCore (~> 12.3.0) - FirebaseAnalytics/Core (12.3.0): - FirebaseCore (~> 12.3.0) - FirebaseInstallations (~> 12.3.0) @@ -61,6 +66,8 @@ PODS: - FirebaseCoreInternal (~> 12.3.0) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreExtension (12.3.0): + - FirebaseCore (~> 12.3.0) - FirebaseCoreInternal (12.3.0): - "GoogleUtilities/NSData+zlib (~> 8.1)" - FirebaseInstallations (12.3.0): @@ -68,6 +75,35 @@ PODS: - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/UserDefaults (~> 8.1) - PromisesObjC (~> 2.4) + - FirebasePerformance (12.3.0): + - FirebaseCore (~> 12.3.0) + - FirebaseInstallations (~> 12.3.0) + - FirebaseRemoteConfig (~> 12.3.0) + - FirebaseSessions (~> 12.3.0) + - GoogleDataTransport (~> 10.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - nanopb (~> 3.30910.0) + - FirebaseRemoteConfig (12.3.0): + - FirebaseABTesting (~> 12.3.0) + - FirebaseCore (~> 12.3.0) + - FirebaseInstallations (~> 12.3.0) + - FirebaseRemoteConfigInterop (~> 12.3.0) + - FirebaseSharedSwift (~> 12.3.0) + - GoogleUtilities/Environment (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseRemoteConfigInterop (12.3.0) + - FirebaseSessions (12.3.0): + - FirebaseCore (~> 12.3.0) + - FirebaseCoreExtension (~> 12.3.0) + - FirebaseInstallations (~> 12.3.0) + - GoogleDataTransport (~> 10.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - nanopb (~> 3.30910.0) + - PromisesSwift (~> 2.1) + - FirebaseSharedSwift (12.3.0) - fmt (11.0.2) - glog (0.3.5) - GoogleAppMeasurement/Core (12.3.0): @@ -83,6 +119,9 @@ PODS: - GoogleUtilities/Network (~> 8.1) - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) - GoogleSignIn (7.1.0): - AppAuth (< 2.0, >= 1.7.3) - GTMAppAuth (< 5.0, >= 4.1.1) @@ -128,6 +167,8 @@ PODS: - nanopb/decode (3.30910.0) - nanopb/encode (3.30910.0) - PromisesObjC (2.4.0) + - PromisesSwift (2.4.0): + - PromisesObjC (= 2.4.0) - RCT-Folly (2024.11.18.00): - boost - DoubleConversion @@ -2741,6 +2782,10 @@ PODS: - RNFBApp (23.4.0): - Firebase/CoreOnly (= 12.3.0) - React-Core + - RNFBPerf (23.4.0): + - Firebase/Performance (= 12.3.0) + - React-Core + - RNFBApp - RNFS (2.20.0): - React-Core - RNGestureHandler (2.28.0): @@ -3340,6 +3385,7 @@ DEPENDENCIES: - RNDeviceInfo (from `../node_modules/react-native-device-info`) - "RNFBAnalytics (from `../node_modules/@react-native-firebase/analytics`)" - "RNFBApp (from `../node_modules/@react-native-firebase/app`)" + - "RNFBPerf (from `../node_modules/@react-native-firebase/perf`)" - RNFS (from `../node_modules/react-native-fs`) - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) - "RNGoogleSignin (from `../node_modules/@react-native-google-signin/google-signin`)" @@ -3359,11 +3405,19 @@ SPEC REPOS: trunk: - AppAuth - Firebase + - FirebaseABTesting - FirebaseAnalytics - FirebaseCore + - FirebaseCoreExtension - FirebaseCoreInternal - FirebaseInstallations + - FirebasePerformance + - FirebaseRemoteConfig + - FirebaseRemoteConfigInterop + - FirebaseSessions + - FirebaseSharedSwift - GoogleAppMeasurement + - GoogleDataTransport - GoogleSignIn - GoogleUtilities - GTMAppAuth @@ -3371,6 +3425,7 @@ SPEC REPOS: - Mute - nanopb - PromisesObjC + - PromisesSwift - React-Codegen - SocketRocket @@ -3586,6 +3641,8 @@ EXTERNAL SOURCES: :path: "../node_modules/@react-native-firebase/analytics" RNFBApp: :path: "../node_modules/@react-native-firebase/app" + RNFBPerf: + :path: "../node_modules/@react-native-firebase/perf" RNFS: :path: "../node_modules/react-native-fs" RNGestureHandler: @@ -3622,13 +3679,21 @@ SPEC CHECKSUMS: FasterImage: 0543472141d8f64716efdeb3bb886a6fe814f3b9 FBLazyVector: 86588b5a1547e7a417942a08f49559b184e002c8 Firebase: f5439b235721ceeef14ca1f327c0da8e4e8556b5 + FirebaseABTesting: 6e748dfecb81a49539c030adb5b12263acf62865 FirebaseAnalytics: fefb765365c6d3a4f2cb9d2c99dcf595124d41ae FirebaseCore: ff47fe1ad3ab9ef66edd3e8bc4647b493d2067f8 + FirebaseCoreExtension: 898ca55c7cb126f83747b32cb0daa95394b10853 FirebaseCoreInternal: a9e1ff270f217489d9258563b693d11a312903bf FirebaseInstallations: ca48ec60ea51b66b9f214a91847ea3720cde97f5 + FirebasePerformance: 12f8e5e26b7b7ae91db36e5fcd01e0c8b323e364 + FirebaseRemoteConfig: 4f4785cfc62e3e3b2f3715d1b7e41ade5ac0419e + FirebaseRemoteConfigInterop: 66d61ad6cee1cd2137cb8dedf468112e9ba92c6a + FirebaseSessions: 352b204966530e5cb7036c02d8c366922210e681 + FirebaseSharedSwift: a08758c5e2617f3ebb47865b45ecca6f02cd35ea fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 GoogleAppMeasurement: 9074f50943fe26a7829be3a2b0043270ad935b9e + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleSignIn: d4281ab6cf21542b1cfaff85c191f230b399d2db GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de @@ -3637,6 +3702,7 @@ SPEC CHECKSUMS: Mute: 20135a96076f140cc82bfc8b810e2d6150d8ec7e nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: 300c5eb91114d4339b0bb39505d0f4824d7299b7 RCTRequired: e0446b01093475b7082fbeee5d1ef4ad1fe20ac4 @@ -3734,6 +3800,7 @@ SPEC CHECKSUMS: RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047 RNFBAnalytics: 2c4927af02d587595a7888a8275b7e3a2ccc1842 RNFBApp: 0211ae65fadcb017cc787b2bd539847330687b93 + RNFBPerf: 3e7b00d193c37044790f92053fc013c12f385c75 RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8 RNGestureHandler: a52292df4f8fc7daab354ad2c37be401d4c3bed8 RNGoogleSignin: 65a7b18dd8fd9f279068ede103db4840f6782d99 diff --git a/package-lock.json b/package-lock.json index 5ca8224fe..7bd9a7ef2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@react-native-community/slider": "^5.1.1", "@react-native-firebase/analytics": "^23.4.0", "@react-native-firebase/app": "^23.3.1", + "@react-native-firebase/perf": "^23.4.0", "@react-native-google-signin/google-signin": "^13.1.0", "@react-native-picker/picker": "^2.11.1", "@react-native-vector-icons/common": "^12.3.0", @@ -5495,6 +5496,21 @@ } } }, + "node_modules/@react-native-firebase/perf": { + "version": "23.4.0", + "resolved": "https://registry.npmjs.org/@react-native-firebase/perf/-/perf-23.4.0.tgz", + "integrity": "sha512-E8UAmAdRIxlW3s6CLkmr4UHZSvMF62CxrG/QVDMZwJ0W/TYlyQZGooU5XE1wNYGxz8V3lPvCM1+7HemqrXrDUg==", + "license": "Apache-2.0", + "peerDependencies": { + "@react-native-firebase/app": "23.4.0", + "expo": ">=47.0.0" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, "node_modules/@react-native-google-signin/google-signin": { "version": "13.1.0", "resolved": "https://registry.npmjs.org/@react-native-google-signin/google-signin/-/google-signin-13.1.0.tgz", diff --git a/package.json b/package.json index 9653aa354..d9796fc6a 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@react-native-community/slider": "^5.1.1", "@react-native-firebase/analytics": "^23.4.0", "@react-native-firebase/app": "^23.3.1", + "@react-native-firebase/perf": "^23.4.0", "@react-native-google-signin/google-signin": "^13.1.0", "@react-native-picker/picker": "^2.11.1", "@react-native-vector-icons/common": "^12.3.0", diff --git a/src/components/Camera/AICamera/AICamera.js b/src/components/Camera/AICamera/AICamera.js index ed9f00988..b805a50ed 100644 --- a/src/components/Camera/AICamera/AICamera.js +++ b/src/components/Camera/AICamera/AICamera.js @@ -16,6 +16,7 @@ import { VolumeManager } from "react-native-volume-manager"; import convertScoreToConfidence from "sharedHelpers/convertScores"; import { log } from "sharedHelpers/logger"; import { deleteSentinelFile, logStage } from "sharedHelpers/sentinelFiles"; +import { logFirebaseEvent } from "sharedHelpers/tracking"; import { useDebugMode, useLayoutPrefs, @@ -172,6 +173,7 @@ const AICamera = ( { const handleTakePhoto = useCallback( async ( ) => { await logStage( sentinelFileName, "take_photo_start" ); setHasTakenPhoto( true ); + logFirebaseEvent( "ai_camera_shutter_tap", { hasLocationPermissions } ); // this feels a little duplicative, but we're currently using aICameraSuggestion // to show the loading screen in Suggestions *without* setting an observation.taxon, // and we're using visionResult to populate ObsEdit *with* the taxon @@ -195,7 +197,8 @@ const AICamera = ( { setAICameraSuggestion, sentinelFileName, takePhotoAndStoreUri, - result + result, + hasLocationPermissions ] ); useEffect( () => { diff --git a/src/components/Camera/CameraContainer.tsx b/src/components/Camera/CameraContainer.tsx index 7ce8600ac..c98597d49 100644 --- a/src/components/Camera/CameraContainer.tsx +++ b/src/components/Camera/CameraContainer.tsx @@ -22,6 +22,7 @@ import { log } from "sharedHelpers/logger"; import { createSentinelFile, deleteSentinelFile, logStage } from "sharedHelpers/sentinelFiles"; import { useTranslation } from "sharedHooks"; import useLocationPermission from "sharedHooks/useLocationPermission"; +import { FIREBASE_TRACE_ATTRIBUTES, FIREBASE_TRACES } from "stores/createFirebaseTraceSlice"; import useStore from "stores/useStore"; import CameraWithDevice from "./CameraWithDevice"; @@ -67,6 +68,7 @@ const CameraContainer = ( ) => { const sentinelFileName = useStore( state => state.sentinelFileName ); const setSentinelFileName = useStore( state => state.setSentinelFileName ); const addCameraRollUris = useStore( state => state.addCameraRollUris ); + const startFirebaseTrace = useStore( state => state.startFirebaseTrace ); const { params } = useRoute( ); const cameraType = params?.camera; @@ -171,8 +173,13 @@ const CameraContainer = ( ) => { // and we want to make sure Suggestions has the correct observationPhotos const handleNavigation = useCallback( async ( newPhotoState: PhotoState, - visionResult: StoredResult | null + hasSavePhotoPermission: boolean, + visionResult?: StoredResult | null ) => { + startFirebaseTrace( + FIREBASE_TRACES.AI_CAMERA_TO_MATCH, + { [FIREBASE_TRACE_ATTRIBUTES.HAS_SAVE_PHOTO_PERMISSION]: `${hasSavePhotoPermission}` } + ); // fetch accurate user location, with a fallback to a course location // at the time the user taps AI shutter or multicapture checkmark // to create an observation @@ -191,7 +198,8 @@ const CameraContainer = ( ) => { prepareStoreAndNavigate, navigationOptions, logStageIfAICamera, - deleteStageIfAICamera + deleteStageIfAICamera, + startFirebaseTrace ] ); const handleCheckmarkPress = useCallback( async ( @@ -199,7 +207,7 @@ const CameraContainer = ( ) => { visionResult: StoredResult | null ) => { if ( !showPhotoPermissionsGate ) { - await handleNavigation( newPhotoState, visionResult ); + await handleNavigation( newPhotoState, true, visionResult ); } else { await logStageIfAICamera( "request_save_photo_permission_start" ); requestSavePhotoPermission( ); @@ -355,14 +363,14 @@ const CameraContainer = ( ) => { await handleNavigation( { cameraUris, evidenceToAdd - } ); + }, true ); }, onModalHide: async ( ) => { await logStageIfAICamera( "request_save_photo_permission_complete" ); await handleNavigation( { cameraUris, evidenceToAdd - } ); + }, false ); } } )} {renderLocationPermissionsGate( { diff --git a/src/components/Match/MatchContainer.js b/src/components/Match/MatchContainer.js index d90056b8f..9a59281e1 100644 --- a/src/components/Match/MatchContainer.js +++ b/src/components/Match/MatchContainer.js @@ -28,6 +28,7 @@ import { useExitObservationFlow, useLocationPermission, useSuggestions, useWatchPosition } from "sharedHooks"; import { isDebugMode } from "sharedHooks/useDebugMode"; +import { FIREBASE_TRACE_ATTRIBUTES, FIREBASE_TRACES } from "stores/createFirebaseTraceSlice"; import useStore from "stores/useStore"; import tryToReplaceWithLocalTaxon from "./helpers/tryToReplaceWithLocalTaxon"; @@ -125,6 +126,8 @@ const MatchContainer = ( ) => { shouldUseEvidenceLocation: evidenceHasLocation } ); + const stopFirebaseTrace = useStore( state => state.stopFirebaseTrace ); + const { scoreImageParams, onlineFetchStatus, @@ -426,6 +429,24 @@ const MatchContainer = ( ) => { const suggestionsLoading = onlineFetchStatus === FETCH_STATUS_LOADING || offlineFetchStatus === FETCH_STATUS_LOADING; + useEffect( ( ) => { + if ( + onlineSuggestionsAttempted + && !suggestionsLoading + ) { + // This should capture a case where online and offline have had a chance to load + stopFirebaseTrace( + FIREBASE_TRACES.AI_CAMERA_TO_MATCH, + { [FIREBASE_TRACE_ATTRIBUTES.ONLINE]: `${!usingOfflineSuggestions}` } + ); + } + }, [ + onlineSuggestionsAttempted, + suggestionsLoading, + stopFirebaseTrace, + usingOfflineSuggestions + ] ); + // Remove the top suggestion from the list of other suggestions const otherSuggestions = orderedSuggestions .filter( suggestion => suggestion.taxon.id !== taxonId ); diff --git a/src/components/Onboarding/OnboardingCarousel.js b/src/components/Onboarding/OnboardingCarousel.js index 0491eb31d..a08f2db9a 100644 --- a/src/components/Onboarding/OnboardingCarousel.js +++ b/src/components/Onboarding/OnboardingCarousel.js @@ -29,6 +29,7 @@ import AnimatedDotsCarousel from "react-native-animated-dots-carousel"; import Animated, { interpolate, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; import Carousel from "react-native-reanimated-carousel"; import { useOnboardingShown } from "sharedHelpers/installData"; +import { logFirebaseEvent } from "sharedHelpers/tracking"; import colors from "styles/tailwindColors"; const SlideItem = props => { @@ -75,7 +76,10 @@ const OnboardingCarousel = ( ) => { const [currentIndex, setCurrentIndex] = useState( 0 ); const [imagesLoaded, setImagesLoaded] = useState( false ); - const closeModal = () => setOnboardingShown( true ); + const closeModal = () => { + logFirebaseEvent( "onboarding_close_pressed", { current_slide: currentIndex } ); + setOnboardingShown( true ); + }; const paginationColor = colors.white; const backgroundAnimation1 = useAnimatedStyle( () => { @@ -314,10 +318,19 @@ const OnboardingCarousel = ( ) => { level="primary" forceDark text={t( "CONTINUE" )} - onPress={() => ( - carouselRef.current?.getCurrentIndex() >= ONBOARDING_SLIDES.length - 1 - ? closeModal() - : carouselRef.current?.scrollTo( { count: 1, animated: true } ) )} + onPress={() => { + logFirebaseEvent( + "onboarding_continue_pressed", + { current_slide: currentIndex } + ); + const isLastSlide = carouselRef.current?.getCurrentIndex() + >= ONBOARDING_SLIDES.length - 1; + if ( isLastSlide ) { + closeModal(); + } else { + carouselRef.current?.scrollTo( { count: 1, animated: true } ); + } + }} /> diff --git a/src/components/SharedComponents/Buttons/PressableWithTracking.tsx b/src/components/SharedComponents/Buttons/PressableWithTracking.tsx index 0147a56a3..20fde7d1a 100644 --- a/src/components/SharedComponents/Buttons/PressableWithTracking.tsx +++ b/src/components/SharedComponents/Buttons/PressableWithTracking.tsx @@ -1,4 +1,3 @@ -import { getAnalytics, logEvent } from "@react-native-firebase/analytics"; import { getCurrentRoute } from "navigation/navigationUtils"; import React from "react"; import type { GestureResponderEvent, PressableProps } from "react-native"; @@ -15,12 +14,6 @@ const PressableWithTracking = React.forwardRef if ( otherProps?.testID ) { const currentRoute = getCurrentRoute( ); logger.info( `Button tap: ${otherProps?.testID}-${currentRoute?.name || "undefined"}` ); - // Basic button tap tracking with Firebase Analytics - const analytics = getAnalytics(); - logEvent( analytics, "button_tap", { - testID: otherProps?.testID, - screen: currentRoute?.name - } ); } if ( onPress ) { diff --git a/src/navigation/OfflineNavigationGuard.tsx b/src/navigation/OfflineNavigationGuard.tsx index 34f2f63ce..37a0c957a 100644 --- a/src/navigation/OfflineNavigationGuard.tsx +++ b/src/navigation/OfflineNavigationGuard.tsx @@ -1,11 +1,11 @@ import { useNetInfo } from "@react-native-community/netinfo"; -import { getAnalytics, logEvent } from "@react-native-firebase/analytics"; import { NavigationContainer } from "@react-navigation/native"; import type { PropsWithChildren } from "react"; import React, { useRef } from "react"; import { Alert } from "react-native"; +import { logFirebaseScreenView } from "sharedHelpers/tracking"; import { useTranslation } from "sharedHooks"; import { navigationRef } from "./navigationUtils"; @@ -22,13 +22,8 @@ const OfflineNavigationGuard = ( { children }: PropsWithChildren ) => { const previousRouteName = routeNameRef.current; const currentRouteName = navigationRef.current?.getCurrentRoute( )?.name; // Basic screen tracking with Firebase Analytics - if ( previousRouteName !== currentRouteName ) { - const analytics = getAnalytics(); - // @ts-expect-error https://github.com/invertase/react-native-firebase/pull/8687 - logEvent( analytics, "screen_view", { - screen_name: currentRouteName, - screen_class: currentRouteName - } ); + if ( previousRouteName !== currentRouteName && currentRouteName ) { + logFirebaseScreenView( currentRouteName ); } if ( currentRouteName === "Login" && !isConnected ) { // return to previous screen if offline diff --git a/src/sharedHelpers/tracking.ts b/src/sharedHelpers/tracking.ts new file mode 100644 index 000000000..3ce03ecf8 --- /dev/null +++ b/src/sharedHelpers/tracking.ts @@ -0,0 +1,34 @@ +import { + getAnalytics, logEvent +} from "@react-native-firebase/analytics"; +import { log } from "sharedHelpers/logger"; + +const logger = log.extend( "tracking.ts" ); + +type FirebaseParameters = Record + +export const logFirebaseEvent = ( + eventId: string, + parameters?: FirebaseParameters +) => { + try { + const analytics = getAnalytics(); + logEvent( analytics, eventId, parameters ); + } catch ( error ) { + logger.error( "Error logging firebase event", JSON.stringify( error ) ); + } +}; + +export const logFirebaseScreenView = ( + screenName: string +) => { + try { + const analytics = getAnalytics(); + logEvent( analytics, "screen_view", { + firebase_screen: screenName, + firebase_screen_class: screenName + } ); + } catch ( error ) { + logger.error( "Error logging firebase screen view", JSON.stringify( error ) ); + } +}; diff --git a/src/stores/createFirebaseTraceSlice.ts b/src/stores/createFirebaseTraceSlice.ts new file mode 100644 index 000000000..8e0e89fb0 --- /dev/null +++ b/src/stores/createFirebaseTraceSlice.ts @@ -0,0 +1,99 @@ +import type { FirebasePerformanceTypes } from "@react-native-firebase/perf"; +import { getPerformance } from "@react-native-firebase/perf"; +import type { StateCreator } from "zustand"; + +import { log } from "../../react-native-logs.config"; + +const logger = log.extend( "createFirebaseTraceSlice.ts" ); + +export enum FIREBASE_TRACES { + AI_CAMERA_TO_MATCH = "ai_camera_to_match", +} + +export enum FIREBASE_TRACE_ATTRIBUTES { + ONLINE = "online", + DID_TIMEOUT = "did_timeout", + HAS_SAVE_PHOTO_PERMISSION = "has_save_photo_permission", +} + +interface TraceData { + trace: FirebasePerformanceTypes.Trace; + timeoutId: ReturnType; +} + +type FirebaseTraceAttributes = Partial>; + +const TRACE_TIMEOUT = 10000; + +const applyTraceAttributes = ( + trace: FirebasePerformanceTypes.Trace, + attributes: FirebaseTraceAttributes +): void => { + try { + Object.entries( attributes ).forEach( ( [key, value] ) => { + if ( typeof value === "string" ) { + // Firebase will silently fail if we exceed these limits + if ( key.length <= 40 && value.length <= 100 ) { + trace.putAttribute( key, value ); + } else { + logger.error( `Failed to set firebase attribute (too long): ${key}=${value}` ); + } + } else if ( key.length <= 32 && /^(?!_)[^\s](?:.*[^\s])?$/.test( key ) ) { + /* metric key constraint: "Must not have a leading or trailing whitespace, + no leading underscore '_' character and have a max length of 32 characters." */ + trace.putMetric( key, value ); + } + } ); + } catch ( error ) { + logger.error( "Error setting firebase trace attributes", JSON.stringify( error ) ); + } +}; + +export interface FirebaseTraceSlice { + activeFirebaseTraces: Record; + startFirebaseTrace: ( traceId: string, attributes: FirebaseTraceAttributes ) => Promise; + stopFirebaseTrace: ( traceId: string, attributes: FirebaseTraceAttributes ) => Promise; +} + +const createFirebaseTraceSlice: StateCreator += ( set, get ) => ( { + activeFirebaseTraces: {}, + + startFirebaseTrace: async ( traceId: string, attributes: FirebaseTraceAttributes = {} ) => { + const perf = getPerformance(); + const trace = await perf.startTrace( traceId ); + + const timeoutId = setTimeout( () => { + get().stopFirebaseTrace( traceId, { [FIREBASE_TRACE_ATTRIBUTES.DID_TIMEOUT]: "true" } ); + }, TRACE_TIMEOUT ); + + applyTraceAttributes( trace, attributes ); + + set( state => ( { + activeFirebaseTraces: { + ...state.activeFirebaseTraces, + [traceId]: { trace, timeoutId } + } + } ) ); + }, + + stopFirebaseTrace: async ( traceId: string, attributes: FirebaseTraceAttributes = {} ) => { + const { activeFirebaseTraces } = get(); + const traceData = activeFirebaseTraces[traceId]; + if ( traceData ) { + clearTimeout( traceData.timeoutId ); + + const { trace } = traceData; + applyTraceAttributes( trace, attributes ); + + await trace.stop(); + + set( state => { + const { [traceId]: _, ...remainingTraces } = state.activeFirebaseTraces; + return { activeFirebaseTraces: remainingTraces }; + } ); + } + } +} ); + +export default createFirebaseTraceSlice; diff --git a/src/stores/useStore.js b/src/stores/useStore.js index 0cee0c40b..980d1dcef 100644 --- a/src/stores/useStore.js +++ b/src/stores/useStore.js @@ -4,6 +4,7 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import createExploreSlice from "./createExploreSlice"; +import createFirebaseTraceSlice from "./createFirebaseTraceSlice"; import createLayoutSlice from "./createLayoutSlice"; import createMyObsSlice from "./createMyObsSlice"; import createObservationFlowSlice from "./createObservationFlowSlice"; @@ -32,6 +33,7 @@ const useStore = create( persist( // Let's make our slices const slices = [ createExploreSlice( ...args ), + createFirebaseTraceSlice( ...args ), createLayoutSlice( ...args ), createMyObsSlice( ...args ), createObservationFlowSlice( ...args ), diff --git a/tests/jest.setup.js b/tests/jest.setup.js index bdae3ca75..9f6eac302 100644 --- a/tests/jest.setup.js +++ b/tests/jest.setup.js @@ -109,6 +109,11 @@ jest.mock( "@react-native-firebase/analytics", () => ( { logEvent: jest.fn( ) } ) ); +jest.mock( "@react-native-firebase/perf", () => ( { + startFirebaseTrace: jest.fn( ), + stopFirebaseTrace: jest.fn( ) +} ) ); + // see https://stackoverflow.com/questions/42268673/jest-test-animated-view-for-react-native-app // for more details about this withAnimatedTimeTravelEnabled approach. basically, this // allows us to step through animation frames when a screen is first loading when we're using the