Implementing a smart analytics hook for Firebase and Amplitude in Expo 44+

Let's build a unified, promise-based, error-resilient, privacy-respecting, debuggable analytics hook for Expo 44+.

In today's show, we're creating a smart analytics hook for Firebase Analytics and Amplitude using Expo!

What does "smart" mean? I'm glad you asked! It means:

  1. It allows us to send a single event to both platforms concurrently and consistently
  2. These promise-based events should resolve themselves and not return a promise, so thrown errors don't affect async functions calling them (errors in tracking shouldn't break our app)
  3. It should respect user privacy we'll need a way of allowing the user to disable this tracking
  4. It should enable debug mode on development environments
  5. It should present all this to the consumer in the neatest, tidiest possible way.

I'll be honest, this is going to get pretty hectic, so we'll break it down piece by piece.

Dependencies

We're going to need a fair few dependencies for this one:

I'm also using react-use's useAsync to effectively support asynchronous useEffects, but that one's up to you. If you do choose to use it and you're into linting, remember to enable useAsync as a custom hook definition in your ESLint config so we get our handy "rule of hooks" checks:

{
  "rules": {
    "react-hooks/exhaustive-deps": [
      "error",
      {
        "additionalHooks": "(useAsync)"
      }
    ]
  }
}

Also, if you don't have tree-shaking enabled, you'll need to import the useAsync function directly to avoid the useCss hook which works only in browser e.g.

import useAsync from 'react-use/lib/useAsync';

Right, now let's get into it.

Setup

First thing's first - we're going to need a standard hook setup. It won't take any props (for simplicity) and will return 4 things:

  • enabled: a boolean that tracks whether analytics is enabled or not
  • setEnabled: a function that allows us to toggle analytics on and off, like a regular useState
  • track: a function that takes an eventName (string) and props (record) and tracks an event
  • page: a function that takes a route (string) and tracks a pageview (well, screenview but you get it)

Here's how this will look:

import * as Amplitude from 'expo-analytics-amplitude';
import * as FirebaseAnalytics from 'expo-firebase-analytics';
import useAsync from 'react-use/lib/useAsync';
import { amplitude, environment } from '../constants/variables';
 
const useAnalytics = (): {
  enabled: boolean;
  track: (eventName: string, props?: Record<string, unknown>) => void;
  page: (route: string) => void;
  setEnabled: (enabled: boolean) => Promise<void>;
} => {
  // Temporary variables
  const enabled = false;
  const track = console.log;
  const page = console.log;
  const setEnabled = console.log;
 
  useAsync(async () => {
    console.log('Initializing analytics...');
    await Amplitude.initializeAsync(amplitude.apiKey);
    if (environment.current === 'development') {
      await FirebaseAnalytics.setDebugModeEnabled(true);
    }
  }, []);
 
  // magic will go here
 
  return {
    enabled,
    track,
    page,
    setEnabled,
  };
};
 
export default useAnalytics;

Identification

Next up is identifying users. This is super trivial as we should know when a user has logged in or out on our app. In this example, we'll make use of the useUser and useProfile hooks we talked about previously.

Because our app is aware of changes in the user state, there's no need to actually export an identify() event - we can just fire this internally whenever we see the user change.

For all these functions, we'll want to do a couple of things:

  1. Only fire the event if the user exists
  2. Only fire the event if analytics are enabled
  3. Only fire the event if the user has given us permission (more on this later)
  4. Only fire the event if we're not in development (limitations of the Firebase-Expo ecosystem)

Here we go!

import * as Amplitude from 'expo-analytics-amplitude';
import * as FirebaseAnalytics from 'expo-firebase-analytics';
 
// ...
 
const { user } = useUser();
 
useAsync(async () => {
  if (!user?.uid) {
    return;
  }
 
  if (!enabled) {
    console.log(`Failed to identify user "${user.uid}" (permissions).`);
    return;
  }
 
  console.log(`Identifying user "${user.uid}"...`);
 
  if (environment.current === 'development') {
    return;
  }
 
  try {
    await FirebaseAnalytics.setUserId(user.uid);
    await Amplitude.setUserIdAsync(user.uid);
  } catch (error) {
    catchError(error);
  }
}, [enabled, user?.uid]);

Easy, right? You can add extra logic into this later to identify user properties. For now, let's move onto event tracking.

Event tracking

Events are the core of any analytics platform - we want to know when a particular user, or cohort of users, are performing actions on our platform. We look at this from a high-level, aggregate point of view and identify trends to improve and optimize our app.

Events in both Firebase Analytics and Amplitude require two things: an event name and some optional properties. The event name is how you'll distinguish the event from others, the properties are any extra, contextual information you want to collect about that particular event instance.

Building this is much the same as the Identification event above, however I want to do 2 things here:

  1. Piece together a helpful console.log for myself based on what properties I pass in
  2. Format the event names for each platform accordingly (Firebase likes snake_case, Amplitude doesn't care... I think).

Also, something to note: Firebase Analytics has a single logEvent function that takes 1 or 2 params whereas Amplitude has different events for 1 and 2 parameter variants.

Anyway, here's how we do that:

import * as Amplitude from 'expo-analytics-amplitude';
import * as FirebaseAnalytics from 'expo-firebase-analytics';
 
// ...
 
const track = (event: string, props?: Record<string, unknown>): void => {
  if (!user?.uid) {
    return;
  }
 
  if (!enabled) {
    console.log(`Failed to track event for user "${user.uid}" (permissions).`);
    return;
  }
 
  let message = `Tracking event "${event}" for user "${user.uid}"...`;
 
  if (props) {
    message += ` with props "${Object.keys(props).join(', ')}"...`;
  } else {
    message += '...';
  }
 
  console.log(message);
 
  if (environment.current === 'development') {
    return;
  }
 
  const firebaseEventName = event.replace(/ /gu, '_').toLowerCase();
 
  try {
    if (props) {
      FirebaseAnalytics.logEvent(firebaseEventName, props).catch(console.error);
      Amplitude.logEventWithPropertiesAsync(event, props).catch(console.error);
    } else {
      FirebaseAnalytics.logEvent(firebaseEventName).catch(console.error);
      Amplitude.logEventAsync(event).catch(console.error);
    }
  } catch (error) {
    catchError(error);
  }
};

Noice. Now let's track some pageviews.

Page tracking

Page (or screen) tracking in Expo is really easy if you're using React Navigation (which you probably should be).

Let's start with the page function. A page view is, in essence, an event. This differs between analytics tools (in Segment for example, a pageview is it's own tracking type) so we'll keep it seperate to track. Additionally, it helps to simplify the function call in our navigator later on.

Our page function therefore won't be dissimilar to track. The only real difference is that we want to pass in a much simpler prop: a route (string) that contains the name of the current page we're on.

We'll log all pageviews as a single "event" type, then use the properties to provide more context i.e. the actual screen we're on.

Let's do this:

import * as Amplitude from 'expo-analytics-amplitude';
import * as FirebaseAnalytics from 'expo-firebase-analytics';
 
// ...
 
const page = (route: string): void => {
  if (!user?.uid) {
    return;
  }
 
  if (!enabled) {
    console.log(`Failed to track route for user "${user.uid}" (permissions).`);
    return;
  }
 
  console.log(`Tracking route "${route}"...`);
 
  if (environment.current === 'development') {
    return;
  }
 
  try {
    FirebaseAnalytics.logEvent('screen_view', { screen_name: route }).catch(
      console.error
    );
    Amplitude.logEventWithPropertiesAsync('Viewed page', {
      screen_name: route,
    }).catch(console.error);
  } catch (error) {
    catchError(error);
  }
};

Now, let's wire this up into our App's Navigator so we don't need to fire page() events manually:

import {
  NavigationContainer,
  useNavigationContainerRef,
} from '@react-navigation/native';
import type { FC } from 'react';
import { useRef } from 'react';
import useAnalytics from '../hooks/useAnalytics';
import LinkingConfiguration from './LinkingConfiguration';
import RootNavigator from './rootNavigator';
import themes from './themes';
 
const Navigation: FC = () => {
  const navigationRef = useNavigationContainerRef();
  const routeNameRef = useRef<string>();
  const { page } = useAnalytics();
 
  return (
    <NavigationContainer
      linking={LinkingConfiguration}
      ref={navigationRef}
      onReady={() => {
        routeNameRef.current = navigationRef.getCurrentRoute()?.name;
      }}
      onStateChange={() => {
        const previousRouteName = routeNameRef.current;
        const currentRouteName = navigationRef.getCurrentRoute()?.name;
 
        console.log({
          previousRouteName,
          currentRouteName,
        });
 
        if (previousRouteName !== currentRouteName && currentRouteName) {
          page(currentRouteName);
        }
 
        routeNameRef.current = currentRouteName;
      }}
    >
      <RootNavigator />
    </NavigationContainer>
  );
};
 
export default Navigation;

Respecting user privacy

Okay, now the really juicy part. Keeping track of whether analytics are and should be enabled is (not going to lie) a damn headache. Between Firebase's own state describing whether analytics are enabled, the various permission hoops we have to jump through and making all this available to the user in our app, it's not trivial.

Don't worry though, we can do this together. We're going to wrap all this up in a single updateAnalyticsEnabled function. The real crux of this function lies in this logic:

  1. If the user is disabling analytics OR they're enabling it and they've given us permission, then proceed with the update.
  2. If the user is enabling analytics, they haven't given us permission and we're not allowed to ask again, then we'll need to push them to Settings to change their privacy settings for this app.
  3. If, same as the above but we are allowed to ask again, then let's ask again! We'll use the result of that to proceed with the update.
  4. If we're not in our development environment, then let's update Firebase's state as well.

In terms of the "update" - we're updating the user's Firebase profile. This means these settings will only apply if the user is logged in (more or less). If you wanted this to work without authentication, you could rebuild it with something like expo-secure-store like I've done previously.

Anyway, here goes:

import * as Amplitude from 'expo-analytics-amplitude';
import * as FirebaseAnalytics from 'expo-firebase-analytics';
import { openSettings } from 'expo-linking';
import { useTrackingPermissions } from 'expo-tracking-transparency';
import { useCallback } from 'react';
import { Alert } from 'react-native';
import useAsync from 'react-use/lib/useAsync';
 
// ...
 
const { user } = useUser();
const { profile, updateProfile } = useProfile(user);
const [isTrackingAvailable, requestTrackingPermission] =
  useTrackingPermissions();
const enabled = profile?.analyticsEnabled ?? true;
 
const updateAnalyticsEnabled = useCallback(
  async (newEnabled: boolean) => {
    console.log(
      newEnabled ? 'Enabling analytics...' : 'Disabling analytics...'
    );
 
    if (!newEnabled || isTrackingAvailable?.granted) {
      await updateProfile({ analyticsEnabled: newEnabled });
      if (environment.current === 'development') {
        await FirebaseAnalytics.setAnalyticsCollectionEnabled(newEnabled);
      }
    }
 
    if (!isTrackingAvailable?.canAskAgain) {
      Alert.alert(
        'Error',
        'Sorry, we need your permission to enable analytics. Please enable it in your privacy settings.',
        [
          {
            text: 'OK',
            onPress: async () => {
              await updateAnalyticsEnabled(false);
            },
          },
          {
            text: 'Open Settings',
            onPress: async () => openSettings(),
          },
        ]
      );
    }
 
    const { granted } = await requestTrackingPermission();
 
    await updateProfile({ analyticsEnabled: granted });
 
    if (environment.current === 'development') {
      return;
    }
 
    await FirebaseAnalytics.setAnalyticsCollectionEnabled(granted);
  },
  [
    isTrackingAvailable?.canAskAgain,
    isTrackingAvailable?.granted,
    updateProfile,
  ]
);

All together now

Okay, we've probably read enough about the theory behind this bad boi. Keen to see the full hook? Here we go:

import * as Amplitude from 'expo-analytics-amplitude';
import * as FirebaseAnalytics from 'expo-firebase-analytics';
import { openSettings } from 'expo-linking';
import { useTrackingPermissions } from 'expo-tracking-transparency';
import { useCallback } from 'react';
import { Alert } from 'react-native';
import useAsync from 'react-use/lib/useAsync';
import { amplitude, environment } from '../constants/variables';
import useProfile from './useProfile';
import useUser from './useUser';
 
const useAnalytics = (): {
  enabled: boolean;
  track: (eventName: string, props?: Record<string, unknown>) => void;
  page: (route: string) => void;
  setEnabled: (enabled: boolean) => Promise<void>;
} => {
  const { user } = useUser();
  const { profile, updateProfile } = useProfile(user);
  const [isTrackingAvailable, requestTrackingPermission] =
    useTrackingPermissions();
  const enabled = profile?.analyticsEnabled ?? true;
 
  const updateAnalyticsEnabled = useCallback(
    async (newEnabled: boolean) => {
      console.log(
        newEnabled ? 'Enabling analytics...' : 'Disabling analytics...'
      );
 
      if (!newEnabled || isTrackingAvailable?.granted) {
        await updateProfile({ analyticsEnabled: newEnabled });
        if (environment.current === 'development') {
          await FirebaseAnalytics.setAnalyticsCollectionEnabled(newEnabled);
        }
      }
 
      if (!isTrackingAvailable?.canAskAgain) {
        Alert.alert(
          'Error',
          'Sorry, we need your permission to enable analytics. Please enable it in your privacy settings.',
          [
            {
              text: 'OK',
              onPress: async () => {
                await updateAnalyticsEnabled(false);
              },
            },
            {
              text: 'Open Settings',
              onPress: async () => openSettings(),
            },
          ]
        );
      }
 
      const { granted } = await requestTrackingPermission();
 
      await updateProfile({ analyticsEnabled: granted });
 
      if (environment.current === 'development') {
        return;
      }
 
      await FirebaseAnalytics.setAnalyticsCollectionEnabled(granted);
    },
    [
      isTrackingAvailable?.canAskAgain,
      isTrackingAvailable?.granted,
      updateProfile,
    ]
  );
 
  useAsync(async () => {
    console.log('Initializing analytics...');
    await Amplitude.initializeAsync(amplitude.apiKey);
    if (environment.current === 'development') {
      await FirebaseAnalytics.setDebugModeEnabled(true);
    }
  }, []);
 
  useAsync(async () => {
    if (!user?.uid) {
      return;
    }
 
    if (!enabled) {
      console.log(`Failed to identify user "${user.uid}" (permissions).`);
      return;
    }
 
    console.log(`Identifying user "${user.uid}"...`);
 
    if (environment.current === 'development') {
      return;
    }
 
    try {
      await FirebaseAnalytics.setUserId(user.uid);
      await Amplitude.setUserIdAsync(user.uid);
    } catch (error) {
      catchError(error);
    }
  }, [enabled, user?.uid]);
 
  const track = (event: string, props?: Record<string, unknown>): void => {
    if (!user?.uid) {
      return;
    }
 
    if (!enabled) {
      console.log(
        `Failed to track event for user "${user.uid}" (permissions).`
      );
      return;
    }
 
    let message = `Tracking event "${event}" for user "${user.uid}"...`;
 
    if (props) {
      message += ` with props "${Object.keys(props).join(', ')}"...`;
    } else {
      message += '...';
    }
 
    console.log(message);
 
    if (environment.current === 'development') {
      return;
    }
 
    const firebaseEventName = event.replace(/ /gu, '_').toLowerCase();
 
    try {
      if (props) {
        FirebaseAnalytics.logEvent(firebaseEventName, props).catch(
          console.error
        );
        Amplitude.logEventWithPropertiesAsync(event, props).catch(
          console.error
        );
      } else {
        FirebaseAnalytics.logEvent(firebaseEventName).catch(console.error);
        Amplitude.logEventAsync(event).catch(console.error);
      }
    } catch (error) {
      catchError(error);
    }
  };
 
  const page = (route: string): void => {
    if (!user?.uid) {
      return;
    }
 
    if (!enabled) {
      console.log(
        `Failed to track route for user "${user.uid}" (permissions).`
      );
      return;
    }
 
    console.log(`Tracking route "${route}"...`);
 
    if (environment.current === 'development') {
      return;
    }
 
    try {
      FirebaseAnalytics.logEvent('screen_view', { screen_name: route }).catch(
        console.error
      );
      Amplitude.logEventWithPropertiesAsync('Viewed page', {
        screen_name: route,
      }).catch(console.error);
    } catch (error) {
      catchError(error);
    }
  };
 
  const setEnabled = async (newEnabled: boolean): Promise<void> => {
    await updateAnalyticsEnabled(newEnabled);
  };
 
  return {
    enabled,
    track,
    page,
    setEnabled,
  };
};
 
export default useAnalytics;

That's it! Hope this helps 🙏


Published on January 28, 2022 12 min read