1- /* eslint-disable @typescript-eslint/no-explicit-any */
2-
31import { getCurrentHub } from '@sentry/hub' ;
4- import { Primitive , TraceparentData , Transaction , TransactionContext } from '@sentry/types' ;
2+ import { Primitive , TraceparentData , Transaction , TransactionContext , TransactionSource } from '@sentry/types' ;
53import {
64 extractTraceparentData ,
7- fill ,
85 getGlobalObject ,
96 logger ,
107 parseBaggageHeader ,
@@ -14,7 +11,13 @@ import type { NEXT_DATA as NextData } from 'next/dist/next-server/lib/utils';
1411import { default as Router } from 'next/router' ;
1512import type { ParsedUrlQuery } from 'querystring' ;
1613
17- const global = getGlobalObject < Window > ( ) ;
14+ const global = getGlobalObject <
15+ Window & {
16+ __BUILD_MANIFEST ?: {
17+ sortedPages ?: string [ ] ;
18+ } ;
19+ }
20+ > ( ) ;
1821
1922type StartTransactionCb = ( context : TransactionContext ) => Transaction | undefined ;
2023
@@ -76,6 +79,8 @@ function extractNextDataTagInformation(): NextDataTagInfo {
7679 // `nextData.page` always contains the parameterized route - except for when an error occurs in a data fetching
7780 // function, then it is "/_error", but that isn't a problem since users know which route threw by looking at the
7881 // parent transaction
82+ // TODO: Actually this is a problem (even though it is not that big), because the DSC and the transaction payload will contain
83+ // a different transaction name. Maybe we can fix this. Idea: Also send transaction name via pageProps when available.
7984 nextDataTagInfo . route = page ;
8085 nextDataTagInfo . params = query ;
8186
@@ -96,20 +101,12 @@ const DEFAULT_TAGS = {
96101 'routing.instrumentation' : 'next-router' ,
97102} as const ;
98103
104+ // We keep track of the active transaction so we can finish it when we start a navigation transaction.
99105let activeTransaction : Transaction | undefined = undefined ;
100- let startTransaction : StartTransactionCb | undefined = undefined ;
101106
102- // We keep track of the previous page location so we can avoid creating transactions when navigating to the same page.
103- // This variable should always contain a pathname. (without query string or fragment)
104- // We are making a tradeoff by not starting transactions when just the query string changes. One could argue that we
105- // should in fact start transactions when the query changes, however, in some cases (for example when typing in a search
106- // box) the query might change multiple times a second, resulting in way too many transactions.
107- // Because we currently don't have a real way of preventing transactions to be created in this case (except for the
108- // shotgun approach `startTransactionOnLocationChange: false`), we won't start transactions when *just* the query changes.
109- let previousLocation : string | undefined = undefined ;
110-
111- // We keep track of the previous transaction name so we can set the `from` field on navigation transactions.
112- let prevTransactionName : string | undefined = undefined ;
107+ // We keep track of the previous location name so we can set the `from` field on navigation transactions.
108+ // This is either a route or a pathname.
109+ let prevLocationName : string | undefined = undefined ;
113110
114111const client = getCurrentHub ( ) . getClient ( ) ;
115112
@@ -126,18 +123,14 @@ export function nextRouterInstrumentation(
126123 startTransactionOnPageLoad : boolean = true ,
127124 startTransactionOnLocationChange : boolean = true ,
128125) : void {
129- startTransaction = startTransactionCb ;
126+ const { route, traceParentData, baggage, params } = extractNextDataTagInformation ( ) ;
127+ prevLocationName = route || global . location . pathname ;
130128
131129 if ( startTransactionOnPageLoad ) {
132- const { route, traceParentData, baggage, params } = extractNextDataTagInformation ( ) ;
133-
134- prevTransactionName = route || global . location . pathname ;
135- previousLocation = global . location . pathname ;
136-
137130 const source = route ? 'route' : 'url' ;
138131
139132 activeTransaction = startTransactionCb ( {
140- name : prevTransactionName ,
133+ name : prevLocationName ,
141134 op : 'pageload' ,
142135 tags : DEFAULT_TAGS ,
143136 ...( params && client && client . getOptions ( ) . sendDefaultPii && { data : params } ) ,
@@ -149,78 +142,111 @@ export function nextRouterInstrumentation(
149142 } ) ;
150143 }
151144
152- Router . ready ( ( ) => {
153- // Spans that aren't attached to any transaction are lost; so if transactions aren't
154- // created (besides potentially the onpageload transaction), no need to wrap the router.
155- if ( ! startTransactionOnLocationChange ) return ;
156-
157- // `withRouter` uses `useRouter` underneath:
158- // https://github.com/vercel/next.js/blob/de42719619ae69fbd88e445100f15701f6e1e100/packages/next/client/with-router.tsx#L21
159- // Router events also use the router:
160- // https://github.com/vercel/next.js/blob/de42719619ae69fbd88e445100f15701f6e1e100/packages/next/client/router.ts#L92
161- // `Router.changeState` handles the router state changes, so it may be enough to only wrap it
162- // (instead of wrapping all of the Router's functions).
163- const routerPrototype = Object . getPrototypeOf ( Router . router ) ;
164- fill ( routerPrototype , 'changeState' , changeStateWrapper ) ;
165- } ) ;
166- }
145+ if ( startTransactionOnLocationChange ) {
146+ Router . events . on ( 'routeChangeStart' , ( navigationTarget : string ) => {
147+ const matchedRoute = getNextRouteFromPathname ( stripUrlQueryAndFragment ( navigationTarget ) ) ;
167148
168- type RouterChangeState = (
169- method : string ,
170- url : string ,
171- as : string ,
172- options : Record < string , any > ,
173- ...args : any [ ]
174- ) => void ;
175- type WrappedRouterChangeState = RouterChangeState ;
149+ let transactionName : string ;
150+ let transactionSource : TransactionSource ;
176151
177- /**
178- * Wraps Router.changeState()
179- * https://github.com/vercel/next.js/blob/da97a18dafc7799e63aa7985adc95f213c2bf5f3/packages/next/next-server/lib/router/router.ts#L1204
180- * Start a navigation transaction every time the router changes state.
181- */
182- function changeStateWrapper ( originalChangeStateWrapper : RouterChangeState ) : WrappedRouterChangeState {
183- return function wrapper (
184- this : any ,
185- method : string ,
186- // The parameterized url, ex. posts/[id]/[comment]
187- url : string ,
188- // The actual url, ex. posts/85/my-comment
189- as : string ,
190- options : Record < string , any > ,
191- // At the moment there are no additional arguments (meaning the rest parameter is empty).
192- // This is meant to protect from future additions to Next.js API, especially since this is an
193- // internal API.
194- ...args : any [ ]
195- ) : Promise < boolean > {
196- const newTransactionName = stripUrlQueryAndFragment ( url ) ;
197-
198- // do not start a transaction if it's from the same page
199- if ( startTransaction !== undefined && previousLocation !== as ) {
200- previousLocation = as ;
201-
202- if ( activeTransaction ) {
203- activeTransaction . finish ( ) ;
152+ if ( matchedRoute ) {
153+ transactionName = matchedRoute ;
154+ transactionSource = 'route' ;
155+ } else {
156+ transactionName = navigationTarget ;
157+ transactionSource = 'url' ;
204158 }
205159
206160 const tags : Record < string , Primitive > = {
207161 ...DEFAULT_TAGS ,
208- method,
209- ...options ,
162+ from : prevLocationName ,
210163 } ;
211164
212- if ( prevTransactionName ) {
213- tags . from = prevTransactionName ;
165+ prevLocationName = transactionName ;
166+
167+ if ( activeTransaction ) {
168+ activeTransaction . finish ( ) ;
214169 }
215170
216- prevTransactionName = newTransactionName ;
217- activeTransaction = startTransaction ( {
218- name : prevTransactionName ,
171+ const navigationTransaction = startTransactionCb ( {
172+ name : transactionName ,
219173 op : 'navigation' ,
220174 tags,
221- metadata : { source : 'route' } ,
175+ metadata : { source : transactionSource } ,
222176 } ) ;
223- }
224- return originalChangeStateWrapper . call ( this , method , url , as , options , ...args ) ;
225- } ;
177+
178+ if ( navigationTransaction ) {
179+ // In addition to the navigation transaction we're also starting a span to mark Next.js's `routeChangeStart`
180+ // and `routeChangeComplete` events.
181+ // We don't want to finish the navigation transaction on `routeChangeComplete`, since users might want to attach
182+ // spans to that transaction even after `routeChangeComplete` is fired (eg. HTTP requests in some useEffect
183+ // hooks). Instead, we'll simply let the navigation transaction finish itself (it's an `IdleTransaction`).
184+ const nextRouteChangeSpan = navigationTransaction . startChild ( {
185+ op : 'ui.nextjs.route-change' ,
186+ description : 'Next.js Route Change' ,
187+ } ) ;
188+
189+ const finishRouteChangeSpan = ( ) : void => {
190+ nextRouteChangeSpan . finish ( ) ;
191+ Router . events . off ( 'routeChangeComplete' , finishRouteChangeSpan ) ;
192+ } ;
193+
194+ Router . events . on ( 'routeChangeComplete' , finishRouteChangeSpan ) ;
195+ }
196+ } ) ;
197+ }
198+ }
199+
200+ function getNextRouteFromPathname ( pathname : string ) : string | undefined {
201+ const pageRoutes = ( global . __BUILD_MANIFEST || { } ) . sortedPages ;
202+
203+ // Page route should in 99.999% of the cases be defined by now but just to be sure we make a check here
204+ if ( ! pageRoutes ) {
205+ return ;
206+ }
207+
208+ return pageRoutes . find ( route => {
209+ const routeRegExp = convertNextRouteToRegExp ( route ) ;
210+ return pathname . match ( routeRegExp ) ;
211+ } ) ;
212+ }
213+
214+ /**
215+ * Converts a Next.js style route to a regular expression that matches on pathnames (no query params or URL fragments).
216+ *
217+ * In general this involves replacing any instances of square brackets in a route with a wildcard:
218+ * e.g. "/users/[id]/info" becomes /\/users\/([^/]+?)\/info/
219+ *
220+ * Some additional edgecases need to be considered:
221+ * - All routes have an optional slash at the end, meaning users can navigate to "/users/[id]/info" or
222+ * "/users/[id]/info/" - both will be resolved to "/users/[id]/info".
223+ * - Non-optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[...params]").
224+ * - Optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[[...params]]").
225+ *
226+ * @param route A Next.js style route as it is found in `global.__BUILD_MANIFEST.sortedPages`
227+ */
228+ function convertNextRouteToRegExp ( route : string ) : RegExp {
229+ // We can assume a route is at least "/".
230+ const routeParts = route . split ( '/' ) ;
231+
232+ let optionalCatchallWildcardRegex = '' ;
233+ if ( routeParts [ routeParts . length - 1 ] . match ( / ^ \[ \[ \. \. \. .+ \] \] $ / ) ) {
234+ // If last route part has pattern "[[...xyz]]" we pop the latest route part to get rid of the required trailing
235+ // slash that would come before it if we didn't pop it.
236+ routeParts . pop ( ) ;
237+ optionalCatchallWildcardRegex = '(?:/(.+?))?' ;
238+ }
239+
240+ const rejoinedRouteParts = routeParts
241+ . map (
242+ routePart =>
243+ routePart
244+ . replace ( / ^ \[ \. \. \. .+ \] $ / , '(.+?)' ) // Replace catch all wildcard with regex wildcard
245+ . replace ( / ^ \[ .* \] $ / , '([^/]+?)' ) , // Replace route wildcards with lazy regex wildcards
246+ )
247+ . join ( '/' ) ;
248+
249+ return new RegExp (
250+ `^${ rejoinedRouteParts } ${ optionalCatchallWildcardRegex } (?:/)?$` , // optional slash at the end
251+ ) ;
226252}
0 commit comments