Repository: inaturalist/iNaturalistReactNative
Pull request #2855: Add tracking for Pressable and INatIconButton taps
Author: login: albullington
Created: 2025-04-14T21:46:54Z | Merged: 2025-04-16T15:54:41Z

REVISED: see comments below.

ORIGINAL:
Trying out a simple approach to analytics by logging `Button` taps. If we like this approach, the next step would be to extend this to the rest of our tappable components such as `INatIconButton`.

This currently shows up in the logs as something like what's shown below, which also alerts me to one problem with this approach -- we're translating strings before passing them to the Button, which means we'll be logging text in all languages and making it hard to aggregate any data:

`{"level":"info","message":"Button tap: {\"accessibilityHint\":null,\"accessibilityLabel\":\"APP-SPRACHE ÄNDERN\",\"testID\":null,\"text\":\"APP-SPRACHE ÄNDERN\",\"routeName\":\"Settings\"}","context":"Button","timestamp":"2025-04-14T21:36:42.246Z"}`

I'm blanking on the right way to handle finding the original, non-translated version of an i18next string once the Button component receives an already translated version as props, but I'm sure there's a way to do this...


Diff:
diff --git a/src/components/MyObservations/MyObservationsEmptySimple.js b/src/components/MyObservations/MyObservationsEmptySimple.js
index 33cce11b22..c0f08d1cb0 100644
--- a/src/components/MyObservations/MyObservationsEmptySimple.js
+++ b/src/components/MyObservations/MyObservationsEmptySimple.js
@@ -8,11 +8,12 @@ import {
 } from "components/SharedComponents";
 import GradientButton from "components/SharedComponents/Buttons/GradientButton.tsx";
 import Modal from "components/SharedComponents/Modal.tsx";
-import { View } from "components/styledComponents";
+import {
+  Pressable, View
+} from "components/styledComponents";
 import Arrow from "images/svg/curved_arrow_down.svg";
 import type { Node } from "react";
 import React, { useState } from "react";
-import { Pressable } from "react-native";
 import { useTranslation } from "sharedHooks";
 import useStore from "stores/useStore";
 
diff --git a/src/components/SharedComponents/Buttons/INatIconButton.tsx b/src/components/SharedComponents/Buttons/INatIconButton.tsx
index 663b9f58cc..893a34604c 100644
--- a/src/components/SharedComponents/Buttons/INatIconButton.tsx
+++ b/src/components/SharedComponents/Buttons/INatIconButton.tsx
@@ -1,6 +1,7 @@
 import classnames from "classnames";
 import { INatIcon } from "components/SharedComponents";
 import { View } from "components/styledComponents";
+import { getCurrentRoute } from "navigation/navigationUtils.ts";
 import React, { PropsWithChildren } from "react";
 import {
   GestureResponderEvent,
@@ -8,8 +9,11 @@ import {
   Pressable,
   ViewStyle
 } from "react-native";
+import { log } from "sharedHelpers/logger";
 import colors from "styles/tailwindColors";
 
+const logger = log.extend( "INatIconButton" );
+
 interface Props extends PropsWithChildren {
   accessibilityHint?: string;
   accessibilityLabel: string;
@@ -171,6 +175,17 @@ const INatIconButton = ( {
     );
   }
 
+  const handlePressWithTracking = ( event?: GestureResponderEvent ) => {
+    if ( testID ) {
+      const currentRoute = getCurrentRoute( );
+      logger.info( `Button tap: ${testID}-${currentRoute?.name || "undefined"}` );
+    }
+
+    if ( onPress ) {
+      onPress( event );
+    }
+  };
+
   return (
     <Pressable
       accessibilityHint={accessibilityHint}
@@ -178,7 +193,7 @@ const INatIconButton = ( {
       accessibilityRole="button"
       accessibilityState={{ disabled }}
       disabled={disabled}
-      onPress={onPress}
+      onPress={handlePressWithTracking}
       style={( { pressed } ) => [
         ...wrapperStyle,
         { opacity: getOpacity( pressed ) }
diff --git a/src/components/SharedComponents/Buttons/PressableWithTracking.tsx b/src/components/SharedComponents/Buttons/PressableWithTracking.tsx
new file mode 100644
index 0000000000..429013e46a
--- /dev/null
+++ b/src/components/SharedComponents/Buttons/PressableWithTracking.tsx
@@ -0,0 +1,26 @@
+import { getCurrentRoute } from "navigation/navigationUtils.ts";
+import React from "react";
+import { GestureResponderEvent, Pressable } from "react-native";
+import { log } from "sharedHelpers/logger";
+
+const logger = log.extend( "PressableWithTracking" );
+
+const PressableWithTracking = React.forwardRef( ( props, ref ) => {
+  const { onPress, ...otherProps } = props;
+
+  const handlePressWithTracking = ( event?: GestureResponderEvent ) => {
+    if ( otherProps?.testID ) {
+      const currentRoute = getCurrentRoute( );
+      logger.info( `Button tap: ${otherProps?.testID}-${currentRoute?.name || "undefined"}` );
+    }
+
+    if ( onPress ) {
+      onPress( event );
+    }
+  };
+
+  // eslint-disable-next-line react/jsx-props-no-spreading
+  return <Pressable {...otherProps} onPress={handlePressWithTracking} ref={ref} />;
+} );
+
+export default PressableWithTracking;
diff --git a/src/components/SharedComponents/IconicTaxonChooser.tsx b/src/components/SharedComponents/IconicTaxonChooser.tsx
index 3be2c24214..6ece3da3e1 100644
--- a/src/components/SharedComponents/IconicTaxonChooser.tsx
+++ b/src/components/SharedComponents/IconicTaxonChooser.tsx
@@ -78,6 +78,7 @@ const IconicTaxonChooser = ( {
           accessibilityHint={
             t( "Selects-iconic-taxon-X-for-identification", { iconicTaxon: iconicTaxonName } )
           }
+          testID={`INatIconButton.IconicTaxonButton.${iconicTaxonName}`}
         />
       </View>
     );
diff --git a/src/components/styledComponents.js b/src/components/styledComponents.js
index c44b42ce48..91658b3ecc 100644
--- a/src/components/styledComponents.js
+++ b/src/components/styledComponents.js
@@ -4,6 +4,8 @@ import { FasterImageView as UnstyledFasterImageView } from "@candlefinance/faste
 import {
   BottomSheetTextInput as StyledBottomSheetTextInput
 } from "@gorhom/bottom-sheet";
+import UnstyledPressableWithTracking
+  from "components/SharedComponents/Buttons/PressableWithTracking.tsx";
 import { styled } from "nativewind";
 import {
   Image as UnstyledImage,
@@ -11,7 +13,6 @@ import {
   KeyboardAvoidingView as UnstyledKeyboardAvoidingView,
   Modal as UnstyledModal,
   Platform,
-  Pressable as StyledPressable,
   SafeAreaView as UnstyledSafeAreaView,
   ScrollView as UnstyledScrollView,
   Text as UnstyledText,
@@ -37,7 +38,7 @@ const Text = styled( UnstyledText );
 // $FlowIgnore
 const TextInput = styled( UntyledTextInput );
 // $FlowIgnore
-const Pressable = styled( StyledPressable );
+const Pressable = styled( UnstyledPressableWithTracking );
 // $FlowIgnore
 const Image = styled( UnstyledImage );
 // $FlowIgnore


Reviews:
user: login: kueda
body: Close to what I had in mind. If you literally want to log every button tap, you could just pass a unique identifier to every button, e.g. you could make `testID` a required field and ensure every single button receives that prop and then do something like 

```javascript
logger.info( `button tap: ${testID}-currentRoute?.name` )
```

Then if you wanted to know if anyone ever taps the iconic taxa buttons on ObsEdit, you'd look for log events like `iconic-taxa-btn-Animalia-ObsEdit` or something.

Alternative, you could ad a new prop to `<Button>` that specifies a unique logging identifier, and its presence means the button should log taps, like

```javascript
<Button
  logPressWithId="iconic-taxon-btn-Animalia-ObsEdit"
/>
```

and then in Button you just log the press if that identifier is present.

If you want further segmentation of the log data, you could use [the `extra` param Patrick added](https://github.com/inaturalist/iNaturalistAPI/pull/497) to the logging endpoint.

Last note: we started talking about this b/c someone asked if anyone uses the iconic taxon buttons. Logging all button taps would tell us if people tap them, but you could argue that people only *use* them if they actually save an observation with one of them set, which would require logging an event when the user taps SAVE and we know they tapped one of those buttons to add their ID.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T23:27:34Z
user: login: jtklein
body: I can see the logs on the server from both types of buttons, so it is working as expected.

I think personally I would have opted for a new `logID` that specifies if we want to log this one specific button. To separate finding something in tests (via testID) from a different function of logging. But I guess either way is working.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-15T09:38:10Z
user: login: albullington
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T23:07:13Z

Review comments:
diff_hunk: @@ -37,7 +38,7 @@ const Text = styled( UnstyledText );
 // $FlowIgnore
 const TextInput = styled( UntyledTextInput );
 // $FlowIgnore
-const Pressable = styled( StyledPressable );
+const Pressable = styled( PressableWithTracking );
path: src/components/styledComponents.js
user: login: jtklein
body: Should we import as UnstyledPressableWithTracking for consistency?
created_at: 2025-04-15T09:23:27Z
author_association: MEMBER
diff_hunk: @@ -37,7 +38,7 @@ const Text = styled( UnstyledText );
 // $FlowIgnore
 const TextInput = styled( UntyledTextInput );
 // $FlowIgnore
-const Pressable = styled( StyledPressable );
+const Pressable = styled( PressableWithTracking );
path: src/components/styledComponents.js
user: login: albullington
body: yep, good call
created_at: 2025-04-15T23:07:13Z
author_association: CONTRIBUTOR

PR comments:
user: login: albullington
created_at: 2025-04-14T21:47:40Z
body: @kueda tagged you as a reviewer because I'm curious if this is the approach you had in mind & mentioned in our morning meeting. Also curious if this is in a format that's easy to turn into another Grafana dashboard.
author_association: CONTRIBUTOR
user: login: albullington
created_at: 2025-04-15T00:57:54Z
body: Neat, I like the simple one line logging statement -- much more elegant than what I was trying to do. I went for a very global solution here and added tracking around all of our Pressables as well as our INatIconButton component. If they have a testID, they'll be tracked.

I'm keen to play around with the `extra` data but will save that for another PR since @jtklein has been nudging us towards smaller PRs with less changes to reason about all at once.
author_association: CONTRIBUTOR
user: login: albullington
created_at: 2025-04-15T23:09:14Z
body: Yeah, I can see the benefit of a `logID`, but that didn't quite get at the question I wanted to answer, which is "How much tracking can we do with a minimal amount of code changes?" Also I'm not the biggest fan of sending tons of props around, and if we added that we would need to sprinkle it throughout the app.
author_association: CONTRIBUTOR