66 PAGELOAD_SPAN_NAME_FALLBACK ,
77 SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN ,
88 filterCollectedUrl ,
9+ timestampInSeconds ,
910} from '@sentry/core' ;
1011import {
1112 startBrowserTracingNavigationSpan ,
@@ -38,7 +39,44 @@ function setNavigationSpanUrlAttributes(span: Span, urlPath: string, urlOrPath:
3839 } ) ;
3940}
4041
41- export const INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME = 'incomplete-app-router-transaction' ;
42+ /**
43+ * `router.back()` and `router.forward()` carry no destination, so their navigation span can only be
44+ * started once the resulting `popstate` event tells us where we ended up. Until then, this remembers
45+ * which router method triggered the traversal and when, so the span still gets the router's
46+ * navigation type and starts at the router call rather than at the `popstate`.
47+ */
48+ interface PendingHistoryTraversal {
49+ navigationType : 'router.back' | 'router.forward' ;
50+ startTime : number ;
51+ }
52+
53+ let pendingHistoryTraversal : PendingHistoryTraversal | undefined ;
54+ let pendingHistoryTraversalTimeout : ReturnType < typeof setTimeout > | undefined ;
55+
56+ /**
57+ * A `back()`/`forward()` without a matching history entry never fires `popstate`. Without an expiry,
58+ * a later unrelated `popstate` (e.g. the browser's back button) would be attributed to that stale
59+ * router call. Browsers dispatch the `popstate` of a same-document traversal within a few
60+ * milliseconds, so anything older than this is not the traversal we are waiting for. A timer rather
61+ * than a timestamp comparison keeps this tolerant of a blocked main thread, which delays the
62+ * `popstate` and the timer alike.
63+ */
64+ const PENDING_HISTORY_TRAVERSAL_TIMEOUT_MS = 1000 ;
65+
66+ function setPendingHistoryTraversal ( navigationType : PendingHistoryTraversal [ 'navigationType' ] ) : void {
67+ clearTimeout ( pendingHistoryTraversalTimeout ) ;
68+ pendingHistoryTraversal = { navigationType, startTime : timestampInSeconds ( ) } ;
69+ pendingHistoryTraversalTimeout = setTimeout ( ( ) => {
70+ pendingHistoryTraversal = undefined ;
71+ } , PENDING_HISTORY_TRAVERSAL_TIMEOUT_MS ) ;
72+ }
73+
74+ function takePendingHistoryTraversal ( ) : PendingHistoryTraversal | undefined {
75+ clearTimeout ( pendingHistoryTraversalTimeout ) ;
76+ const traversal = pendingHistoryTraversal ;
77+ pendingHistoryTraversal = undefined ;
78+ return traversal ;
79+ }
4280
4381/**
4482 * This mutable keeps track of what router navigation instrumentation mechanism we are using.
@@ -164,7 +202,10 @@ export function appRouterInstrumentNavigation(client: Client): void {
164202 // With span streaming, span names have to be low cardinality, so we can't fall back to the URL.
165203 const spanName =
166204 parameterizedPathname ?? ( hasSpanStreamingEnabled ( client ) ? NAVIGATION_SPAN_NAME_FALLBACK : pathname ) ;
167- if ( currentRouterPatchingNavigationSpanRef . current ?. isRecording ( ) ) {
205+ const traversal = takePendingHistoryTraversal ( ) ;
206+ // A traversal triggered through the router always gets its own span: an open router-patch span
207+ // here would be a `push()`/`replace()` that the user navigated away from again.
208+ if ( ! traversal && currentRouterPatchingNavigationSpanRef . current ?. isRecording ( ) ) {
168209 currentRouterPatchingNavigationSpanRef . current . updateName ( spanName ) ;
169210 currentRouterPatchingNavigationSpanRef . current . setAttribute (
170211 SENTRY_SEGMENT_NAME_SOURCE ,
@@ -179,14 +220,17 @@ export function appRouterInstrumentNavigation(client: Client): void {
179220 client ,
180221 {
181222 name : spanName ,
223+ startTime : traversal ?. startTime ,
182224 attributes : {
183225 [ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN ] : 'auto.navigation.nextjs.app_router_instrumentation' ,
184226 [ SENTRY_SEGMENT_NAME_SOURCE ] : parameterizedPathname ? 'route' : 'url' ,
185- 'navigation.type' : 'browser.popstate' ,
227+ 'navigation.type' : traversal ?. navigationType ?? 'browser.popstate' ,
186228 ...( parameterizedPathname && { [ URL_TEMPLATE ] : parameterizedPathname } ) ,
187229 } ,
188230 } ,
189- { url : getAbsoluteUrl ( pathname ) } ,
231+ // The full location rather than just the pathname, so the span's `url.full` keeps the
232+ // (filtered) query string like the update path above does.
233+ { url : WINDOW . location . href } ,
190234 ) ;
191235 }
192236 } ) ;
@@ -252,56 +296,34 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe
252296 return target . apply ( thisArg , argArray ) ;
253297 }
254298
255- let transactionName = INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME ;
256- const transactionAttributes : Record < string , string > = {
257- [ SENTRY_OP ] : NAVIGATION ,
258- [ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN ] : 'auto.navigation.nextjs.app_router_instrumentation' ,
259- [ SENTRY_SEGMENT_NAME_SOURCE ] : 'url' ,
260- } ;
299+ if ( routerFunctionName === 'back' || routerFunctionName === 'forward' ) {
300+ setPendingHistoryTraversal ( `router.${ routerFunctionName } ` ) ;
301+ return target . apply ( thisArg , argArray ) ;
302+ }
261303
262304 const href = argArray [ 0 ] ;
263305 const basePath = process . env . _sentryBasePath ?? globalWithInjectedBasePath . _sentryBasePath ;
264306 const normalizedHref =
265307 basePath && typeof href === 'string' && ! href . startsWith ( basePath ) ? `${ basePath } ${ href } ` : href ;
266- if ( routerFunctionName === 'push' ) {
267- transactionName = stripTrailingSlash ( transactionNameifyRouterArgument ( normalizedHref ) ) ;
268- transactionAttributes [ 'navigation.type' ] = 'router.push' ;
269- } else if ( routerFunctionName === 'replace' ) {
270- transactionName = stripTrailingSlash ( transactionNameifyRouterArgument ( normalizedHref ) ) ;
271- transactionAttributes [ 'navigation.type' ] = 'router.replace' ;
272- } else if ( routerFunctionName === 'back' ) {
273- transactionAttributes [ 'navigation.type' ] = 'router.back' ;
274- } else if ( routerFunctionName === 'forward' ) {
275- transactionAttributes [ 'navigation.type' ] = 'router.forward' ;
276- }
277-
308+ const transactionName = stripTrailingSlash ( transactionNameifyRouterArgument ( normalizedHref ) ) ;
278309 const parameterizedPathname = maybeParameterizeRoute ( transactionName ) ;
279310
280- const navigationUrl =
281- routerFunctionName === 'back' || routerFunctionName === 'forward'
282- ? undefined
283- : getAbsoluteUrl ( normalizedHref ) ;
284-
285- // The incomplete-instrumentation placeholder is a static name, so it is low cardinality
286- // already, and keeping it is what makes the `ignoreSpans` entry filtering those spans match.
287- const isPlaceholderName = transactionName === INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME ;
288-
289311 currentNavigationSpanRef . current = startBrowserTracingNavigationSpan (
290312 client ,
291313 {
292314 // With span streaming, span names have to be low cardinality, so we can't fall back to the URL.
293315 name :
294316 parameterizedPathname ??
295- ( isPlaceholderName || ! hasSpanStreamingEnabled ( client )
296- ? transactionName
297- : NAVIGATION_SPAN_NAME_FALLBACK ) ,
317+ ( hasSpanStreamingEnabled ( client ) ? NAVIGATION_SPAN_NAME_FALLBACK : transactionName ) ,
298318 attributes : {
299- ...transactionAttributes ,
319+ [ SENTRY_OP ] : NAVIGATION ,
320+ [ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN ] : 'auto.navigation.nextjs.app_router_instrumentation' ,
300321 [ SENTRY_SEGMENT_NAME_SOURCE ] : parameterizedPathname ? 'route' : 'url' ,
322+ 'navigation.type' : `router.${ routerFunctionName } ` ,
301323 ...( parameterizedPathname && { [ URL_TEMPLATE ] : parameterizedPathname } ) ,
302324 } ,
303325 } ,
304- navigationUrl ? { url : navigationUrl } : undefined ,
326+ { url : getAbsoluteUrl ( normalizedHref ) } ,
305327 ) ;
306328
307329 return target . apply ( thisArg , argArray ) ;
0 commit comments