|
| 1 | +# Title |
| 2 | + |
| 3 | +Date: 2025-09-22 |
| 4 | + |
| 5 | +Status: proposed |
| 6 | + |
| 7 | +## Context |
| 8 | + |
| 9 | +We want it to be easy to add observability to production React Router applications. This involves the ability to add logging, error reporting, and performance tracing to your application on both the server and the client. |
| 10 | + |
| 11 | +We always had a good story for user-facing error handling via `ErrorBoundary`, but until recently we only had a server-side error-reporting solution via the `entry.server` `handleError` export. In `7.8.2`, we shipped an `onError` client-side equivalent so it should now be possible to report on errors on the server and client pretty easily. |
| 12 | + |
| 13 | +We have not historically had great recommendations for the other 2 facets of observability - logging and performance tracing. Middleware, shipped in `7.3.0` and stabilized in `7.9.0` gave us a way to "wrap" request handlers at any level of the tree, which provides a good solution for logging and _some_ high-level performance tracing. But it's too coarse-grained and does not allow folks to drill down into their applications. |
| 14 | + |
| 15 | +This has also been raised in the (currently) 2nd-most upvoted Proposal in the past year: https://github.com/remix-run/react-router/discussions/13749. |
| 16 | + |
| 17 | +One way to add fine-grained logging/tracing today is to manually include it in all of your loaders and actions, but this is tedious and error-prone. |
| 18 | + |
| 19 | +Another way is to "instrument" the server build, which has long been our suggestion - initially to the folks at Sentry - and over time to RR users here and there in discord and github issues. but, we've never formally documented this as a recommended pattern, and it currently only works on the server and requires that you use a custom server. |
| 20 | + |
| 21 | +## Decision |
| 22 | + |
| 23 | +Adopt instrumentation as a first class API and the recommended way to implement observability in your application. |
| 24 | + |
| 25 | +There are 2 levels in which we want to instrument: |
| 26 | + |
| 27 | +- router level - ability to track the start and end of a router operation |
| 28 | + - requests on the server handler |
| 29 | + - initialization, navigations, and fetchers on the client router |
| 30 | +- route level |
| 31 | + - loaders, actions, middlewares |
| 32 | + |
| 33 | +On the server, if you are using a custom server, this is already possible by wrapping the react router handler and walking the `build.routes` tree and wrapping the route handlers. |
| 34 | + |
| 35 | +To provide the same functionality when using `@react-router/serve` we need to open up a new API. Currently, I am proposing 2 new exports from `entry.server`. These will be run on the server build in `createRequestHandler` and that way can work without a custom server. This will also allow custom-server users today to move some more code from their custom server into React Router by leveraging these new exports. |
| 36 | + |
| 37 | +```tsx |
| 38 | +// entry.server.tsx |
| 39 | + |
| 40 | +// Wrap incoming request handlers. Currently applies to _all_ requests handled |
| 41 | +// by the RR handler, including: |
| 42 | +// - manifest reqeusts |
| 43 | +// - document requests |
| 44 | +// - `.data` requests |
| 45 | +// - resource route requests |
| 46 | +export function instrumentHandler(handler: RequestHandler): RequestHandler { |
| 47 | + return (...args) => { |
| 48 | + let [request] = args; |
| 49 | + let path = new URL(request.url).pathname; |
| 50 | + let start = Date.now(); |
| 51 | + console.log(`Request start: ${request.method} ${path}`); |
| 52 | + |
| 53 | + try { |
| 54 | + return await handler(...args); |
| 55 | + } finally { |
| 56 | + let duration = Date.now() - start; |
| 57 | + console.log(`Request end: ${request.method} ${path} (${duration}ms)`); |
| 58 | + } |
| 59 | + }; |
| 60 | +} |
| 61 | + |
| 62 | +// Instrument an individual route, allowing you to wrap middleware/loader/action/etc. |
| 63 | +// This also gives you a place to do global "shouldRevalidate" which is a nice side |
| 64 | +// effect as folks have asked for that for a long time |
| 65 | +export function instrumentRoute(route: RouteModule): RequestHandler { |
| 66 | + let { loader } = route; |
| 67 | + let newRoute = { ...route }; |
| 68 | + if (loader) { |
| 69 | + newRoute.loader = (args) => { |
| 70 | + let { request } = args; |
| 71 | + let path = new URL(request.url).pathname; |
| 72 | + let start = Date.now(); |
| 73 | + console.log(`Loader start: ${request.method} ${path}`); |
| 74 | + |
| 75 | + try { |
| 76 | + return await loader(...args); |
| 77 | + } finally { |
| 78 | + let duration = Date.now() - start; |
| 79 | + console.log(`Loader end: ${request.method} ${path} (${duration}ms)`); |
| 80 | + } |
| 81 | + }; |
| 82 | + } |
| 83 | + return newRoute; |
| 84 | +} |
| 85 | +``` |
| 86 | + |
| 87 | +Open questions: |
| 88 | + |
| 89 | +- On the server we could technically do this at build time, but I don't expect this to have a large startup cost and doing it at build-time just feels a bit more magical and would differ from any examples we want to show in data mode. |
| 90 | +- Another option for custom server folks would be to make these parameters to `createRequestHandler`, but then we'd still need a way for `react-router-server` users to use them and thus we'd still need to support them in `entry.server`, so might as well make it consistent for both. |
| 91 | + |
| 92 | +Client-side, it's a similar story. You could do this today at the route level in Data mode before calling `createBrowserRouter`, and you could wrap `router.navigate`/`router.fetch` after that. but there's no way to instrument the router `initialize` method without "ejecting" to using the lower level `createRouter`. And there is no way to do this in framework mode. |
| 93 | + |
| 94 | +I think we can open up APIs similar to those in `entry.server` but do them on `createBrowserRouter` and `HydratedRouter`: |
| 95 | + |
| 96 | +```tsx |
| 97 | +function instrumentRouter(router: DataRouter): DataRouter { /* ... */ } |
| 98 | + |
| 99 | +function instrumentRoute(route: RouteObject): RouteObject { /* ... */ } |
| 100 | + |
| 101 | +// Data mode |
| 102 | +let router = createBrowserRouter(routes, { |
| 103 | + instrumentRouter, |
| 104 | + instrumentRoute, |
| 105 | +}) |
| 106 | + |
| 107 | +// Framework mode |
| 108 | +<HydratedRouter |
| 109 | + instrumentRouter={instrumentRouter} |
| 110 | + instrumentRoute={instrumentRoute} /> |
| 111 | +``` |
| 112 | + |
| 113 | +In both of these cases, we'll handle the instrumentation at the router creation level. And by passing `instrumentRoute` into the router, we can properly instrument future routes discovered via `route.lazy` or `patchRouteOnNavigation` |
| 114 | + |
| 115 | +## Alternatives Considered |
| 116 | + |
| 117 | +Originally we wanted to add an [Events API](https://github.com/remix-run/react-router/discussions/9565), but this proved to [have issues](https://github.com/remix-run/react-router/discussions/13749#discussioncomment-14135422) with the ability to "wrap" logic for easier OTEL instrumentation. These were not [insurmountable](https://github.com/remix-run/react-router/discussions/13749#discussioncomment-14421335), but the solutions didn't feel great. |
| 118 | + |
| 119 | +Client side, we also considered whether this could be done via `patchRoutes`, but that's currently intended mostly to add new routes and doesn't work for `route.lazy` routes. In some RSC-use cases it can update parts of an existing route, but it sonly allows updates for the server-rendered RSC "elements," and doesn't walk the entire child tree to update children routes so it's not an ideal solution for updating loaders in the entire tree. |
0 commit comments