1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
-
3
1
import { getCurrentHub } from '@sentry/hub' ;
4
- import { Primitive , TraceparentData , Transaction , TransactionContext } from '@sentry/types' ;
2
+ import { Primitive , TraceparentData , Transaction , TransactionContext , TransactionSource } from '@sentry/types' ;
5
3
import {
6
4
extractTraceparentData ,
7
- fill ,
8
5
getGlobalObject ,
9
6
logger ,
10
7
parseBaggageHeader ,
@@ -14,7 +11,13 @@ import type { NEXT_DATA as NextData } from 'next/dist/next-server/lib/utils';
14
11
import { default as Router } from 'next/router' ;
15
12
import type { ParsedUrlQuery } from 'querystring' ;
16
13
17
- const global = getGlobalObject < Window > ( ) ;
14
+ const global = getGlobalObject <
15
+ Window & {
16
+ __BUILD_MANIFEST ?: {
17
+ sortedPages ?: string [ ] ;
18
+ } ;
19
+ }
20
+ > ( ) ;
18
21
19
22
type StartTransactionCb = ( context : TransactionContext ) => Transaction | undefined ;
20
23
@@ -76,6 +79,8 @@ function extractNextDataTagInformation(): NextDataTagInfo {
76
79
// `nextData.page` always contains the parameterized route - except for when an error occurs in a data fetching
77
80
// function, then it is "/_error", but that isn't a problem since users know which route threw by looking at the
78
81
// 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.
79
84
nextDataTagInfo . route = page ;
80
85
nextDataTagInfo . params = query ;
81
86
@@ -96,20 +101,12 @@ const DEFAULT_TAGS = {
96
101
'routing.instrumentation' : 'next-router' ,
97
102
} as const ;
98
103
104
+ // We keep track of the active transaction so we can finish it when we start a navigation transaction.
99
105
let activeTransaction : Transaction | undefined = undefined ;
100
- let startTransaction : StartTransactionCb | undefined = undefined ;
101
106
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 ;
113
110
114
111
const client = getCurrentHub ( ) . getClient ( ) ;
115
112
@@ -126,18 +123,14 @@ export function nextRouterInstrumentation(
126
123
startTransactionOnPageLoad : boolean = true ,
127
124
startTransactionOnLocationChange : boolean = true ,
128
125
) : void {
129
- startTransaction = startTransactionCb ;
126
+ const { route, traceParentData, baggage, params } = extractNextDataTagInformation ( ) ;
127
+ prevLocationName = route || global . location . pathname ;
130
128
131
129
if ( startTransactionOnPageLoad ) {
132
- const { route, traceParentData, baggage, params } = extractNextDataTagInformation ( ) ;
133
-
134
- prevTransactionName = route || global . location . pathname ;
135
- previousLocation = global . location . pathname ;
136
-
137
130
const source = route ? 'route' : 'url' ;
138
131
139
132
activeTransaction = startTransactionCb ( {
140
- name : prevTransactionName ,
133
+ name : prevLocationName ,
141
134
op : 'pageload' ,
142
135
tags : DEFAULT_TAGS ,
143
136
...( params && client && client . getOptions ( ) . sendDefaultPii && { data : params } ) ,
@@ -149,78 +142,111 @@ export function nextRouterInstrumentation(
149
142
} ) ;
150
143
}
151
144
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 ) ) ;
167
148
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 ;
176
151
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' ;
204
158
}
205
159
206
160
const tags : Record < string , Primitive > = {
207
161
...DEFAULT_TAGS ,
208
- method,
209
- ...options ,
162
+ from : prevLocationName ,
210
163
} ;
211
164
212
- if ( prevTransactionName ) {
213
- tags . from = prevTransactionName ;
165
+ prevLocationName = transactionName ;
166
+
167
+ if ( activeTransaction ) {
168
+ activeTransaction . finish ( ) ;
214
169
}
215
170
216
- prevTransactionName = newTransactionName ;
217
- activeTransaction = startTransaction ( {
218
- name : prevTransactionName ,
171
+ const navigationTransaction = startTransactionCb ( {
172
+ name : transactionName ,
219
173
op : 'navigation' ,
220
174
tags,
221
- metadata : { source : 'route' } ,
175
+ metadata : { source : transactionSource } ,
222
176
} ) ;
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
+ ) ;
226
252
}
0 commit comments