Back to Blog Home

Performance Monitoring Support for React Native

Jenn Mueng image

Jenn Mueng -

Performance Monitoring Support for React Native

March Mobile Madness continues with Performance support for React Native. Our friend, Jenn Mueng shares how Performance supports his mobile appliction.

In addition to working with Sentry, I also contribute to Tour, a travel app which helps people plan trips with a drag-and-drop interface. Because Tour is built with React Native, we've always had issues accurately gauging how people use our app and its performance. We wrote our own analytics trackers to try and get ahead of these issues, but it was still difficult to monitor the app’s performance.

But now that Performance from Sentry is available on React Native, I'd like to introduce some creative recipes that helped me instrument Tour outside of the instrumentation already included with the Sentry SDK. But first things first. Let’s start with setting the SDK up for basic Application Performance Monitoring.

Install and set up our React Native SDK:

yarn add @sentry/react-native yarn sentry-wizard -i reactNative -p ios android cd ios pod install

Enable performance monitoring along with the routing instrumentation of your choice:

import * as Sentry from "@sentry/react-native"; const routingInstrumentation = new Sentry.ReactNavigationV5Instrumentation(); Sentry.init({ dsn: "DSN", tracesSampleRate: 1, integrations: [ new Sentry.ReactNativeTracing({ tracingOrigins: ["localhost", "my-site-url.com", /^\//], routingInstrumentation // ... other options }), ], });

And that’s it. You'll start to see transactions for every screen containing Spans for XHR & fetch requests. Before you continue reading, for additional tips and use cases for your app, the following section assumes that you have some knowledge about transactions and spans in Sentry. You can learn more about traces, transactions, and spans in our docs.

Recipes for Performance

Because Tour uses TypeScript, these code snippets will be in TypeScript —although you can easily adapt them back into plain JavaScript.

Note: These are intermediate recipes that assume prior knowledge of our React Native SDK. If you just want to learn how to get started, see our docs.

Promises

Sentry’s Performance instrumentation records duration-based events, which are then visualized in the Sentry UI to help you identify slow operations. Because they're typically attached to network calls or other asynchronous tasks, Promises are a great place to surface poorly performing code – if properly instrumented.

While Promises need to be instrumented manually, simple Promises can be easily wrapped with a helper function instead of writing the instrumentation manually. I created these wrappers so I can instrument any Promise with either a transaction or a span:

const withTransaction = <P>( promise: Promise<P>, context: TransactionContext ): Promise<P> => { const transaction = Sentry.startTransaction(context); return promise .catch((e) => { transaction.setStatus(SpanStatus.UnknownError); throw e; }) .finally(() => { transaction.finish(); }); };
const withSpan = <P>( promise: Promise<P>, context: SpanContext, /** Leave empty to use scope transaction */ _transaction?: Transaction | null ): Promise<P> => { const transaction = _transaction ?? Sentry.getCurrentHub()?.getScope()?.getTransaction(); const span = transaction?.startChild(context); return promise .catch((e) => { span?.setStatus(SpanStatus.UnknownError); throw e; }) .finally(() => { span?.finish(); }); };

Note that the third parameter of withSpan is optional. If you pass a transaction object, then the span will be attached to that transaction. If the parameter is omitted, then that span will be attached to the active transaction on the current Sentry scope. This allows withSpan to easily integrate with the included routing instrumentation that automatically attaches route change transactions to the scope.

This is how we use withSpan inside Tour: by instrumenting each call to the client individually, our venue client can either make an API call or load from the local device cache.

fetchVenues(venueIds: string[]): Promise<Venue> { return Promise.all( venueIds.map( withSpan(venueClient.getVenue(venueId), { description: `venueId: ${venueId}`, op: "get.foursquare", }) ) ); }

Below is how they’re rendered in Sentry:

Transaction Hub

As I instrumented my code, I discovered that passing transaction objects around quickly became tedious. That's because Tour's code is designed in a functional way: pure functions take some parameters, read the global Redux state, perform some computation, and then finally output something. This made it extremely difficult to instrument at first, which is what led me on the path to creating what I’ve dubbed the transaction hub.

The transaction hub is a simple exported object declared in a file where all of the active transactions live. Each type of transaction has a unique op (operation), such as "trip.initialize". And by using this op field, we were able to add only a few  lines of code to each function in order to start or stop a transaction anywhere in the app.

For example, for the op "trip.initialize" we would start a transaction when the initialization of the trip screen begins. And then, once the screen is mounted, we could easily finish it on the transaction hub.

const transactionHub = { transactions: [], startTransaction(transactionContext: TransactionContext): Transaction { const transaction = Sentry.startTransaction(transactionContext); this.transactions.push(transaction); return transaction; }, finishTransaction(op: string): Transaction[] { // Find all the transactions with this op. const selectedTransactions = this.transactions.filter(t => t.op == op); // Finish each of the transactions with this op. selectedTransactions.forEach(t => t.finish()); // Remove these finished transactions from the transaction hub this.transactions = this.transactions.filter(t => t.op != op); return selectedTransactions; }, // ... };

So when you want to start a transaction on the hub, you would call:

transactionHub.startTransaction({ op: 'trip.initialize', name: 'Initialize New Trip', tags: { tripId }, trimEnd: true });

And when you want to finish the transaction, you can call: finishTransaction like below from anywhere in the app.

transactionHub.finishTransaction('trip.initialize')

Instrumenting our real-time data flow

We took the transaction hub a little further.

In Tour, we have a data flow where any changes that you make on the UI are first computed on the app itself before being updated on the Firestore document update. Only then does the change come back down to be stored in our persist store, before being shown on the UI. The UI only accesses data from the persist store and never listens to Firestore directly. This is done to ensure that this flow is done in real time, meaning the result is the same whether you are online or offline.

This too seemed like a difficult task to instrument, but with a little playing around we found a way forward by extending the transaction hub. Here, each change gets assigned a timestamp as well as a list of all the documents it changes. Then we have the hub listen for the event that triggers the document update until it finally finishes the transaction.

// Simplified and shortened example const transactionHub = { transactions: [], // A map of spans by their write ids spansByWriteId: {}, startWriteTransaction( writes: { id: string; name: string; }[], name?: string ): Transaction { const transactionContext = { op: "db.write", name: name ?? "Database Write", }; // Start the parent transaction const transaction = Sentry.startTransaction(transactionContext); // Create spans for each write operation writes.forEach((write) => { // We store each span to the map via ID so we can find them again this.spansByWriteId[write.id] = transaction.startChild({ op: "db.doc.write", data: { id: write.id, }, description: write.name, }); }); this.transactions.push(transaction); return transaction; }, onDbUpdate(id: string): void { const span = this.spansByWriteId[id]; if (span) { // Finish the write span span.finish(); if ( // Check that each child span has been finished span.transaction && span.transaction.spanRecorder.spans .filter((s) => s !== span.transaction) .every((s) => s.endTimestamp) ) { // If every child is finished, we finish the transaction span.transaction.finish(); } } }, // ... };

By calling startWriteTransaction when we write to Firestore and calling onDbUpdate when the write comes in, this allows us to engage what I believe to be the most important transaction in the whole app. Although it looks simple, it allows us to essentially gauge User Misery every time a user makes a change to their trip plans!

// Changes will be made to the trip and user document transactionHub.startWriteTransaction([ { id: `${trip.id}-${trip.metadata.timestamp}`, name: "Trip", }, { id: `${uid}-${userUpdateTimestamp}`, name: "User", }, ]);

And when the changes get updated on the database listener, you would call onDbUpdate like so:

transactionHub.onDbUpdate(`${trip.id}-${trip.timestamp}`); transactionHub.onDbUpdate(`${uid}-${userData.updateTimestamp}`);

I am still experimenting with Performance and am sure there will be a lot more recipes and techniques for instrumenting apps. I hope you'll be able to come up with some cool ones as well!

To start using Sentry with React Native, configure the SDK. And if you’re new to Sentry, you can try it for free today.

Share

Share on Twitter
Share on Bluesky
Share on HackerNews
Share on LinkedIn

Published

Sentry Sign Up CTA

Code breaks, fix it faster

Sign up for Sentry and monitor your application in minutes.

Try Sentry Free

Topics

Dashboards

New product releases and exclusive demos

Listen to the Syntax Podcast

Of course we sponsor a developer podcast. Check it out on your favorite listening platform.

Listen To Syntax
© 2024 • Sentry is a registered Trademark of Functional Software, Inc.