Expo Starter: Logging & Monitoring
📱

Expo Starter: Logging & Monitoring

Setup Error Monitoring

Sign up for Sentry and Create Project

Sign up for Sentry and create a project in your Dashboard. Note your Organization Slug, Project Name, and DSN for later.
  1. The Organization Slug is available in your Organization Settings tab.
  1. The Project Name is available in your project's Settings > General Settings tab.
  1. The DSN is avalable in your project's Settings > Client Keys tab.
Next, go to the Sentry API section, and create an auth token. The token requires the scopes: org:readproject:releases, and project:write. Note this auth token as well because we will use it soon.

Install Dependencies

% expo install sentry-expo expo-application expo-constants expo-device expo-updates

Setup Environment variables

Follow the sample .env to configure your development and production envs with the values you noted in the previous step.
// envs/.env.sample SENTRY_ORGANIZATION=FOO SENTRY_PROJECT=FOO SENTRY_DSN=https://foo.ingest.sentry.io/foo SENTRY_AUTH_TOKEN=FOO

Configure App

The Sentry library has two different versions; Sentry.Native and Sentry.Browser. They have a lot of overlapping methods so, to make things easier, let's create a service, detect the current platform, and export the respective version. We will still export the individual versions as well so that, if needed, we can use the platform specific functionality.
// src/services/sentry.ts import { Platform } from 'react-native'; import * as Sentry from 'sentry-expo'; export const initSentry = (enableInDev: boolean = false) => { Sentry.init({ dsn: process.env.SENTRY_DSN, enableInExpoDevelopment: enableInDev, debug: __DEV__, }); } let SentryClient = Sentry.Browser; if(Platform.OS !== 'web') { /* @ts-ignore */ SentryClient = Sentry.Native; } export const SentryNative = Sentry.Native; export const SentryBrowser = Sentry.Browser; export default SentryClient;
Now, in our App.tsx we have to import and call the initialization function.
// src/App.tsx ... import { initSentry } from '@/services/sentry'; ... initSentry(); ...

Configure Source Map Upload

In order to use our environmental variables in the app configuration, we have to convert the app.json to app.config.js. Yeah, kinda annoying to rewrite, but it's worth it to get source maps in your Sentry error logs!
// app.config.js export default { ... hooks: { postPublish: [ { file: "sentry-expo/upload-sourcemaps", config: { organization: process.env.SENTRY_ORGANIZATION, project: process.env.SENTRY_PROJECT, authToken: process.env.SENTRY_AUTH_TOKEN, setCommits: true, deployEnv: process.env.NODE_ENV, } } ] }, }

Test

Let's test if Sentry is working by simulating an error.
For expo web, make sure you disable your adblocker (if you have one), because it will most likely block the fetch to sentry.io.
// src/App.tsx ... import Sentry, { initSentry } from '@/services/sentry'; ... initSentry(true); export default function App() { return ( <View style={styles.container}> ... <Button title='Press to cause error!' onPress={() => Sentry.captureException(new Error('Oops!'))}/> ... </View>); }
Press the button in the emulator(s) and in the web and then head over to your Sentry dashboard and you should see the errors. (This is the only time you should be excited to see them!)
notion image
The mobile error event should include source maps, allowing you to see the exact error line. The web error event won't show this because Sentry doesn't allow retrieving the source map from localhost but it will work in production -- when it's not hosted locally.

Setup Logging

The most popular Node.js loggers aren't easy to setup in Expo (or React-Native) so we will be using react-native-logs.
% expo install react-native-logs expo-file-system
Let's configure a logger service that logs all levels to the console in development and logs errors to filesystem logs and Sentry in production.
// src/services/logger.ts /* https://github.com/onubo/react-native-logs#configuration */ import { Platform } from 'react-native'; import { logger, consoleTransport, fileAsyncTransport, sentryTransport } from "react-native-logs"; import * as FileSystem from 'expo-file-system'; import Sentry from '@/services/sentry'; let today = new Date(); let date = today.getDate(); let month = today.getMonth() + 1; let year = today.getFullYear(); const developmentConfig = { severity: "debug", transport: consoleTransport, transportOptions: { color: "ansi", }, }; const productionConfig = { severity: "error", transport: (props: any) => { Platform.OS !== 'web' && fileAsyncTransport(props); sentryTransport(props); }, transportOptions: { FS: FileSystem, fileName: `logs_${date}-${month}-${year}.txt`, SENTRY: Sentry, color: "ansi", }, }; let config = developmentConfig; if (!__DEV__) { config = productionConfig; } const LOGGER = logger.createLogger(config); export default LOGGER;

Test

// App.tsx import LOGGER from '@/services/logger'; LOGGER.enable('APP'); const log = LOGGER.extend('APP'); ... log.info("app log test"); ... export default function App() { return ( <View style={styles.container}> ... <Button title='Press to cause error!' onPress={() => { log.error('Oh no!!!'); Sentry.captureException(new Error('Oops!')) }} /> ... </View>); }
In the terminal console and the chrome console you should see 9:27:37 PM | APP | INFO | app log test (most likely with a different time though!)
If you want to test the write to log file (not implemented on web) and upload to Sentry, set the config to production and press the error button. The file should be saved to the device (you can go digging if you want) and you should see the events in your Sentry dashboard.

Setup Analytics

There were several contenders for analytics. Segment and Amplitude look awesome but get pricey real fast. Google Analytics seemed like the obvious choice but I wanted to avoid including a bunch of Google tracking stuff if possible. I decided on AWS Pinpoint because I mostly work with AWS, plan on implementing most backend functionality on it, and the free tier is literally insane; 100 million events per month for free and then still super cheap after! My only complaint is that I tried the whole Amplify thing - with it handling configuring backend resources and whatnot - and it is way too much magic. Therefore, this time, we will setup the backend ourselves using AWS CDK.

Setup Infrastructure

Shoutout to this blog post for doing most of the work.

Setup AWS CLI

Follow the guide here to setup the AWS and the CDK CLIs.

Initialize CDK App

% mkdir backend && cd $_ % cdk init app --language typescript

Install Dependencies

We are going to be using the following packages.
% npm i @aws-cdk/{aws-cognito,aws-iam,aws-location,aws-pinpoint} dotenv

Code

This code sets up the infrastructure and permissions for Pinpoint events and Location reverse geocoding.
// backend/lib/backend-stack.ts require('dotenv').config({ path: `../envs/.env.${process.env.NODE_ENV}` }); import * as cdk from '@aws-cdk/core'; import * as cognito from "@aws-cdk/aws-cognito"; import { createCognitoIamRoles } from './cognito-auth-roles'; import * as pinpoint from '@aws-cdk/aws-pinpoint'; import * as location from '@aws-cdk/aws-location'; const { CfnOutput } = cdk export class BackendStack extends cdk.Stack { constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const userPool = new cognito.UserPool(this, "user-pool", {}); const userPoolClient = new cognito.UserPoolClient(this, "UserPoolClient", { userPool, generateSecret: false, }); const identityPool = new cognito.CfnIdentityPool(this, "IdentityPool", { allowUnauthenticatedIdentities: true, cognitoIdentityProviders: [ { clientId: userPoolClient.userPoolClientId, providerName: userPool.userPoolProviderName, }, ], }); const pinpointApp = new pinpoint.CfnApp(this, "PinpointApp", { name: `pinpoint-${process.env.APP_NAME}-${process.env.NODE_ENV}`}); const placeIndex = new location.CfnPlaceIndex(this, "PlaceIndex", { indexName: `place-${process.env.APP_NAME}-${process.env.NODE_ENV}`, dataSource: 'Esri', pricingPlan: 'RequestBasedUsage' }) createCognitoIamRoles(this, identityPool.ref); // Export values new CfnOutput(this, "PINPOINT_APP_ID", { value: pinpointApp.ref }); new CfnOutput(this, "PLACE_INDEX_ID", { value: placeIndex.ref }); new CfnOutput(this, "USER_POOL_ID", { value: userPool.userPoolId, }); new CfnOutput(this, "USER_POOL_CLIENT_ID", { value: userPoolClient.userPoolClientId, }); new CfnOutput(this, "IDENTITY_POOL_ID", { value: identityPool.ref, }); } }
This code configures the Cognito and IAM roles for authenticated and unauthenticated access to our Pinpoint and Location resources.
// backend/lib/cognito-auth-roles.ts require('dotenv').config({ path: `../envs/.env.${process.env.NODE_ENV}` }); import * as cdk from "@aws-cdk/core"; import * as iam from "@aws-cdk/aws-iam"; import * as cognito from "@aws-cdk/aws-cognito"; const pinpointPutEventsPolicy = new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ["mobiletargeting:PutEvents", "mobiletargeting:UpdateEndpoint"], resources: ["arn:aws:mobiletargeting:*:*:apps/*"], }); const geoSearchPlaceIndexForTextPolicy = new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ["geo:SearchPlaceIndexForPosition"], resources: [`arn:aws:geo:*:*:place-index/place-${process.env.APP_NAME}-${process.env.NODE_ENV}`], }); const getRole = (identityPoolRef: string, authed: boolean) => ({ assumedBy: new iam.FederatedPrincipal( "cognito-identity.amazonaws.com", { StringEquals: { "cognito-identity.amazonaws.com:aud": identityPoolRef, }, "ForAnyValue:StringLike": { "cognito-identity.amazonaws.com:amr": authed ? "authenticated" : "unauthenticated", }, }, "sts:AssumeRoleWithWebIdentity" ), }); export const createCognitoIamRoles = ( scope: cdk.Construct, identityPoolRef: string ) => { const authedRole = new iam.Role(scope, "CognitoAuthenticatedRole", getRole(identityPoolRef, true)); const unAuthedRole = new iam.Role(scope, "CognitoUnAuthenticatedRole", getRole(identityPoolRef, false)); authedRole.addToPolicy(pinpointPutEventsPolicy); unAuthedRole.addToPolicy(pinpointPutEventsPolicy); authedRole.addToPolicy(geoSearchPlaceIndexForTextPolicy); unAuthedRole.addToPolicy(geoSearchPlaceIndexForTextPolicy); new cognito.CfnIdentityPoolRoleAttachment( scope, "IdentityPoolRoleAttachment", { identityPoolId: identityPoolRef, roles: { authenticated: authedRole.roleArn, unauthenticated: unAuthedRole.roleArn, }, } ); };

Deploy

Let's add our deploy commands to our main package.json.
// package.json { ... "scripts": { ... "deploy:dev": "cd backend && NODE_ENV=development cdk deploy", "deploy:prod": "cd backend && NODE_ENV=production cdk deploy", ... }, ... }
Now, let's deploy our development stack using npm run deploy:dev. You should see some variables outputed. Add them to your .env.development.
// envs/.env.development ... APP_NAME= AWS_REGION=us-east-1 AWS_ANALYTICS_IDENTITY_POOL_ID = AWS_ANALYTICS_PINPOINT_APP_ID = AWS_ANALYTICS_USER_POOL_CLIENT_ID = AWS_ANALYTICS_USER_POOL_ID = AWS_LOCATION_PLACE_INDEX_ID =

Setup Frontend

Our backend is deployed, so now we just have to add code to our frontend to use Pinpoint for analytics and Location for reverse geocoding.

Setup Location Service

% expo install expo-location % yarn add aws-sdk
This service handles asking for location permissions, retrieving the device location, and reverse geocoding the device location.
// services/location.ts import LOGGER from '@/services/logger'; LOGGER.enable('LOCATION'); const log = LOGGER.extend('LOCATION'); import { Auth } from 'aws-amplify'; import AwsLocation from "aws-sdk/clients/location"; import * as Location from 'expo-location'; import { Alert } from 'react-native'; let locationClient:AwsLocation; /* Request permissions and initialize AWS Location client */ export const initLocation = async () => { let { status } = await Location.requestForegroundPermissionsAsync(); if (status !== 'granted') { Alert.alert('Location Services Error', 'Permission to access location was denied', [{ text: "OK", onPress: () => console.log("OK Pressed") }], { cancelable: false }); return false; } locationClient = await createLocationClient(); log.info('Location Service initialized'); return true; } /* Create AWS Location client */ const createLocationClient = async () => { const credentials = await Auth.currentCredentials(); const client = new AwsLocation({ credentials, region: process.env.AWS_REGION, }); return client; } /* Convert device location @coords into AWS Location position */ const convertCoordsToPosition = (coords: any) => { return [coords.longitude, coords.latitude]; } /* Get current location */ export const getLocation = () => { return Location.getCurrentPositionAsync(); } /* Get reverse geocode for @coords */ export const getReverseGeocode = (coords: any, maxResults: number = 1): Promise<AwsLocation.SearchPlaceIndexForPositionResponse> => { return new Promise((resolve, reject) => { const params = { IndexName: process.env.AWS_LOCATION_PLACE_INDEX_ID as string, Position: convertCoordsToPosition(coords), MaxResults: maxResults }; log.debug('\n', params.Position); locationClient.searchPlaceIndexForPosition(params, (err, data) => { if (err) reject(err); else resolve(data); }); }) }
Let's define our storage keys in a constants file.
// src/utilities/constants.ts export const StorageKeys = { UID: 'uid', REVERSE_GEOCODE: 'reverse-geocode' };
Next we will create a simple cache by adding the ability to expire an item in async storage. We are going to use this to cache the results of the reverse geocoding call, that way we don't call the API every time the user kills and reopens the app.
// src/utilities/asyncCache.ts import AsyncStorage from "@react-native-async-storage/async-storage"; const EXP_KEY = '_asyncStorage_exp'; export const setItem = (key:string, item:any, ttl?: number) => { if (ttl) { return AsyncStorage.setItem(key, JSON.stringify({item, [EXP_KEY]: (Date.now() + ttl * 1000)})); } return AsyncStorage.setItem(key, item); } export const getItem = async (key: string) => { try { const data = JSON.parse(await AsyncStorage.getItem(key) || ''); if (EXP_KEY in data && Date.now() < data[EXP_KEY]) { return data['item']; } else { return null; } } catch { return await AsyncStorage.getItem(key); } } export default AsyncStorage;

Setup Analytics

Now that we have location setup, we need to gather a bunch of device and user information, format it for Pinpoint, and configure Amplify. Let's install the dependencies and then create a service that configures our endpoint.
% yarn add aws-amplify @react-native-community/netinfo @react-native-async-storage/async-storage % expo install expo-random expo-localization expo-device
I know that this service is kinda long but it's really nothing fancy -- basically just a bunch of information parsing and formatting.
// services/analytics.ts import LOGGER from '@/services/logger'; LOGGER.enable('ANALYTICS'); const log = LOGGER.extend('ANALYTICS'); import { Platform, Dimensions, PlatformIOSStatic, PlatformAndroidStatic, PlatformWebStatic } from 'react-native'; import * as Device from 'expo-device'; import * as Localization from 'expo-localization'; import Constants from 'expo-constants'; import * as Random from 'expo-random'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { Analytics, Auth } from 'aws-amplify'; import { StorageKeys } from '@/utilities/constants'; import { setItem, getItem } from '@/utilities/asyncCache'; import { getLocation, getReverseGeocode } from '@/services/location'; /* Generate or retrieve (if already exists) a UID for the device */ const getUID = async () => { // If there is a UID in storage, return that let uid = await AsyncStorage.getItem(StorageKeys.UID); log.debug('Retrieved UID: ', uid); if (uid) return uid; // Otherwise, generate, store, and return a UID const bytes = await Random.getRandomBytesAsync(48); /* @ts-ignore */ uid = Array.prototype.map .call(new Uint8Array(bytes), x => `00${x.toString(16)}`.slice(-2)) .join('') .match(/[a-fA-F0-9]{2}/g) .reverse() .join(''); await AsyncStorage.setItem(StorageKeys.UID, uid); log.debug('Generated and stored UID: ', uid); return uid; }; /* Get the respective push notification channel for the OS */ const getChannelType = (os: string) => { switch (os) { case 'ios': return 'APNS'; case 'android': return 'GCM'; default: return ''; } } /* Get the real device info */ const getDeviceInfo = (os: string) => { // The Device info is all fake on web if (os === 'web') { return { // appVersion: Nothing is available by default but we could probably pass in a version with env model: Constants.deviceName, platform: os, } } else { return { appVersion: Constants.nativeAppVersion, make: Device.manufacturer, model: Device.modelName, modelVersion: Device.modelId, platform: os, platformVersion: Device.osVersion, } } } /* Determine if the device is a tablet using width */ export const isTablet = () => { let pixelDensity = Dimensions.get('window').scale; const adjustedWidth = Dimensions.get('window').width * pixelDensity; const adjustedHeight = Dimensions.get('window').height * pixelDensity; if (pixelDensity < 2 && (adjustedWidth >= 1000 || adjustedHeight >= 1000)) { return true; } else { return ( pixelDensity === 2 && (adjustedWidth >= 1920 || adjustedHeight >= 1920) ); } }; /* Get device type */ const getDeviceType = () => { switch(Platform.OS) { case 'ios': const platformIOS = Platform as PlatformIOSStatic; if (platformIOS.isPad) return 'iPad'; if (platformIOS.isTV || platformIOS.isTVOS) return 'Apple TV'; return 'iPhone'; case 'android': const platformAndroid = Platform as PlatformAndroidStatic; if (platformAndroid.isTV) return 'Android TV'; if (isTablet()) return 'Android Tablet'; return 'Android Phone'; case 'web': if ('ontouchstart' in window || navigator.maxTouchPoints) { if(isTablet()) return "Tablet Browser"; return 'Mobile Browser'; } else { return 'Desktop Browser'; } default: return 'Unknown'; } } /* Update endpoint config with location info */ export const updateEndpointLocation = async () => { let place, coords; const cachedRGC = await getItem(StorageKeys.REVERSE_GEOCODE); if (!cachedRGC) { const location = await getLocation(); const reverseGeo = await getReverseGeocode(location.coords); place = reverseGeo.Results[0].Place; coords = location.coords; await setItem(StorageKeys.REVERSE_GEOCODE, { place, coords }, 60*60*24); } else { place = cachedRGC['place']; coords = cachedRGC['coords']; } const config = { city: place?.Municipality, country: place?.Country, latitude: coords?.latitude, longitude: coords?.longitude, postalCode: place?.PostalCode, region: place?.Region } await Analytics.updateEndpoint({ location: config }); log.info('Updated endpoint with location info'); log.debug('\n', JSON.stringify(config, null, 2)); } /* Get analytics config */ export const getConfig = async () => { const uid = await getUID(); const channelType = getChannelType(Platform.OS); const deviceInfo = getDeviceInfo(Platform.OS); const deviceType = getDeviceType(); return { Analytics: { AWSPinpoint: { appId: process.env.AWS_ANALYTICS_PINPOINT_APP_ID, region: process.env.AWS_REGION, endpointId: uid, endpoint: { // address: '', // TODO: Update with device token, email address, or mobile phone number when user auths attributes: { appType: [deviceInfo.platform === 'web' ? 'web' : 'native'], deviceType: [deviceType], screenResolution: [`${Dimensions.get('window').width.toFixed(0)}x${Dimensions.get('window').height.toFixed(0)}`], }, channelType: channelType || 'APNS', demographic: { ...deviceInfo, locale: Localization.locale, timezone: Localization.timezone }, userId: uid // For unauth, use device uid and update to userId after auth }, } } } }
Now we have to use this config when we initialize our Amplify app.
// api/amplify.ts import LOGGER from '@/services/logger'; LOGGER.enable('AMPLIFY'); const log = LOGGER.extend('AMPLIFY'); import Amplify from 'aws-amplify'; import { getConfig } from '@/services/analytics'; export const initAmplify = async () => { const analyticsConfig = await getConfig(); log.debug(JSON.stringify(analyticsConfig, null, 2)); Amplify.configure({ Auth: { region: process.env.AWS_REGION, identityPoolId: process.env.AWS_ANALYTICS_IDENTITY_POOL_ID, mandatorySignIn: false, }, ...analyticsConfig, }) log.info('Amplify initialized'); }
Next, let's update our App.tsx to actually initialize all this stuff.

Test

Same deal as the other steps, let's make sure we can get the device location, reverse geocode it, configure our analytics endpoint, and that Pinpoint is receiving our app events.
... import Amplify, { Analytics } from 'aws-amplify'; import React, { useEffect, useState } from 'react'; import { StyleSheet, Text, View, Button } from 'react-native'; import { initAmplify } from '@/api/amplify'; import { initLocation, getLocation, getReverseGeocode } from '@/services/location'; import { updateEndpointLocation } from '@/services/analytics'; import { LocationObject } from 'expo-location'; import { SearchPlaceIndexForPositionResponse } from "aws-sdk/clients/location"; ... export default function App() { const [location, setLocation] = useState<LocationObject>(); const [reverseGeocode, setReverseGeocode] = useState<SearchPlaceIndexForPositionResponse>(); useEffect(() => { (async () => { await initAmplify(); const locEnabled = await initLocation(); const loc = await getLocation(); setLocation(loc); // TODO: If cannot access device location, use IP to Location API instead if(locEnabled) { const reverseGeo = await getReverseGeocode(loc.coords); setReverseGeocode(reverseGeo); await updateEndpointLocation(); } })(); }, []); const rgc = reverseGeocode?.Results[0].Place; return ( <View style={styles.container}> ... <Text>- Location -</Text> <Text>Latitude: {location?.coords.latitude}</Text> <Text>Longitude: {location?.coords.longitude}</Text> <Text>- Reverse Geocode -</Text> <Text>{rgc?.Label}</Text> <Button title='Press to cause analytics event!' onPress={() => { Analytics.record({ name: 'buttonClick' }); }} /> ... </View>); } ...
Run your project, click the button in the emulator(s) and in the web app, and check the Pinpoint analytics dashboard. You should see some graphs showing activity and can click through the dashboard to see the breakdown by platform, country, etc.
notion image
Click to rocket boost to the top of the page!