Skip to content

Commit ecb46df

Browse files
chargomeclaude
andcommitted
fix(nextjs): Record the document URL on a pageload that starts after a navigation
A pageload that waits for its trace meta tag can be settled by a navigation, and a `popstate` has already changed the location by then. The browser SDK reads the location when the span starts, so the pageload got the URL of the navigation. Capture the document URL when the instrumentation sets up and put it on the span and the request data instead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 4a8297e commit ecb46df

2 files changed

Lines changed: 75 additions & 4 deletions

File tree

‎packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Client, Span, StartSpanOptions } from '@sentry/core';
22
import {
3+
getCurrentScope,
34
GLOBAL_OBJ,
45
hasSpanStreamingEnabled,
56
isObjectLike,
@@ -17,7 +18,7 @@ import {
1718
resolveRoute,
1819
} from '@sentry/react';
1920
import { stripTrailingSlash } from './parameterization';
20-
import type { TraceMetaTagWait } from './traceMetaTags';
21+
import type { TraceMetaTagValues, TraceMetaTagWait } from './traceMetaTags';
2122
import { addTraceMetaTagLink, canWaitForTraceMetaTag, readTraceMetaTags, waitForTraceMetaTag } from './traceMetaTags';
2223
import {
2324
SENTRY_OP,
@@ -114,8 +115,28 @@ export function settlePendingPageloadWait(): void {
114115
pendingPageloadWait?.giveUp();
115116
}
116117

118+
/**
119+
* Starts the pageload span with the request data of the document. The span may start after a
120+
* navigation already changed the location, so the browser SDK would otherwise read the new one.
121+
*/
122+
function startPageloadSpan(
123+
client: Client,
124+
spanOptions: StartSpanOptions,
125+
documentUrl: string,
126+
traceMetaTags?: TraceMetaTagValues,
127+
): Span | undefined {
128+
const span = startBrowserTracingPageLoadSpan(client, spanOptions, traceMetaTags);
129+
const scope = getCurrentScope();
130+
const { normalizedRequest } = scope.getScopeData().sdkProcessingMetadata;
131+
if (normalizedRequest) {
132+
scope.setSDKProcessingMetadata({ normalizedRequest: { ...normalizedRequest, url: documentUrl } });
133+
}
134+
return span;
135+
}
136+
117137
/** Instruments the Next.js app router for pageloads. */
118138
export function appRouterInstrumentPageLoad(client: Client): void {
139+
const documentUrl = WINDOW.location.href;
119140
const pathname = stripTrailingSlash(WINDOW.location.pathname);
120141
const parameterizedPathname = resolveCurrentRoute(client);
121142
const spanOptions: StartSpanOptions = {
@@ -126,6 +147,8 @@ export function appRouterInstrumentPageLoad(client: Client): void {
126147
[SENTRY_OP]: PAGELOAD,
127148
[SENTRY_ORIGIN]: 'auto.pageload.nextjs.app_router_instrumentation',
128149
[SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url',
150+
[URL_PATH]: WINDOW.location.pathname,
151+
[URL_FULL]: filterCollectedUrl(documentUrl),
129152
...(parameterizedPathname && { [URL_TEMPLATE]: parameterizedPathname }),
130153
},
131154
};
@@ -136,18 +159,18 @@ export function appRouterInstrumentPageLoad(client: Client): void {
136159
// trace instead of linking it; the span's start is backdated to the time origin either way.
137160
const traceMetaTags = readTraceMetaTags();
138161
if (traceMetaTags || !canWaitForTraceMetaTag()) {
139-
startBrowserTracingPageLoadSpan(client, spanOptions, traceMetaTags);
162+
startPageloadSpan(client, spanOptions, documentUrl, traceMetaTags);
140163
return;
141164
}
142165

143166
pendingPageloadWait = waitForTraceMetaTag(
144167
values => {
145168
pendingPageloadWait = undefined;
146-
startBrowserTracingPageLoadSpan(client, spanOptions, values);
169+
startPageloadSpan(client, spanOptions, documentUrl, values);
147170
},
148171
() => {
149172
pendingPageloadWait = undefined;
150-
const span = startBrowserTracingPageLoadSpan(client, spanOptions);
173+
const span = startPageloadSpan(client, spanOptions, documentUrl);
151174
// Only reachable through the cap or a navigation while the document still streams: a tag that
152175
// shows up after this can no longer be continued, but the server request can still be linked.
153176
if (span) {

‎packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,3 +264,51 @@ describe('appRouterInstrumentNavigation with basePath', () => {
264264
expect(core.spanToJSON(span!).name).toBe('/my-app/navigation/:param/router-push');
265265
});
266266
});
267+
268+
describe('appRouterInstrumentPageLoad', () => {
269+
const originalReadyState = Object.getOwnPropertyDescriptor(Document.prototype, 'readyState');
270+
271+
beforeEach(() => {
272+
globalWithNext._sentryRouteManifest = JSON.stringify(manifest);
273+
window.history.replaceState({}, '', '/navigation?from=document');
274+
Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true });
275+
vi.spyOn(performance, 'getEntriesByType').mockReturnValue([]);
276+
});
277+
278+
afterEach(() => {
279+
delete globalWithNext.next;
280+
delete globalWithNext._sentryRouteManifest;
281+
// @ts-expect-error deleting the instance override restores the prototype getter
282+
delete document.readyState;
283+
if (originalReadyState) {
284+
Object.defineProperty(Document.prototype, 'readyState', originalReadyState);
285+
}
286+
vi.restoreAllMocks();
287+
});
288+
289+
it('records the URL of the document when the pageload starts after a navigation changed it', async () => {
290+
const { core, instrumentation } = await setup('static');
291+
292+
instrumentation.appRouterInstrumentPageLoad(core.getClient()!);
293+
const spanBeforeSettling = core.getActiveSpan();
294+
295+
window.history.replaceState({}, '', '/navigation/42/router-back');
296+
instrumentation.settlePendingPageloadWait();
297+
298+
const span = core.getActiveSpan();
299+
expect(span).toBeDefined();
300+
expect(span).not.toBe(spanBeforeSettling);
301+
const spanJson = core.spanToJSON(span!);
302+
expect(spanJson.name).toBe('/navigation');
303+
expect(spanJson.attributes?.['sentry.op']).toBe('pageload');
304+
expect(spanJson.attributes).toEqual(
305+
expect.objectContaining({
306+
'url.path': '/navigation',
307+
'url.full': 'http://localhost:3000/navigation?from=document',
308+
}),
309+
);
310+
expect(core.getCurrentScope().getScopeData().sdkProcessingMetadata.normalizedRequest?.url).toBe(
311+
'http://localhost:3000/navigation?from=document',
312+
);
313+
});
314+
});

0 commit comments

Comments
 (0)