Repository navigation
feat(nextjs): Wait for the resumed part of a document before starting the pageload #25142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bf8dec5
b05fd2b
33ff1b5
2511028
f86d6f6
c248cc5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import { headers } from 'next/headers'; | ||
|
|
||
| // Opts out of the static shell: the whole page renders on the server for every request, so the | ||
| // trace meta tags land in <head> like in a classic dynamic render. | ||
| export const instant = false; | ||
|
|
||
| export default async function Page() { | ||
| const requestHeaders = await headers(); | ||
| return <h1 id="blocking">Blocking render for {requestHeaders.get('user-agent') ? 'a browser' : 'nobody'}</h1>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| // No request data anywhere, so the whole page is a static shell and nothing renders per request. | ||
| export default function Page() { | ||
| return <h1 id="static-shell">Static shell</h1>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import { headers } from 'next/headers'; | ||
|
|
||
| // Opts out of the static shell: the whole page renders on the server for every request, so the | ||
| // trace meta tags land in <head> like in a classic dynamic render. | ||
| export const instant = false; | ||
|
|
||
| export default async function Page() { | ||
| const requestHeaders = await headers(); | ||
| return <h1 id="blocking">Blocking render for {requestHeaders.get('user-agent') ? 'a browser' : 'nobody'}</h1>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| // No request data anywhere, so the whole page is a static shell and nothing renders per request. | ||
| export default function Page() { | ||
| return <h1 id="static-shell">Static shell</h1>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import type { Integration } from '@sentry/core'; | ||
| import { browserTracingIntegration as originalBrowserTracingIntegration, isBotUserAgent } from '@sentry/react'; | ||
| import { settlePendingPageloadWait } from './routing/appRouterRoutingInstrumentation'; | ||
| import { nextRouterInstrumentNavigation, nextRouterInstrumentPageLoad } from './routing/nextRoutingInstrumentation'; | ||
|
|
||
| /** | ||
|
|
@@ -41,6 +42,14 @@ export function browserTracingIntegration( | |
| nextRouterInstrumentNavigation(client); | ||
| } | ||
|
|
||
| // Registered before the browser tracing integration's own handler, which creates the navigation | ||
| // span: a pageload that is still waiting for its trace meta tag has to exist by then, so the | ||
| // handler ends it like any pageload a navigation interrupts. Started later, the pageload would | ||
| // end the navigation span instead. | ||
| if (instrumentPageLoad) { | ||
| client.on('startNavigationSpan', settlePendingPageloadWait); | ||
| } | ||
|
Comment on lines
+49
to
+51
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. M: this hook is registered in Can we add an E2E test that clicks a link during a slow dynamic hole? I think that would catch it. |
||
|
|
||
| browserTracingIntegrationInstance.afterAllSetup(client); | ||
|
|
||
| if (instrumentPageLoad) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import type { Client, Span } from '@sentry/core'; | ||
| import type { Client, Span, StartSpanOptions } from '@sentry/core'; | ||
| import { | ||
| getCurrentScope, | ||
| GLOBAL_OBJ, | ||
| hasSpanStreamingEnabled, | ||
| isObjectLike, | ||
|
|
@@ -17,6 +18,8 @@ import { | |
| resolveRoute, | ||
| } from '@sentry/react'; | ||
| import { stripTrailingSlash } from './parameterization'; | ||
| import type { TraceMetaTagValues, TraceMetaTagWait } from './traceMetaTags'; | ||
| import { addTraceMetaTagLink, canWaitForTraceMetaTag, readTraceMetaTags, waitForTraceMetaTag } from './traceMetaTags'; | ||
| import { | ||
| SENTRY_OP, | ||
| SENTRY_SEGMENT_NAME_SOURCE, | ||
|
|
@@ -95,21 +98,90 @@ let navigationRoutingMode: 'router-patch' | 'transition-start-hook' = 'router-pa | |
|
|
||
| const currentRouterPatchingNavigationSpanRef: NavigationSpanRef = { current: undefined }; | ||
|
|
||
| /** | ||
| * How long the pageload start may wait for the resumed part of a Cache Components document. The wait | ||
| * normally ends with `DOMContentLoaded`; the cap only matters for a response the server keeps open. | ||
| */ | ||
| const PAGELOAD_TRACE_META_TAG_TIMEOUT_MS = 10_000; | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this one is arbitrarily chosen and should likely never be met in real world conditions |
||
|
|
||
| let pendingPageloadWait: TraceMetaTagWait | undefined; | ||
|
|
||
| /** | ||
| * Starts a pageload that is still waiting for its trace meta tag with a fresh trace. Called when a | ||
| * navigation starts, before the navigation span is created, so the navigation ends the pageload like | ||
| * any pageload it interrupts, and the pageload is still recorded. | ||
| */ | ||
| export function settlePendingPageloadWait(): void { | ||
| pendingPageloadWait?.giveUp(); | ||
| } | ||
|
|
||
| /** | ||
| * Starts the pageload span with the request data of the document. The span may start after a | ||
| * navigation already changed the location, so the browser SDK would otherwise read the new one. | ||
| */ | ||
| function startPageloadSpan( | ||
| client: Client, | ||
| spanOptions: StartSpanOptions, | ||
| documentUrl: string, | ||
| traceMetaTags?: TraceMetaTagValues, | ||
| ): Span | undefined { | ||
| const span = startBrowserTracingPageLoadSpan(client, spanOptions, traceMetaTags); | ||
| const scope = getCurrentScope(); | ||
| const { normalizedRequest } = scope.getScopeData().sdkProcessingMetadata; | ||
| if (normalizedRequest) { | ||
| scope.setSDKProcessingMetadata({ normalizedRequest: { ...normalizedRequest, url: documentUrl } }); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What do we get in the
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'll check again and guard it if needed |
||
| } | ||
| return span; | ||
| } | ||
|
|
||
| /** Instruments the Next.js app router for pageloads. */ | ||
| export function appRouterInstrumentPageLoad(client: Client): void { | ||
| const documentUrl = WINDOW.location.href; | ||
| const pathname = stripTrailingSlash(WINDOW.location.pathname); | ||
| const parameterizedPathname = resolveCurrentRoute(client); | ||
| startBrowserTracingPageLoadSpan(client, { | ||
| const spanOptions: StartSpanOptions = { | ||
| // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. | ||
| name: parameterizedPathname ?? (hasSpanStreamingEnabled(client) ? PAGELOAD_SPAN_NAME_FALLBACK : pathname), | ||
| // pageload should always start at timeOrigin (and needs to be in s, not ms) | ||
| attributes: { | ||
| [SENTRY_OP]: PAGELOAD, | ||
| [SENTRY_ORIGIN]: 'auto.pageload.nextjs.app_router_instrumentation', | ||
| [SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url', | ||
| [URL_PATH]: WINDOW.location.pathname, | ||
| [URL_FULL]: filterCollectedUrl(documentUrl, client), | ||
| ...(parameterizedPathname && { [URL_TEMPLATE]: parameterizedPathname }), | ||
| }, | ||
| }); | ||
| }; | ||
|
|
||
| // A tag that is already in the document was rendered for this request and can be continued. A | ||
| // Cache Components document that resumes a prerendered shell gets its tag with the resumed part, | ||
| // which usually arrives after the SDK started. Waiting for it lets the pageload continue the server | ||
| // trace instead of linking it; the span's start is backdated to the time origin either way. | ||
| const traceMetaTags = readTraceMetaTags(); | ||
| if (traceMetaTags || !canWaitForTraceMetaTag()) { | ||
| startPageloadSpan(client, spanOptions, documentUrl, traceMetaTags); | ||
| return; | ||
| } | ||
|
|
||
| pendingPageloadWait = waitForTraceMetaTag( | ||
| values => { | ||
| pendingPageloadWait = undefined; | ||
| startPageloadSpan(client, spanOptions, documentUrl, values); | ||
| }, | ||
| () => { | ||
| pendingPageloadWait = undefined; | ||
| const span = startPageloadSpan(client, spanOptions, documentUrl); | ||
| // Only reachable through the cap or a navigation while the document still streams: a tag that | ||
| // shows up after this can no longer be continued, but the server request can still be linked. | ||
| if (span) { | ||
| waitForTraceMetaTag( | ||
| values => span.isRecording() && addTraceMetaTagLink(span, values), | ||
| () => undefined, | ||
| ); | ||
| } | ||
| }, | ||
| PAGELOAD_TRACE_META_TAG_TIMEOUT_MS, | ||
| ); | ||
| } | ||
|
|
||
| interface NavigationSpanRef { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.