Distrito Telefónica. Innovation & Talent Hub

Sentry tuning for frontend performance monitoring 

In the world of web development, delivering exceptional and responsive user experiences is the key to success. As modern web applications become increasingly sophisticated, monitoring their performance and identifying issues that impact users have become crucial aspects of the development process. There are numerous tools over the internet that can help you improve your user experience, but in this article we are gonna dive into Sentry, applied to our particular use case. 

In the Checkout Team we own a set of payment and payment methods management flows (save credit cards, bank accounts, …) designed to be easily integrated by any other application. For this matter, and besides API integration, we have a light integration based on a web flow that can be accessed through a link that only requires a query param with a JWT containing the signed payable information. Any third party app within Telefonica ecosystem that requires the user to perform a payment can just delegate it to our flow and enjoy the benefits of a fully functional plug-and-play payment user experience. Our product can be loaded directly as a standalone web page or be injected in the original website within an iframe, which allows the embedding site to keep in full control of the experience. 

Our app is built on top of NextJS and, for a long time, we have been using Sentry as a tool to detect and monitor errors in our production code. But what we didn’t have until now was a way to track how our app was working for the end user regarding performance. We wanted a way to know what pieces of our app where potentially making interactions slower for the end user and also have frontend logs that could be matched to our Kibana backend logs. This is where Sentry performance monitoring came in handy. 

Enabling Sentry performance monitoring 

Enabling Sentry performance monitoring in NextJS is as simple as setting the desired sample rate of traces in your sentry.client.config.ts and sentry.server.config.ts files: 

import * as Sentry from "@sentry/nextjs"; 
 
Sentry.init({ 
  tracesSampleRate: 1.0, 
}); 

In our case, we have a lot of user traffic expected, so we want to lower the sample rate: 

// sentry.client.config.ts 
import * as Sentry from "@sentry/nextjs"; 
import getConfig from 'next/config'; 
 
Sentry.init({ 
  tracesSampleRate: 0.1, 
}); 

For our initial setup this is all the code needed. Easy, right?
 
Now you should have enabled the performance tab of your Sentry deployment. Once you receive some data, you should expect to see a panel similar to this one: 
Main screen of performance tab in Sentry

Main screen of performance tab in Sentry

Later in the article we will explain the main information that can be extracted from these panels, but first we will have to address some tweaks we had to apply to the initial setup so the data sent was grouped and aggregated correctly on them. 

Making query string parameters visible

Transactions are the main objects that group all the information and traces captured for an user interaction. They are listed in the main screen of the performance Sentry tab: 
Transactions list

Transactions list

Our main flow is a SPA that takes the user through a payment journey using a variable set of steps. We wanted to allow the user to (natively) navigate back and forth through our flow, so we had to keep track of the current step somewhere. We also wanted client side routing and easily traceable transactions, which led us to use a query parameter for this matter. Following this example, our initial URL could be: https://oursite.com/payment?webview-step=init. When the user clicks the continue button, he would navigate to the next step:  https://oursite.com/payment?webview-step=payment-form, and so on. We wanted this query parameter to be available in Sentry’s transaction name, so we could have traces grouped by the step the user was loading or navigating to. 

On top of that, we also received a JWT query parameter that contained encrypted and signed information about the payment. This JWT is considered sensitive information and it shouldn’t be logged anywhere. Taking that in account, an example initial URL could be:  https://oursite.com/payment?step=init&jwt=ey.iashdfiusaduihUysdbasHFY… On top of being sensitive information, this query parameter would be different for each interaction, so it should be replaced with a placeholder before being sent, as we wanted all the transactions related to the same step to get grouped in Sentry. 

By default Sentry strips query parameters from transaction names (which are effectively the URLs loaded or navigated to), so with our initial setup all the pages would be tracked under the same transaction name ( https://oursite.com/payment), and we wouldn’t be able to see event details grouped by the step that the user is loading or navigating to. 
In order to fix transaction naming and have our transactions grouped by the step the user is currently at, we had to implement a custom BrowserTracing integration

/ sentry.client.config.ts 
import * as Sentry from "@sentry/nextjs"; 
import {beforeNavigate} from './utils/sentry' 
 
Sentry.init({ 
    // ... 
    integrations: [ 
        new Sentry.BrowserTracing({ 
            beforeNavigate, 
        }), 
    ], 
});// utils/sentry.ts 
// Sentry strips query parameters from the url on navigation events. We get them back by listening to next router. https://github.com/getsentry/sentry-javascript/pull/8278 
let currentNavigationPath: string = 
    typeof window !== 'undefined' ? window.location.pathname + window.location.search : ''; 
let nextNavigationPath: string = 
    typeof window !== 'undefined' ? window.location.pathname + window.location.search : ''; 
 
Router.events.on('routeChangeStart', (navigationTarget: string) => { 
    nextNavigationPath = navigationTarget; 
}); 
 
export const beforeNavigate = (context: TransactionContext): TransactionContext => { 
    context.name = removeJwtFromQueryParams(nextNavigationPath); 
    if (typeof context.tags?.from === 'string') { 
        context.tags.from = removeJwtFromQueryParams(currentNavigationPath); 
    } 
    currentNavigationPath = nextNavigationPath; // Once we have completed the navigation, we can update the current path 
    return context; 
};


As you can see, we are relying on next router events to know where is the user navigating. Then we use that information to modify the transaction that will be sent to sentry, adding back the query parameters that are automatically stripped by Sentry. The removeJwtFromQueryParams function will be explained below. 

Dealing with variable URL parameters

On the other hand, we also had variable parameters in our URL-related events that we wanted to group. For example API request GET /api/pets/1 should be tracked under the same group as GET /api/pets/2. To accomplish this, we should replace the variable pet id in the path with a placeholder: GET /api/pets/<pet-id>. The same applies to the jwt query parameter in our flow’s entrypoint. Each jwt token is different, but we wanted to group all the navigations to our entry points under the same transaction names that include a placeholder for the JWT token. On top of that, as mentioned before, JWT tokens contain sensitive information and they shouldn’t be logged anywhere, hence this parameter-removing mechanism can serve multiple purposes such as anonymising, removing personal data and grouping transactions, spans, and breadcrumbs. 

Sentry offers 2 functions to modify this metadata: beforeBreadcrumb and beforeSendTransaction
Breadcrumbs include navigation events, XHR requests, user clicks, … They are listed under particular transactions and can help trace what happened in a particular user interaction. 
Breadcrumbs

Breadcrumbs

On the other hand, regarding beforeSendTransaction, transactions are the main objects that contain traces. In our case we have 2 types: page loads and navigations. 
We can leverage on the two functions described above to format our events so they get automatically grouped by Sentry. 

// sentry.client.config.ts 
import * as Sentry from "@sentry/nextjs"; 
import {beforeBreadcrumb, beforeSendTransaction} from './utils/sentry' 
 
Sentry.init({ 
    // ... 
    beforeBreadcrumb, 
    beforeSendTransaction, 
}); 


We want to normalise the jwt query parameter from the navigation breadcrumbs. On the other hand, for the fetch breadcrumbs, we have variable path parameters (in our API http calls) that should be removed too: 

 

export const beforeBreadcrumb = (breadcrumb: Sentry.Breadcrumb): Sentry.Breadcrumb => { 
    if (breadcrumb.category === 'navigation') { 
        breadcrumb.data.from = removeJwtFromQueryParams(breadcrumb.data.from); 
        breadcrumb.data.to = removeJwtFromQueryParams(breadcrumb.data.to); 
        return breadcrumb; 
    } 
    if (breadcrumb.category === 'fetch') { 
        breadcrumb.data.url = removeJwtFromApiPath(breadcrumb.data.url); 
        return breadcrumb; 
    } 
    return breadcrumb; 
}; 

For the transactions, we want to remove the jwt query parameter from the request object. We also want to iterate through the spans and normalise those that represent http calls to our API: 

export const beforeSendTransaction = (transaction: TransactionEvent): TransactionEvent => { 
    if (transaction?.request?.url) { 
        transaction.request.url = removeJwtFromQueryParams(transaction.request.url); 
    } 
    if (transaction.request?.headers?.Referer) { 
        transaction.request.headers.Referer = removeJwtFromQueryParams(transaction.request.headers.Referer); 
    } 
    transaction.spans = transaction.spans?.map(parameterizeHttpRequests); 
    return transaction; 
}; 
const parameterizeHttpRequests = (span: Span): Span => { 
    if (span.op === 'http.client') { 
        if (span.data.url) { 
            span.data.url = removeJwtFromApiPath(span.data.url); 
        } 
        if (span.description) { 
            span.description = removeJwtFromApiPath(span.description); 
        } 
    } 
    return span; 
}; 


The functions to normalise the URLs can be adapted to your own API and client URLs. For example, this one replaces a JWT query parameter with a hyphen symbol: 

const removeJwtFromQueryParams = (urlPath: string) => { 
    if (urlPath.includes('?')) { 
        const [path, queryString] = urlPath.split('?'); 
        const searchParams = new URLSearchParams(queryString); 
        searchParams.set('jwt', '-'); 
        return `${path}?${searchParams.toString()}`; 
    } 
    return urlPath; 
}; 


Feel free to implement your own replacement functions adapted to your use case. 

Final result

After all the changes were applied, this is the final result. Here we can see the main performance tab, where we have some overall data and a list of our transaction names. We have info about the TPM, load times (percentiles) and failure rates. As you can see, the jwt query parameter has been replaced with a placeholder. 
Final result of the main performance tab

Final result of the main performance tab

If we click on a transaction we will get detailed information about it: 
Transaction details

Transaction details

You can see the spans are aggregated and grouped by transaction. The first span is a GET XHR request that had its jwt token replaced with a placeholder. A list of the particular transactions (slower ones) is shown right below the percentiles. If we click on one of those events we can get even more information about that particular interaction: 
Event details

Event details

This is a page load event. We can see a detailed waterfall graph of the events that happened in the browser while loading the page, including http connection, bundles download, browser paints, XHR requests, etc. We can also see a list of the breadcrumbs available for the interaction, where the jwt parameter has also been substituted by a placeholder. 

Now, with this tool in our hand, we can start looking for bottlenecks and possible performance issues that make our web application go slower. But this is just a sneak peak into the performance analysis possibilities Sentry can offer. For more information refer to Sentry’s doc

Cheers from the Checkout Team!