Adding Observability to a React Router v7 app

Written by on .

react-router
opentelemetry

  1. Instrumenting React Router
    1. Conclusion

      Glama has exploded in popularity over the last few months, and with increased popularity came performance issues.

      Luckily, Sentry has a great OpenTelemetry integration that makes tracing a breeze.

      Unfortunately, Sentry's OpenTelemetry integration does not cover React Router loaders.

      In this post, I will show you how to instrument your React Router application with OpenTelemetry to measure the load times of your loaders.

      This post assumes that you are using React Router v7 with Fastify. Refer to my previous post for instructions on how to set up a React Router v7 with Fastify.

      Instrumenting React Router

      Somewhere in your production code, you should have a factory that creates a React Router request handler. Something like this:

      const handler = createReactRouterRequestHandler({ build: serverBuild, getLoadContext, mode: 'production', });

      In order to instrument the loaders, we will iterate over all routes and wrap each loader and action in a span. We can do this by wrapping the serverBuild object using the instrumentServerBuild function below.

      const instrumentServerBuild = (build: ServerBuild) => { const routes = build.routes as Record<string, Route>; return { ...build, routes: Object.fromEntries( Object.entries(routes).map(([routeId, route]) => { return [ routeId, { ...route, module: wrapRouteModule(routeId, route.module), }, ]; }), ), }; }; const wrapRouteModule = (routeId: string, routeModule: Route['module']) => { return { ...routeModule, action: routeModule.action ? instrumentRouteFn(routeId, routeModule.action, 'action') : undefined, loader: routeModule.loader ? instrumentRouteFn(routeId, routeModule.loader, 'loader') : undefined, }; }; const instrumentRouteFn = ( routeId: string, dataFunction: ActionFunction | LoaderFunction, type: 'action' | 'loader', ) => { return async (args: ActionFunctionArgs | LoaderFunctionArgs) => { return Sentry.startSpan( { name: `${routeId}#${type}`, }, async () => { return await dataFunction({ ...args, }); }, ); }; };

      Did you ever want to get route ID in your loader or action? Notice that the routeId is available to us when wrapping the loader and action in a span. You can extend this code to inject the route ID into the args object.

      This function was shared by @rossipedia on Discord.

      What this function does is:

      1. Iterate over all routes in the server build.
      2. Wrap the loader and action using Sentry.startSpan.
      3. Return the new server build.

      I use Sentry, but you could easily switch Sentry.startSpan here with startActiveSpan from OpenTelemetry if you prefer (that would also work with Sentry's OpenTelemetry integration).

      Additionally, I personally like to use a custom span function that allows me to print execution times in the console, e.g.,

      const startSpan = <T>( options: StartSpanOptions, callback: () => Promise<T>, ): Promise<T> => { return Sentry.startSpan(options, async () => { const startTime = performance.now(); try { return await callback(); } finally { const duration = performance.now() - startTime; console.debug(`${options.name} took ${formatDuration(duration)}`); } }); };

      This way I can simply observe the stream of server logs to identify slow loaders.

      That's it!

      Conclusion

      By instrumenting your React Router v7 loaders with OpenTelemetry, you gain valuable insights into your app's performance and can quickly identify bottlenecks. After adding this instrumentation, it only took me five minutes to identify the performance issue I had been tracking for weeks.

      In Ross's words: Good observability is priceless

      Written by Frank Fiegel (@punkpeye)