feat(browser): Detect redirects when emitting navigation spans - #16324

Merged
mydea merged 6 commits into
developfrom
fn/detect-pageload-redirects
Jul 10, 2025
Merged

feat(browser): Detect redirects when emitting navigation spans#16324
mydea merged 6 commits into
developfrom
fn/detect-pageload-redirects

Conversation

@mydea

Copy link
Copy Markdown
Member

Closes#15286

This PR adds a new option to browserTracingIntegration, detectRedirects, which is enabled by default. If this is enabled, the integration will try to detect if a navigation is actually a redirect based on a simple heuristic, and in this case, will not end the ongoing pageload/navigation, but instead let it run and create a navigation.redirect zero-duration span instead.

An example trace for this would be: https://sentry-sdks.sentry.io/explore/discover/trace/95280de69dc844448d39de7458eab527/?dataset=transactions&eventId=8a1150fd1dc846e4ac8420ccf03ad0ee&field=title&field=project&field=user.display&field=timestamp&name=All%20Errors&project=4504956726345728&query=&queryDataset=transaction-like&sort=-timestamp&source=discover&statsPeriod=5m&timestamp=1747646096&yAxis=count%28%29
image

Where the respective index route that triggered this has this code:

setTimeout(()=>{window.history.pushState({},"","/test-sub-page");fetch('https://example.com')},100);

The used heuristic is:

  • If the ongoing pageload/navigation was started less than 300ms ago...
  • ... and no click has happened in this time...
  • ... then we consider the navigation a redirect

this limit was chosen somewhat arbitrarily, open for other suggestions too.

While this logic will not be 100% bullet proof, it should be reliable enough and likely better than what we have today. Users can opt-out of this logic via browserTracingIntegration({ detectRedirects: false }), if needed.

@mydea
mydea requested review from Lms24, bcoe and s1gr1dMay 19, 2025 09:21
@mydeamydea self-assigned this May 19, 2025
@github-actions

github-actionsBot commented May 19, 2025

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser23.99 kB--
@sentry/browser - with treeshaking flags23.76 kB--
@sentry/browser (incl. Tracing)39.85 kB+0.6%+235 B 🔺
@sentry/browser (incl. Tracing, Replay)78.06 kB+0.31%+238 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags71.09 kB+0.27%+187 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)82.77 kB+0.28%+225 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)94.99 kB+0.3%+277 B 🔺
@sentry/browser (incl. Feedback)40.76 kB--
@sentry/browser (incl. sendFeedback)28.7 kB--
@sentry/browser (incl. FeedbackAsync)33.59 kB--
@sentry/react25.76 kB--
@sentry/react (incl. Tracing)41.85 kB+0.58%+239 B 🔺
@sentry/vue28.37 kB--
@sentry/vue (incl. Tracing)41.66 kB+0.6%+246 B 🔺
@sentry/svelte24.01 kB--
CDN Bundle25.5 kB--
CDN Bundle (incl. Tracing)39.82 kB+0.48%+187 B 🔺
CDN Bundle (incl. Tracing, Replay)75.8 kB+0.25%+187 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)81.27 kB+0.24%+193 B 🔺
CDN Bundle - uncompressed74.5 kB--
CDN Bundle (incl. Tracing) - uncompressed118.25 kB+0.41%+481 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed232.55 kB+0.21%+481 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed245.38 kB+0.2%+481 B 🔺
@sentry/nextjs (client)43.48 kB+0.52%+222 B 🔺
@sentry/sveltekit (client)40.32 kB+0.59%+235 B 🔺
@sentry/node161.84 kB--
@sentry/node - without tracing98.79 kB--
@sentry/aws-serverless124.61 kB--

View base workflow run

@codecov

codecovBot commented May 19, 2025

Copy link
Copy Markdown

❌ Unsupported file format

Upload processing failed due to unsupported file format. Please review the parser error message:

Error parsing JUnit XML in /home/runner/work/sentry-javascript/sentry-javascript/packages/solidstart/vitest.junit.xml at 18:17
Caused by:
RuntimeError: Error parsing XML
Caused by:
0: ill-formed document: expected `</testsuites>`, but `</testsuite>` was found
1: expected `</testsuites>`, but `</testsuite>` was found

For more help, visit our troubleshooting guide.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch 2 times, most recently from ccbd697 to eb3c0bcCompareMay 23, 2025 07:22
@mydea
mydea marked this pull request as ready for review May 23, 2025 07:22
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts Outdated
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from eb3c0bc to cb8e92eCompareMay 26, 2025 11:22

@edwardgou-sentryedwardgou-sentry left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me! There aren't many product areas in performance that specifically rely on navigations so I think this should be fine (and I think we'd consider surfacing redirects in those areas a bug anyways).

}

if (detectRedirects && optionalWindowDocument) {
addEventListener('click', () => (lastClickTimestamp = timestampInSeconds()), { capture: true, passive: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are there other events, such as key presses, that could indicate a user manually navigating?

@Lms24Lms24Jun 16, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, keypress might also be a good candidate, agreed.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 also looking at keypress

@Lms24Lms24 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the late review, but LGTM! I think we probably need to widen the timespan a bit because 300ms feel a bit fast to me (thinking of the endless redirects I get when doing SSO or stuff like this). But maybe it's good enough for now. I'd say its something we adjust on a per-feedback basis.

}

if (detectRedirects && optionalWindowDocument) {
addEventListener('click', () => (lastClickTimestamp = timestampInSeconds()), { capture: true, passive: true });

@Lms24Lms24Jun 16, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, keypress might also be a good candidate, agreed.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 19d02d3 to 67791e9CompareJune 17, 2025 10:27
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 67791e9 to e2018b5CompareJune 18, 2025 07:52
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from e2018b5 to 9dec9c3CompareJuly 7, 2025 14:42
cursor[bot]

This comment was marked as outdated.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 9dec9c3 to da0cffeCompareJuly 10, 2025 07:12

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Navigation URL Metadata Update Fails

The scope.setSDKProcessingMetadata is not updated for navigation spans if the URL is falsy or if the navigation is detected as a redirect. This prevents subsequent events from having the correct URL information on the scope. Additionally, the redirect detection logic uses inconsistent timestamp functions (timestampInSeconds vs dateTimestampInSeconds), which can lead to inaccurate timing comparisons.

packages/browser/src/tracing/browserTracingIntegration.ts#L469-L780

constinteractionHandler=(): void=>{
lastInteractionTimestamp=timestampInSeconds();
};
addEventListener('click',interactionHandler,{capture: true});
addEventListener('keydown',interactionHandler,{capture: true,passive: true});
}
functionmaybeEndActiveSpan(): void{
constactiveSpan=getActiveIdleSpan(client);
if(activeSpan&&!spanToJSON(activeSpan).timestamp){
DEBUG_BUILD&&logger.log(`[Tracing] Finishing current active span with op: ${spanToJSON(activeSpan).op}`);
// If there's an open active span, we need to finish it before creating an new one.
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,'cancelled');
activeSpan.end();
}
}
client.on('startNavigationSpan',(startSpanOptions,navigationOptions)=>{
if(getClient()!==client){
return;
}
if(navigationOptions?.isRedirect){
DEBUG_BUILD&&
logger.warn('[Tracing] Detected redirect, navigation span will not be the root span, but a child span.');
_createRouteSpan(
client,
{
op: 'navigation.redirect',
...startSpanOptions,
},
false,
);
return;
}
maybeEndActiveSpan();
getIsolationScope().setPropagationContext({traceId: generateTraceId(),sampleRand: Math.random()});
constscope=getCurrentScope();
scope.setPropagationContext({traceId: generateTraceId(),sampleRand: Math.random()});
// We reset this to ensure we do not have lingering incorrect data here
// places that call this hook may set this where appropriate - else, the URL at span sending time is used
scope.setSDKProcessingMetadata({
normalizedRequest: undefined,
});
_createRouteSpan(client,{
op: 'navigation',
...startSpanOptions,
});
});
client.on('startPageLoadSpan',(startSpanOptions,traceOptions={})=>{
if(getClient()!==client){
return;
}
maybeEndActiveSpan();
constsentryTrace=traceOptions.sentryTrace||getMetaContent('sentry-trace');
constbaggage=traceOptions.baggage||getMetaContent('baggage');
constpropagationContext=propagationContextFromHeaders(sentryTrace,baggage);
constscope=getCurrentScope();
scope.setPropagationContext(propagationContext);
// We store the normalized request data on the scope, so we get the request data at time of span creation
// otherwise, the URL etc. may already be of the following navigation, and we'd report the wrong URL
scope.setSDKProcessingMetadata({
normalizedRequest: getHttpRequestData(),
});
_createRouteSpan(client,{
op: 'pageload',
...startSpanOptions,
});
});
},
afterAllSetup(client){
letstartingUrl: string|undefined=getLocationHref();
if(linkPreviousTrace!=='off'){
linkTraces(client,{ linkPreviousTrace, consistentTraceSampling });
}
if(WINDOW.location){
if(instrumentPageLoad){
constorigin=browserPerformanceTimeOrigin();
startBrowserTracingPageLoadSpan(client,{
name: WINDOW.location.pathname,
// pageload should always start at timeOrigin (and needs to be in s, not ms)
startTime: origin ? origin/1000 : undefined,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser',
},
});
}
if(instrumentNavigation){
addHistoryInstrumentationHandler(({ to, from })=>{
/**
* This early return is there to account for some cases where a navigation transaction starts right after
* long-running pageload. We make sure that if `from` is undefined and a valid `startingURL` exists, we don't
* create an uneccessary navigation transaction.
*
* This was hard to duplicate, but this behavior stopped as soon as this fix was applied. This issue might also
* only be caused in certain development environments where the usage of a hot module reloader is causing
* errors.
*/
if(from===undefined&&startingUrl?.indexOf(to)!==-1){
startingUrl=undefined;
return;
}
startingUrl=undefined;
constparsed=parseStringToURLObject(to);
constactiveSpan=getActiveIdleSpan(client);
constnavigationIsRedirect=
activeSpan&&detectRedirects&&isRedirect(activeSpan,lastInteractionTimestamp);
startBrowserTracingNavigationSpan(
client,
{
name: parsed?.pathname||WINDOW.location.pathname,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser',
},
},
{url: to,isRedirect: navigationIsRedirect},
);
});
}
}
if(markBackgroundSpan){
registerBackgroundTabDetection();
}
if(enableInteractions){
registerInteractionListener(client,idleTimeout,finalTimeout,childSpanTimeout,latestRoute);
}
if(enableInp){
registerInpInteractionListener();
}
instrumentOutgoingRequests(client,{
traceFetch,
traceXHR,
trackFetchStreamPerformance,
tracePropagationTargets: client.getOptions().tracePropagationTargets,
shouldCreateSpanForRequest,
enableHTTPTimings,
onRequestSpanStart,
});
},
};
})satisfiesIntegrationFn;
/**
* Manually start a page load span.
* This will only do something if a browser tracing integration integration has been setup.
*
* If you provide a custom `traceOptions` object, it will be used to continue the trace
* instead of the default behavior, which is to look it up on the <meta> tags.
*/
exportfunctionstartBrowserTracingPageLoadSpan(
client: Client,
spanOptions: StartSpanOptions,
traceOptions?: {sentryTrace?: string|undefined;baggage?: string|undefined},
): Span|undefined{
client.emit('startPageLoadSpan',spanOptions,traceOptions);
getCurrentScope().setTransactionName(spanOptions.name);
returngetActiveIdleSpan(client);
}
/**
* Manually start a navigation span.
* This will only do something if a browser tracing integration has been setup.
*/
exportfunctionstartBrowserTracingNavigationSpan(
client: Client,
spanOptions: StartSpanOptions,
options?: {url?: string;isRedirect?: boolean},
): Span|undefined{
const{ url, isRedirect }=options||{};
client.emit('startNavigationSpan',spanOptions,{ isRedirect });
constscope=getCurrentScope();
scope.setTransactionName(spanOptions.name);
// We store the normalized request data on the scope, so we get the request data at time of span creation
// otherwise, the URL etc. may already be of the following navigation, and we'd report the wrong URL
if(url&&!isRedirect){
scope.setSDKProcessingMetadata({
normalizedRequest: {
...getHttpRequestData(),
url,
},
});
}
returngetActiveIdleSpan(client);
}
/** Returns the value of a meta tag */
exportfunctiongetMetaContent(metaName: string): string|undefined{
/**
* This is just a small wrapper that makes `document` optional.
* We want to be extra-safe and always check that this exists, to ensure weird environments do not blow up.
*/
constoptionalWindowDocument=WINDOW.documentas(typeofWINDOW)['document']|undefined;
constmetaTag=optionalWindowDocument?.querySelector(`meta[name=${metaName}]`);
returnmetaTag?.getAttribute('content')||undefined;
}
/** Start listener for interaction transactions */
functionregisterInteractionListener(
client: Client,
idleTimeout: BrowserTracingOptions['idleTimeout'],
finalTimeout: BrowserTracingOptions['finalTimeout'],
childSpanTimeout: BrowserTracingOptions['childSpanTimeout'],
latestRoute: RouteInfo,
): void{
/**
* This is just a small wrapper that makes `document` optional.
* We want to be extra-safe and always check that this exists, to ensure weird environments do not blow up.
*/
constoptionalWindowDocument=WINDOW.documentas(typeofWINDOW)['document']|undefined;
letinflightInteractionSpan: Span|undefined;
constregisterInteractionTransaction=(): void=>{
constop='ui.action.click';
constactiveIdleSpan=getActiveIdleSpan(client);
if(activeIdleSpan){
constcurrentRootSpanOp=spanToJSON(activeIdleSpan).op;
if(['navigation','pageload'].includes(currentRootSpanOpasstring)){
DEBUG_BUILD&&
logger.warn(`[Tracing] Did not create ${op} span because a pageload or navigation span is in progress.`);
returnundefined;
}
}
if(inflightInteractionSpan){
inflightInteractionSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,'interactionInterrupted');
inflightInteractionSpan.end();
inflightInteractionSpan=undefined;
}
if(!latestRoute.name){
DEBUG_BUILD&&logger.warn(`[Tracing] Did not create ${op} transaction because _latestRouteName is missing.`);
returnundefined;
}
inflightInteractionSpan=startIdleSpan(
{
name: latestRoute.name,
op,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: latestRoute.source||'url',
},
},
{
idleTimeout,
finalTimeout,
childSpanTimeout,
},
);
};
if(optionalWindowDocument){
addEventListener('click',registerInteractionTransaction,{capture: true});
}
}
// We store the active idle span on the client object, so we can access it from exported functions
constACTIVE_IDLE_SPAN_PROPERTY='_sentry_idleSpan';
functiongetActiveIdleSpan(client: Client): Span|undefined{
return(clientas{[ACTIVE_IDLE_SPAN_PROPERTY]?: Span})[ACTIVE_IDLE_SPAN_PROPERTY];
}
functionsetActiveIdleSpan(client: Client,span: Span|undefined): void{
addNonEnumerableProperty(client,ACTIVE_IDLE_SPAN_PROPERTY,span);
}
// The max. time in seconds between two pageload/navigation spans that makes us consider the second one a redirect
constREDIRECT_THRESHOLD=0.3;
functionisRedirect(activeSpan: Span,lastInteractionTimestamp: number|undefined): boolean{
constspanData=spanToJSON(activeSpan);
constnow=dateTimestampInSeconds();
// More than 300ms since last navigation/pageload span?
// --> never consider this a redirect
conststartTimestamp=spanData.start_timestamp;
if(now-startTimestamp>REDIRECT_THRESHOLD){
returnfalse;
}
// A click happened in the last 300ms?
// --> never consider this a redirect
if(lastInteractionTimestamp&&now-lastInteractionTimestamp<=REDIRECT_THRESHOLD){
returnfalse;

Fix in CursorFix in Web


Bug: Browser Tracing Integration Event Listener Leak

The browserTracingIntegration introduces a memory leak by adding global click and keydown event listeners for redirect detection without ever removing them. This causes listeners to accumulate when the integration is reinitialized or multiple instances are created, such as in SPAs, hot module reloading, or test environments. A cleanup mechanism is required to prevent this accumulation.

packages/browser/src/tracing/browserTracingIntegration.ts#L467-L475

if(detectRedirects&&optionalWindowDocument){
constinteractionHandler=(): void=>{
lastInteractionTimestamp=timestampInSeconds();
};
addEventListener('click',interactionHandler,{capture: true});
addEventListener('keydown',interactionHandler,{capture: true,passive: true});
}

Fix in CursorFix in Web


Was this report helpful? Give feedback by reacting with 👍 or 👎

@mydea
mydea merged commit 3e5eac5 into developJul 10, 2025
@mydea
mydea deleted the fn/detect-pageload-redirects branch July 10, 2025 08:19
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Distinguish redirects from user-initiated nagivations

5 participants

@mydea@Lms24@s1gr1d@bricefriha@edwardgou-sentry
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(browser): Detect redirects when emitting navigation spans - #16324

Merged
mydea merged 6 commits into
developfrom
fn/detect-pageload-redirects
Jul 10, 2025
Merged

feat(browser): Detect redirects when emitting navigation spans#16324
mydea merged 6 commits into
developfrom
fn/detect-pageload-redirects

Conversation

@mydea

Copy link
Copy Markdown
Member

Closes#15286

This PR adds a new option to browserTracingIntegration, detectRedirects, which is enabled by default. If this is enabled, the integration will try to detect if a navigation is actually a redirect based on a simple heuristic, and in this case, will not end the ongoing pageload/navigation, but instead let it run and create a navigation.redirect zero-duration span instead.

An example trace for this would be: https://sentry-sdks.sentry.io/explore/discover/trace/95280de69dc844448d39de7458eab527/?dataset=transactions&eventId=8a1150fd1dc846e4ac8420ccf03ad0ee&field=title&field=project&field=user.display&field=timestamp&name=All%20Errors&project=4504956726345728&query=&queryDataset=transaction-like&sort=-timestamp&source=discover&statsPeriod=5m&timestamp=1747646096&yAxis=count%28%29
image

Where the respective index route that triggered this has this code:

setTimeout(()=>{window.history.pushState({},"","/test-sub-page");fetch('https://example.com')},100);

The used heuristic is:

  • If the ongoing pageload/navigation was started less than 300ms ago...
  • ... and no click has happened in this time...
  • ... then we consider the navigation a redirect

this limit was chosen somewhat arbitrarily, open for other suggestions too.

While this logic will not be 100% bullet proof, it should be reliable enough and likely better than what we have today. Users can opt-out of this logic via browserTracingIntegration({ detectRedirects: false }), if needed.

@mydea
mydea requested review from Lms24, bcoe and s1gr1dMay 19, 2025 09:21
@mydeamydea self-assigned this May 19, 2025
@github-actions

github-actionsBot commented May 19, 2025

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser23.99 kB--
@sentry/browser - with treeshaking flags23.76 kB--
@sentry/browser (incl. Tracing)39.85 kB+0.6%+235 B 🔺
@sentry/browser (incl. Tracing, Replay)78.06 kB+0.31%+238 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags71.09 kB+0.27%+187 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)82.77 kB+0.28%+225 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)94.99 kB+0.3%+277 B 🔺
@sentry/browser (incl. Feedback)40.76 kB--
@sentry/browser (incl. sendFeedback)28.7 kB--
@sentry/browser (incl. FeedbackAsync)33.59 kB--
@sentry/react25.76 kB--
@sentry/react (incl. Tracing)41.85 kB+0.58%+239 B 🔺
@sentry/vue28.37 kB--
@sentry/vue (incl. Tracing)41.66 kB+0.6%+246 B 🔺
@sentry/svelte24.01 kB--
CDN Bundle25.5 kB--
CDN Bundle (incl. Tracing)39.82 kB+0.48%+187 B 🔺
CDN Bundle (incl. Tracing, Replay)75.8 kB+0.25%+187 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)81.27 kB+0.24%+193 B 🔺
CDN Bundle - uncompressed74.5 kB--
CDN Bundle (incl. Tracing) - uncompressed118.25 kB+0.41%+481 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed232.55 kB+0.21%+481 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed245.38 kB+0.2%+481 B 🔺
@sentry/nextjs (client)43.48 kB+0.52%+222 B 🔺
@sentry/sveltekit (client)40.32 kB+0.59%+235 B 🔺
@sentry/node161.84 kB--
@sentry/node - without tracing98.79 kB--
@sentry/aws-serverless124.61 kB--

View base workflow run

@codecov

codecovBot commented May 19, 2025

Copy link
Copy Markdown

❌ Unsupported file format

Upload processing failed due to unsupported file format. Please review the parser error message:

Error parsing JUnit XML in /home/runner/work/sentry-javascript/sentry-javascript/packages/solidstart/vitest.junit.xml at 18:17
Caused by:
RuntimeError: Error parsing XML
Caused by:
0: ill-formed document: expected `</testsuites>`, but `</testsuite>` was found
1: expected `</testsuites>`, but `</testsuite>` was found

For more help, visit our troubleshooting guide.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch 2 times, most recently from ccbd697 to eb3c0bcCompareMay 23, 2025 07:22
@mydea
mydea marked this pull request as ready for review May 23, 2025 07:22
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts Outdated
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from eb3c0bc to cb8e92eCompareMay 26, 2025 11:22

@edwardgou-sentryedwardgou-sentry left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me! There aren't many product areas in performance that specifically rely on navigations so I think this should be fine (and I think we'd consider surfacing redirects in those areas a bug anyways).

}

if (detectRedirects && optionalWindowDocument) {
addEventListener('click', () => (lastClickTimestamp = timestampInSeconds()), { capture: true, passive: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are there other events, such as key presses, that could indicate a user manually navigating?

@Lms24Lms24Jun 16, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, keypress might also be a good candidate, agreed.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 also looking at keypress

@Lms24Lms24 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the late review, but LGTM! I think we probably need to widen the timespan a bit because 300ms feel a bit fast to me (thinking of the endless redirects I get when doing SSO or stuff like this). But maybe it's good enough for now. I'd say its something we adjust on a per-feedback basis.

}

if (detectRedirects && optionalWindowDocument) {
addEventListener('click', () => (lastClickTimestamp = timestampInSeconds()), { capture: true, passive: true });

@Lms24Lms24Jun 16, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, keypress might also be a good candidate, agreed.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 19d02d3 to 67791e9CompareJune 17, 2025 10:27
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 67791e9 to e2018b5CompareJune 18, 2025 07:52
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from e2018b5 to 9dec9c3CompareJuly 7, 2025 14:42
cursor[bot]

This comment was marked as outdated.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 9dec9c3 to da0cffeCompareJuly 10, 2025 07:12

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Navigation URL Metadata Update Fails

The scope.setSDKProcessingMetadata is not updated for navigation spans if the URL is falsy or if the navigation is detected as a redirect. This prevents subsequent events from having the correct URL information on the scope. Additionally, the redirect detection logic uses inconsistent timestamp functions (timestampInSeconds vs dateTimestampInSeconds), which can lead to inaccurate timing comparisons.

packages/browser/src/tracing/browserTracingIntegration.ts#L469-L780

constinteractionHandler=(): void=>{
lastInteractionTimestamp=timestampInSeconds();
};
addEventListener('click',interactionHandler,{capture: true});
addEventListener('keydown',interactionHandler,{capture: true,passive: true});
}
functionmaybeEndActiveSpan(): void{
constactiveSpan=getActiveIdleSpan(client);
if(activeSpan&&!spanToJSON(activeSpan).timestamp){
DEBUG_BUILD&&logger.log(`[Tracing] Finishing current active span with op: ${spanToJSON(activeSpan).op}`);
// If there's an open active span, we need to finish it before creating an new one.
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,'cancelled');
activeSpan.end();
}
}
client.on('startNavigationSpan',(startSpanOptions,navigationOptions)=>{
if(getClient()!==client){
return;
}
if(navigationOptions?.isRedirect){
DEBUG_BUILD&&
logger.warn('[Tracing] Detected redirect, navigation span will not be the root span, but a child span.');
_createRouteSpan(
client,
{
op: 'navigation.redirect',
...startSpanOptions,
},
false,
);
return;
}
maybeEndActiveSpan();
getIsolationScope().setPropagationContext({traceId: generateTraceId(),sampleRand: Math.random()});
constscope=getCurrentScope();
scope.setPropagationContext({traceId: generateTraceId(),sampleRand: Math.random()});
// We reset this to ensure we do not have lingering incorrect data here
// places that call this hook may set this where appropriate - else, the URL at span sending time is used
scope.setSDKProcessingMetadata({
normalizedRequest: undefined,
});
_createRouteSpan(client,{
op: 'navigation',
...startSpanOptions,
});
});
client.on('startPageLoadSpan',(startSpanOptions,traceOptions={})=>{
if(getClient()!==client){
return;
}
maybeEndActiveSpan();
constsentryTrace=traceOptions.sentryTrace||getMetaContent('sentry-trace');
constbaggage=traceOptions.baggage||getMetaContent('baggage');
constpropagationContext=propagationContextFromHeaders(sentryTrace,baggage);
constscope=getCurrentScope();
scope.setPropagationContext(propagationContext);
// We store the normalized request data on the scope, so we get the request data at time of span creation
// otherwise, the URL etc. may already be of the following navigation, and we'd report the wrong URL
scope.setSDKProcessingMetadata({
normalizedRequest: getHttpRequestData(),
});
_createRouteSpan(client,{
op: 'pageload',
...startSpanOptions,
});
});
},
afterAllSetup(client){
letstartingUrl: string|undefined=getLocationHref();
if(linkPreviousTrace!=='off'){
linkTraces(client,{ linkPreviousTrace, consistentTraceSampling });
}
if(WINDOW.location){
if(instrumentPageLoad){
constorigin=browserPerformanceTimeOrigin();
startBrowserTracingPageLoadSpan(client,{
name: WINDOW.location.pathname,
// pageload should always start at timeOrigin (and needs to be in s, not ms)
startTime: origin ? origin/1000 : undefined,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser',
},
});
}
if(instrumentNavigation){
addHistoryInstrumentationHandler(({ to, from })=>{
/**
* This early return is there to account for some cases where a navigation transaction starts right after
* long-running pageload. We make sure that if `from` is undefined and a valid `startingURL` exists, we don't
* create an uneccessary navigation transaction.
*
* This was hard to duplicate, but this behavior stopped as soon as this fix was applied. This issue might also
* only be caused in certain development environments where the usage of a hot module reloader is causing
* errors.
*/
if(from===undefined&&startingUrl?.indexOf(to)!==-1){
startingUrl=undefined;
return;
}
startingUrl=undefined;
constparsed=parseStringToURLObject(to);
constactiveSpan=getActiveIdleSpan(client);
constnavigationIsRedirect=
activeSpan&&detectRedirects&&isRedirect(activeSpan,lastInteractionTimestamp);
startBrowserTracingNavigationSpan(
client,
{
name: parsed?.pathname||WINDOW.location.pathname,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser',
},
},
{url: to,isRedirect: navigationIsRedirect},
);
});
}
}
if(markBackgroundSpan){
registerBackgroundTabDetection();
}
if(enableInteractions){
registerInteractionListener(client,idleTimeout,finalTimeout,childSpanTimeout,latestRoute);
}
if(enableInp){
registerInpInteractionListener();
}
instrumentOutgoingRequests(client,{
traceFetch,
traceXHR,
trackFetchStreamPerformance,
tracePropagationTargets: client.getOptions().tracePropagationTargets,
shouldCreateSpanForRequest,
enableHTTPTimings,
onRequestSpanStart,
});
},
};
})satisfiesIntegrationFn;
/**
* Manually start a page load span.
* This will only do something if a browser tracing integration integration has been setup.
*
* If you provide a custom `traceOptions` object, it will be used to continue the trace
* instead of the default behavior, which is to look it up on the <meta> tags.
*/
exportfunctionstartBrowserTracingPageLoadSpan(
client: Client,
spanOptions: StartSpanOptions,
traceOptions?: {sentryTrace?: string|undefined;baggage?: string|undefined},
): Span|undefined{
client.emit('startPageLoadSpan',spanOptions,traceOptions);
getCurrentScope().setTransactionName(spanOptions.name);
returngetActiveIdleSpan(client);
}
/**
* Manually start a navigation span.
* This will only do something if a browser tracing integration has been setup.
*/
exportfunctionstartBrowserTracingNavigationSpan(
client: Client,
spanOptions: StartSpanOptions,
options?: {url?: string;isRedirect?: boolean},
): Span|undefined{
const{ url, isRedirect }=options||{};
client.emit('startNavigationSpan',spanOptions,{ isRedirect });
constscope=getCurrentScope();
scope.setTransactionName(spanOptions.name);
// We store the normalized request data on the scope, so we get the request data at time of span creation
// otherwise, the URL etc. may already be of the following navigation, and we'd report the wrong URL
if(url&&!isRedirect){
scope.setSDKProcessingMetadata({
normalizedRequest: {
...getHttpRequestData(),
url,
},
});
}
returngetActiveIdleSpan(client);
}
/** Returns the value of a meta tag */
exportfunctiongetMetaContent(metaName: string): string|undefined{
/**
* This is just a small wrapper that makes `document` optional.
* We want to be extra-safe and always check that this exists, to ensure weird environments do not blow up.
*/
constoptionalWindowDocument=WINDOW.documentas(typeofWINDOW)['document']|undefined;
constmetaTag=optionalWindowDocument?.querySelector(`meta[name=${metaName}]`);
returnmetaTag?.getAttribute('content')||undefined;
}
/** Start listener for interaction transactions */
functionregisterInteractionListener(
client: Client,
idleTimeout: BrowserTracingOptions['idleTimeout'],
finalTimeout: BrowserTracingOptions['finalTimeout'],
childSpanTimeout: BrowserTracingOptions['childSpanTimeout'],
latestRoute: RouteInfo,
): void{
/**
* This is just a small wrapper that makes `document` optional.
* We want to be extra-safe and always check that this exists, to ensure weird environments do not blow up.
*/
constoptionalWindowDocument=WINDOW.documentas(typeofWINDOW)['document']|undefined;
letinflightInteractionSpan: Span|undefined;
constregisterInteractionTransaction=(): void=>{
constop='ui.action.click';
constactiveIdleSpan=getActiveIdleSpan(client);
if(activeIdleSpan){
constcurrentRootSpanOp=spanToJSON(activeIdleSpan).op;
if(['navigation','pageload'].includes(currentRootSpanOpasstring)){
DEBUG_BUILD&&
logger.warn(`[Tracing] Did not create ${op} span because a pageload or navigation span is in progress.`);
returnundefined;
}
}
if(inflightInteractionSpan){
inflightInteractionSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,'interactionInterrupted');
inflightInteractionSpan.end();
inflightInteractionSpan=undefined;
}
if(!latestRoute.name){
DEBUG_BUILD&&logger.warn(`[Tracing] Did not create ${op} transaction because _latestRouteName is missing.`);
returnundefined;
}
inflightInteractionSpan=startIdleSpan(
{
name: latestRoute.name,
op,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: latestRoute.source||'url',
},
},
{
idleTimeout,
finalTimeout,
childSpanTimeout,
},
);
};
if(optionalWindowDocument){
addEventListener('click',registerInteractionTransaction,{capture: true});
}
}
// We store the active idle span on the client object, so we can access it from exported functions
constACTIVE_IDLE_SPAN_PROPERTY='_sentry_idleSpan';
functiongetActiveIdleSpan(client: Client): Span|undefined{
return(clientas{[ACTIVE_IDLE_SPAN_PROPERTY]?: Span})[ACTIVE_IDLE_SPAN_PROPERTY];
}
functionsetActiveIdleSpan(client: Client,span: Span|undefined): void{
addNonEnumerableProperty(client,ACTIVE_IDLE_SPAN_PROPERTY,span);
}
// The max. time in seconds between two pageload/navigation spans that makes us consider the second one a redirect
constREDIRECT_THRESHOLD=0.3;
functionisRedirect(activeSpan: Span,lastInteractionTimestamp: number|undefined): boolean{
constspanData=spanToJSON(activeSpan);
constnow=dateTimestampInSeconds();
// More than 300ms since last navigation/pageload span?
// --> never consider this a redirect
conststartTimestamp=spanData.start_timestamp;
if(now-startTimestamp>REDIRECT_THRESHOLD){
returnfalse;
}
// A click happened in the last 300ms?
// --> never consider this a redirect
if(lastInteractionTimestamp&&now-lastInteractionTimestamp<=REDIRECT_THRESHOLD){
returnfalse;

Fix in CursorFix in Web


Bug: Browser Tracing Integration Event Listener Leak

The browserTracingIntegration introduces a memory leak by adding global click and keydown event listeners for redirect detection without ever removing them. This causes listeners to accumulate when the integration is reinitialized or multiple instances are created, such as in SPAs, hot module reloading, or test environments. A cleanup mechanism is required to prevent this accumulation.

packages/browser/src/tracing/browserTracingIntegration.ts#L467-L475

if(detectRedirects&&optionalWindowDocument){
constinteractionHandler=(): void=>{
lastInteractionTimestamp=timestampInSeconds();
};
addEventListener('click',interactionHandler,{capture: true});
addEventListener('keydown',interactionHandler,{capture: true,passive: true});
}

Fix in CursorFix in Web


Was this report helpful? Give feedback by reacting with 👍 or 👎

@mydea
mydea merged commit 3e5eac5 into developJul 10, 2025
@mydea
mydea deleted the fn/detect-pageload-redirects branch July 10, 2025 08:19
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Distinguish redirects from user-initiated nagivations

5 participants

@mydea@Lms24@s1gr1d@bricefriha@edwardgou-sentry
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(browser): Detect redirects when emitting navigation spans - #16324

Merged
mydea merged 6 commits into
developfrom
fn/detect-pageload-redirects
Jul 10, 2025
Merged

feat(browser): Detect redirects when emitting navigation spans#16324
mydea merged 6 commits into
developfrom
fn/detect-pageload-redirects

Conversation

@mydea

Copy link
Copy Markdown
Member

Closes#15286

This PR adds a new option to browserTracingIntegration, detectRedirects, which is enabled by default. If this is enabled, the integration will try to detect if a navigation is actually a redirect based on a simple heuristic, and in this case, will not end the ongoing pageload/navigation, but instead let it run and create a navigation.redirect zero-duration span instead.

An example trace for this would be: https://sentry-sdks.sentry.io/explore/discover/trace/95280de69dc844448d39de7458eab527/?dataset=transactions&eventId=8a1150fd1dc846e4ac8420ccf03ad0ee&field=title&field=project&field=user.display&field=timestamp&name=All%20Errors&project=4504956726345728&query=&queryDataset=transaction-like&sort=-timestamp&source=discover&statsPeriod=5m&timestamp=1747646096&yAxis=count%28%29
image

Where the respective index route that triggered this has this code:

setTimeout(()=>{window.history.pushState({},"","/test-sub-page");fetch('https://example.com')},100);

The used heuristic is:

  • If the ongoing pageload/navigation was started less than 300ms ago...
  • ... and no click has happened in this time...
  • ... then we consider the navigation a redirect

this limit was chosen somewhat arbitrarily, open for other suggestions too.

While this logic will not be 100% bullet proof, it should be reliable enough and likely better than what we have today. Users can opt-out of this logic via browserTracingIntegration({ detectRedirects: false }), if needed.

@mydea
mydea requested review from Lms24, bcoe and s1gr1dMay 19, 2025 09:21
@mydeamydea self-assigned this May 19, 2025
@github-actions

github-actionsBot commented May 19, 2025

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser23.99 kB--
@sentry/browser - with treeshaking flags23.76 kB--
@sentry/browser (incl. Tracing)39.85 kB+0.6%+235 B 🔺
@sentry/browser (incl. Tracing, Replay)78.06 kB+0.31%+238 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags71.09 kB+0.27%+187 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)82.77 kB+0.28%+225 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)94.99 kB+0.3%+277 B 🔺
@sentry/browser (incl. Feedback)40.76 kB--
@sentry/browser (incl. sendFeedback)28.7 kB--
@sentry/browser (incl. FeedbackAsync)33.59 kB--
@sentry/react25.76 kB--
@sentry/react (incl. Tracing)41.85 kB+0.58%+239 B 🔺
@sentry/vue28.37 kB--
@sentry/vue (incl. Tracing)41.66 kB+0.6%+246 B 🔺
@sentry/svelte24.01 kB--
CDN Bundle25.5 kB--
CDN Bundle (incl. Tracing)39.82 kB+0.48%+187 B 🔺
CDN Bundle (incl. Tracing, Replay)75.8 kB+0.25%+187 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)81.27 kB+0.24%+193 B 🔺
CDN Bundle - uncompressed74.5 kB--
CDN Bundle (incl. Tracing) - uncompressed118.25 kB+0.41%+481 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed232.55 kB+0.21%+481 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed245.38 kB+0.2%+481 B 🔺
@sentry/nextjs (client)43.48 kB+0.52%+222 B 🔺
@sentry/sveltekit (client)40.32 kB+0.59%+235 B 🔺
@sentry/node161.84 kB--
@sentry/node - without tracing98.79 kB--
@sentry/aws-serverless124.61 kB--

View base workflow run

@codecov

codecovBot commented May 19, 2025

Copy link
Copy Markdown

❌ Unsupported file format

Upload processing failed due to unsupported file format. Please review the parser error message:

Error parsing JUnit XML in /home/runner/work/sentry-javascript/sentry-javascript/packages/solidstart/vitest.junit.xml at 18:17
Caused by:
RuntimeError: Error parsing XML
Caused by:
0: ill-formed document: expected `</testsuites>`, but `</testsuite>` was found
1: expected `</testsuites>`, but `</testsuite>` was found

For more help, visit our troubleshooting guide.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch 2 times, most recently from ccbd697 to eb3c0bcCompareMay 23, 2025 07:22
@mydea
mydea marked this pull request as ready for review May 23, 2025 07:22
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts Outdated
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from eb3c0bc to cb8e92eCompareMay 26, 2025 11:22

@edwardgou-sentryedwardgou-sentry left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me! There aren't many product areas in performance that specifically rely on navigations so I think this should be fine (and I think we'd consider surfacing redirects in those areas a bug anyways).

}

if (detectRedirects && optionalWindowDocument) {
addEventListener('click', () => (lastClickTimestamp = timestampInSeconds()), { capture: true, passive: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are there other events, such as key presses, that could indicate a user manually navigating?

@Lms24Lms24Jun 16, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, keypress might also be a good candidate, agreed.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 also looking at keypress

@Lms24Lms24 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the late review, but LGTM! I think we probably need to widen the timespan a bit because 300ms feel a bit fast to me (thinking of the endless redirects I get when doing SSO or stuff like this). But maybe it's good enough for now. I'd say its something we adjust on a per-feedback basis.

}

if (detectRedirects && optionalWindowDocument) {
addEventListener('click', () => (lastClickTimestamp = timestampInSeconds()), { capture: true, passive: true });

@Lms24Lms24Jun 16, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, keypress might also be a good candidate, agreed.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 19d02d3 to 67791e9CompareJune 17, 2025 10:27
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 67791e9 to e2018b5CompareJune 18, 2025 07:52
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from e2018b5 to 9dec9c3CompareJuly 7, 2025 14:42
cursor[bot]

This comment was marked as outdated.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 9dec9c3 to da0cffeCompareJuly 10, 2025 07:12

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Navigation URL Metadata Update Fails

The scope.setSDKProcessingMetadata is not updated for navigation spans if the URL is falsy or if the navigation is detected as a redirect. This prevents subsequent events from having the correct URL information on the scope. Additionally, the redirect detection logic uses inconsistent timestamp functions (timestampInSeconds vs dateTimestampInSeconds), which can lead to inaccurate timing comparisons.

packages/browser/src/tracing/browserTracingIntegration.ts#L469-L780

constinteractionHandler=(): void=>{
lastInteractionTimestamp=timestampInSeconds();
};
addEventListener('click',interactionHandler,{capture: true});
addEventListener('keydown',interactionHandler,{capture: true,passive: true});
}
functionmaybeEndActiveSpan(): void{
constactiveSpan=getActiveIdleSpan(client);
if(activeSpan&&!spanToJSON(activeSpan).timestamp){
DEBUG_BUILD&&logger.log(`[Tracing] Finishing current active span with op: ${spanToJSON(activeSpan).op}`);
// If there's an open active span, we need to finish it before creating an new one.
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,'cancelled');
activeSpan.end();
}
}
client.on('startNavigationSpan',(startSpanOptions,navigationOptions)=>{
if(getClient()!==client){
return;
}
if(navigationOptions?.isRedirect){
DEBUG_BUILD&&
logger.warn('[Tracing] Detected redirect, navigation span will not be the root span, but a child span.');
_createRouteSpan(
client,
{
op: 'navigation.redirect',
...startSpanOptions,
},
false,
);
return;
}
maybeEndActiveSpan();
getIsolationScope().setPropagationContext({traceId: generateTraceId(),sampleRand: Math.random()});
constscope=getCurrentScope();
scope.setPropagationContext({traceId: generateTraceId(),sampleRand: Math.random()});
// We reset this to ensure we do not have lingering incorrect data here
// places that call this hook may set this where appropriate - else, the URL at span sending time is used
scope.setSDKProcessingMetadata({
normalizedRequest: undefined,
});
_createRouteSpan(client,{
op: 'navigation',
...startSpanOptions,
});
});
client.on('startPageLoadSpan',(startSpanOptions,traceOptions={})=>{
if(getClient()!==client){
return;
}
maybeEndActiveSpan();
constsentryTrace=traceOptions.sentryTrace||getMetaContent('sentry-trace');
constbaggage=traceOptions.baggage||getMetaContent('baggage');
constpropagationContext=propagationContextFromHeaders(sentryTrace,baggage);
constscope=getCurrentScope();
scope.setPropagationContext(propagationContext);
// We store the normalized request data on the scope, so we get the request data at time of span creation
// otherwise, the URL etc. may already be of the following navigation, and we'd report the wrong URL
scope.setSDKProcessingMetadata({
normalizedRequest: getHttpRequestData(),
});
_createRouteSpan(client,{
op: 'pageload',
...startSpanOptions,
});
});
},
afterAllSetup(client){
letstartingUrl: string|undefined=getLocationHref();
if(linkPreviousTrace!=='off'){
linkTraces(client,{ linkPreviousTrace, consistentTraceSampling });
}
if(WINDOW.location){
if(instrumentPageLoad){
constorigin=browserPerformanceTimeOrigin();
startBrowserTracingPageLoadSpan(client,{
name: WINDOW.location.pathname,
// pageload should always start at timeOrigin (and needs to be in s, not ms)
startTime: origin ? origin/1000 : undefined,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser',
},
});
}
if(instrumentNavigation){
addHistoryInstrumentationHandler(({ to, from })=>{
/**
* This early return is there to account for some cases where a navigation transaction starts right after
* long-running pageload. We make sure that if `from` is undefined and a valid `startingURL` exists, we don't
* create an uneccessary navigation transaction.
*
* This was hard to duplicate, but this behavior stopped as soon as this fix was applied. This issue might also
* only be caused in certain development environments where the usage of a hot module reloader is causing
* errors.
*/
if(from===undefined&&startingUrl?.indexOf(to)!==-1){
startingUrl=undefined;
return;
}
startingUrl=undefined;
constparsed=parseStringToURLObject(to);
constactiveSpan=getActiveIdleSpan(client);
constnavigationIsRedirect=
activeSpan&&detectRedirects&&isRedirect(activeSpan,lastInteractionTimestamp);
startBrowserTracingNavigationSpan(
client,
{
name: parsed?.pathname||WINDOW.location.pathname,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser',
},
},
{url: to,isRedirect: navigationIsRedirect},
);
});
}
}
if(markBackgroundSpan){
registerBackgroundTabDetection();
}
if(enableInteractions){
registerInteractionListener(client,idleTimeout,finalTimeout,childSpanTimeout,latestRoute);
}
if(enableInp){
registerInpInteractionListener();
}
instrumentOutgoingRequests(client,{
traceFetch,
traceXHR,
trackFetchStreamPerformance,
tracePropagationTargets: client.getOptions().tracePropagationTargets,
shouldCreateSpanForRequest,
enableHTTPTimings,
onRequestSpanStart,
});
},
};
})satisfiesIntegrationFn;
/**
* Manually start a page load span.
* This will only do something if a browser tracing integration integration has been setup.
*
* If you provide a custom `traceOptions` object, it will be used to continue the trace
* instead of the default behavior, which is to look it up on the <meta> tags.
*/
exportfunctionstartBrowserTracingPageLoadSpan(
client: Client,
spanOptions: StartSpanOptions,
traceOptions?: {sentryTrace?: string|undefined;baggage?: string|undefined},
): Span|undefined{
client.emit('startPageLoadSpan',spanOptions,traceOptions);
getCurrentScope().setTransactionName(spanOptions.name);
returngetActiveIdleSpan(client);
}
/**
* Manually start a navigation span.
* This will only do something if a browser tracing integration has been setup.
*/
exportfunctionstartBrowserTracingNavigationSpan(
client: Client,
spanOptions: StartSpanOptions,
options?: {url?: string;isRedirect?: boolean},
): Span|undefined{
const{ url, isRedirect }=options||{};
client.emit('startNavigationSpan',spanOptions,{ isRedirect });
constscope=getCurrentScope();
scope.setTransactionName(spanOptions.name);
// We store the normalized request data on the scope, so we get the request data at time of span creation
// otherwise, the URL etc. may already be of the following navigation, and we'd report the wrong URL
if(url&&!isRedirect){
scope.setSDKProcessingMetadata({
normalizedRequest: {
...getHttpRequestData(),
url,
},
});
}
returngetActiveIdleSpan(client);
}
/** Returns the value of a meta tag */
exportfunctiongetMetaContent(metaName: string): string|undefined{
/**
* This is just a small wrapper that makes `document` optional.
* We want to be extra-safe and always check that this exists, to ensure weird environments do not blow up.
*/
constoptionalWindowDocument=WINDOW.documentas(typeofWINDOW)['document']|undefined;
constmetaTag=optionalWindowDocument?.querySelector(`meta[name=${metaName}]`);
returnmetaTag?.getAttribute('content')||undefined;
}
/** Start listener for interaction transactions */
functionregisterInteractionListener(
client: Client,
idleTimeout: BrowserTracingOptions['idleTimeout'],
finalTimeout: BrowserTracingOptions['finalTimeout'],
childSpanTimeout: BrowserTracingOptions['childSpanTimeout'],
latestRoute: RouteInfo,
): void{
/**
* This is just a small wrapper that makes `document` optional.
* We want to be extra-safe and always check that this exists, to ensure weird environments do not blow up.
*/
constoptionalWindowDocument=WINDOW.documentas(typeofWINDOW)['document']|undefined;
letinflightInteractionSpan: Span|undefined;
constregisterInteractionTransaction=(): void=>{
constop='ui.action.click';
constactiveIdleSpan=getActiveIdleSpan(client);
if(activeIdleSpan){
constcurrentRootSpanOp=spanToJSON(activeIdleSpan).op;
if(['navigation','pageload'].includes(currentRootSpanOpasstring)){
DEBUG_BUILD&&
logger.warn(`[Tracing] Did not create ${op} span because a pageload or navigation span is in progress.`);
returnundefined;
}
}
if(inflightInteractionSpan){
inflightInteractionSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,'interactionInterrupted');
inflightInteractionSpan.end();
inflightInteractionSpan=undefined;
}
if(!latestRoute.name){
DEBUG_BUILD&&logger.warn(`[Tracing] Did not create ${op} transaction because _latestRouteName is missing.`);
returnundefined;
}
inflightInteractionSpan=startIdleSpan(
{
name: latestRoute.name,
op,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: latestRoute.source||'url',
},
},
{
idleTimeout,
finalTimeout,
childSpanTimeout,
},
);
};
if(optionalWindowDocument){
addEventListener('click',registerInteractionTransaction,{capture: true});
}
}
// We store the active idle span on the client object, so we can access it from exported functions
constACTIVE_IDLE_SPAN_PROPERTY='_sentry_idleSpan';
functiongetActiveIdleSpan(client: Client): Span|undefined{
return(clientas{[ACTIVE_IDLE_SPAN_PROPERTY]?: Span})[ACTIVE_IDLE_SPAN_PROPERTY];
}
functionsetActiveIdleSpan(client: Client,span: Span|undefined): void{
addNonEnumerableProperty(client,ACTIVE_IDLE_SPAN_PROPERTY,span);
}
// The max. time in seconds between two pageload/navigation spans that makes us consider the second one a redirect
constREDIRECT_THRESHOLD=0.3;
functionisRedirect(activeSpan: Span,lastInteractionTimestamp: number|undefined): boolean{
constspanData=spanToJSON(activeSpan);
constnow=dateTimestampInSeconds();
// More than 300ms since last navigation/pageload span?
// --> never consider this a redirect
conststartTimestamp=spanData.start_timestamp;
if(now-startTimestamp>REDIRECT_THRESHOLD){
returnfalse;
}
// A click happened in the last 300ms?
// --> never consider this a redirect
if(lastInteractionTimestamp&&now-lastInteractionTimestamp<=REDIRECT_THRESHOLD){
returnfalse;

Fix in CursorFix in Web


Bug: Browser Tracing Integration Event Listener Leak

The browserTracingIntegration introduces a memory leak by adding global click and keydown event listeners for redirect detection without ever removing them. This causes listeners to accumulate when the integration is reinitialized or multiple instances are created, such as in SPAs, hot module reloading, or test environments. A cleanup mechanism is required to prevent this accumulation.

packages/browser/src/tracing/browserTracingIntegration.ts#L467-L475

if(detectRedirects&&optionalWindowDocument){
constinteractionHandler=(): void=>{
lastInteractionTimestamp=timestampInSeconds();
};
addEventListener('click',interactionHandler,{capture: true});
addEventListener('keydown',interactionHandler,{capture: true,passive: true});
}

Fix in CursorFix in Web


Was this report helpful? Give feedback by reacting with 👍 or 👎

@mydea
mydea merged commit 3e5eac5 into developJul 10, 2025
@mydea
mydea deleted the fn/detect-pageload-redirects branch July 10, 2025 08:19
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Distinguish redirects from user-initiated nagivations

5 participants

@mydea@Lms24@s1gr1d@bricefriha@edwardgou-sentry
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(browser): Detect redirects when emitting navigation spans - #16324

Merged
mydea merged 6 commits into
developfrom
fn/detect-pageload-redirects
Jul 10, 2025
Merged

feat(browser): Detect redirects when emitting navigation spans#16324
mydea merged 6 commits into
developfrom
fn/detect-pageload-redirects

Conversation

@mydea

Copy link
Copy Markdown
Member

Closes#15286

This PR adds a new option to browserTracingIntegration, detectRedirects, which is enabled by default. If this is enabled, the integration will try to detect if a navigation is actually a redirect based on a simple heuristic, and in this case, will not end the ongoing pageload/navigation, but instead let it run and create a navigation.redirect zero-duration span instead.

An example trace for this would be: https://sentry-sdks.sentry.io/explore/discover/trace/95280de69dc844448d39de7458eab527/?dataset=transactions&eventId=8a1150fd1dc846e4ac8420ccf03ad0ee&field=title&field=project&field=user.display&field=timestamp&name=All%20Errors&project=4504956726345728&query=&queryDataset=transaction-like&sort=-timestamp&source=discover&statsPeriod=5m&timestamp=1747646096&yAxis=count%28%29
image

Where the respective index route that triggered this has this code:

setTimeout(()=>{window.history.pushState({},"","/test-sub-page");fetch('https://example.com')},100);

The used heuristic is:

  • If the ongoing pageload/navigation was started less than 300ms ago...
  • ... and no click has happened in this time...
  • ... then we consider the navigation a redirect

this limit was chosen somewhat arbitrarily, open for other suggestions too.

While this logic will not be 100% bullet proof, it should be reliable enough and likely better than what we have today. Users can opt-out of this logic via browserTracingIntegration({ detectRedirects: false }), if needed.

@mydea
mydea requested review from Lms24, bcoe and s1gr1dMay 19, 2025 09:21
@mydeamydea self-assigned this May 19, 2025
@github-actions

github-actionsBot commented May 19, 2025

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser23.99 kB--
@sentry/browser - with treeshaking flags23.76 kB--
@sentry/browser (incl. Tracing)39.85 kB+0.6%+235 B 🔺
@sentry/browser (incl. Tracing, Replay)78.06 kB+0.31%+238 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags71.09 kB+0.27%+187 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)82.77 kB+0.28%+225 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)94.99 kB+0.3%+277 B 🔺
@sentry/browser (incl. Feedback)40.76 kB--
@sentry/browser (incl. sendFeedback)28.7 kB--
@sentry/browser (incl. FeedbackAsync)33.59 kB--
@sentry/react25.76 kB--
@sentry/react (incl. Tracing)41.85 kB+0.58%+239 B 🔺
@sentry/vue28.37 kB--
@sentry/vue (incl. Tracing)41.66 kB+0.6%+246 B 🔺
@sentry/svelte24.01 kB--
CDN Bundle25.5 kB--
CDN Bundle (incl. Tracing)39.82 kB+0.48%+187 B 🔺
CDN Bundle (incl. Tracing, Replay)75.8 kB+0.25%+187 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)81.27 kB+0.24%+193 B 🔺
CDN Bundle - uncompressed74.5 kB--
CDN Bundle (incl. Tracing) - uncompressed118.25 kB+0.41%+481 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed232.55 kB+0.21%+481 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed245.38 kB+0.2%+481 B 🔺
@sentry/nextjs (client)43.48 kB+0.52%+222 B 🔺
@sentry/sveltekit (client)40.32 kB+0.59%+235 B 🔺
@sentry/node161.84 kB--
@sentry/node - without tracing98.79 kB--
@sentry/aws-serverless124.61 kB--

View base workflow run

@codecov

codecovBot commented May 19, 2025

Copy link
Copy Markdown

❌ Unsupported file format

Upload processing failed due to unsupported file format. Please review the parser error message:

Error parsing JUnit XML in /home/runner/work/sentry-javascript/sentry-javascript/packages/solidstart/vitest.junit.xml at 18:17
Caused by:
RuntimeError: Error parsing XML
Caused by:
0: ill-formed document: expected `</testsuites>`, but `</testsuite>` was found
1: expected `</testsuites>`, but `</testsuite>` was found

For more help, visit our troubleshooting guide.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch 2 times, most recently from ccbd697 to eb3c0bcCompareMay 23, 2025 07:22
@mydea
mydea marked this pull request as ready for review May 23, 2025 07:22
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts Outdated
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from eb3c0bc to cb8e92eCompareMay 26, 2025 11:22

@edwardgou-sentryedwardgou-sentry left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me! There aren't many product areas in performance that specifically rely on navigations so I think this should be fine (and I think we'd consider surfacing redirects in those areas a bug anyways).

}

if (detectRedirects && optionalWindowDocument) {
addEventListener('click', () => (lastClickTimestamp = timestampInSeconds()), { capture: true, passive: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are there other events, such as key presses, that could indicate a user manually navigating?

@Lms24Lms24Jun 16, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, keypress might also be a good candidate, agreed.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 also looking at keypress

@Lms24Lms24 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the late review, but LGTM! I think we probably need to widen the timespan a bit because 300ms feel a bit fast to me (thinking of the endless redirects I get when doing SSO or stuff like this). But maybe it's good enough for now. I'd say its something we adjust on a per-feedback basis.

}

if (detectRedirects && optionalWindowDocument) {
addEventListener('click', () => (lastClickTimestamp = timestampInSeconds()), { capture: true, passive: true });

@Lms24Lms24Jun 16, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, keypress might also be a good candidate, agreed.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 19d02d3 to 67791e9CompareJune 17, 2025 10:27
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 67791e9 to e2018b5CompareJune 18, 2025 07:52
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from e2018b5 to 9dec9c3CompareJuly 7, 2025 14:42
cursor[bot]

This comment was marked as outdated.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 9dec9c3 to da0cffeCompareJuly 10, 2025 07:12

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Navigation URL Metadata Update Fails

The scope.setSDKProcessingMetadata is not updated for navigation spans if the URL is falsy or if the navigation is detected as a redirect. This prevents subsequent events from having the correct URL information on the scope. Additionally, the redirect detection logic uses inconsistent timestamp functions (timestampInSeconds vs dateTimestampInSeconds), which can lead to inaccurate timing comparisons.

packages/browser/src/tracing/browserTracingIntegration.ts#L469-L780

constinteractionHandler=(): void=>{
lastInteractionTimestamp=timestampInSeconds();
};
addEventListener('click',interactionHandler,{capture: true});
addEventListener('keydown',interactionHandler,{capture: true,passive: true});
}
functionmaybeEndActiveSpan(): void{
constactiveSpan=getActiveIdleSpan(client);
if(activeSpan&&!spanToJSON(activeSpan).timestamp){
DEBUG_BUILD&&logger.log(`[Tracing] Finishing current active span with op: ${spanToJSON(activeSpan).op}`);
// If there's an open active span, we need to finish it before creating an new one.
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,'cancelled');
activeSpan.end();
}
}
client.on('startNavigationSpan',(startSpanOptions,navigationOptions)=>{
if(getClient()!==client){
return;
}
if(navigationOptions?.isRedirect){
DEBUG_BUILD&&
logger.warn('[Tracing] Detected redirect, navigation span will not be the root span, but a child span.');
_createRouteSpan(
client,
{
op: 'navigation.redirect',
...startSpanOptions,
},
false,
);
return;
}
maybeEndActiveSpan();
getIsolationScope().setPropagationContext({traceId: generateTraceId(),sampleRand: Math.random()});
constscope=getCurrentScope();
scope.setPropagationContext({traceId: generateTraceId(),sampleRand: Math.random()});
// We reset this to ensure we do not have lingering incorrect data here
// places that call this hook may set this where appropriate - else, the URL at span sending time is used
scope.setSDKProcessingMetadata({
normalizedRequest: undefined,
});
_createRouteSpan(client,{
op: 'navigation',
...startSpanOptions,
});
});
client.on('startPageLoadSpan',(startSpanOptions,traceOptions={})=>{
if(getClient()!==client){
return;
}
maybeEndActiveSpan();
constsentryTrace=traceOptions.sentryTrace||getMetaContent('sentry-trace');
constbaggage=traceOptions.baggage||getMetaContent('baggage');
constpropagationContext=propagationContextFromHeaders(sentryTrace,baggage);
constscope=getCurrentScope();
scope.setPropagationContext(propagationContext);
// We store the normalized request data on the scope, so we get the request data at time of span creation
// otherwise, the URL etc. may already be of the following navigation, and we'd report the wrong URL
scope.setSDKProcessingMetadata({
normalizedRequest: getHttpRequestData(),
});
_createRouteSpan(client,{
op: 'pageload',
...startSpanOptions,
});
});
},
afterAllSetup(client){
letstartingUrl: string|undefined=getLocationHref();
if(linkPreviousTrace!=='off'){
linkTraces(client,{ linkPreviousTrace, consistentTraceSampling });
}
if(WINDOW.location){
if(instrumentPageLoad){
constorigin=browserPerformanceTimeOrigin();
startBrowserTracingPageLoadSpan(client,{
name: WINDOW.location.pathname,
// pageload should always start at timeOrigin (and needs to be in s, not ms)
startTime: origin ? origin/1000 : undefined,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser',
},
});
}
if(instrumentNavigation){
addHistoryInstrumentationHandler(({ to, from })=>{
/**
* This early return is there to account for some cases where a navigation transaction starts right after
* long-running pageload. We make sure that if `from` is undefined and a valid `startingURL` exists, we don't
* create an uneccessary navigation transaction.
*
* This was hard to duplicate, but this behavior stopped as soon as this fix was applied. This issue might also
* only be caused in certain development environments where the usage of a hot module reloader is causing
* errors.
*/
if(from===undefined&&startingUrl?.indexOf(to)!==-1){
startingUrl=undefined;
return;
}
startingUrl=undefined;
constparsed=parseStringToURLObject(to);
constactiveSpan=getActiveIdleSpan(client);
constnavigationIsRedirect=
activeSpan&&detectRedirects&&isRedirect(activeSpan,lastInteractionTimestamp);
startBrowserTracingNavigationSpan(
client,
{
name: parsed?.pathname||WINDOW.location.pathname,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser',
},
},
{url: to,isRedirect: navigationIsRedirect},
);
});
}
}
if(markBackgroundSpan){
registerBackgroundTabDetection();
}
if(enableInteractions){
registerInteractionListener(client,idleTimeout,finalTimeout,childSpanTimeout,latestRoute);
}
if(enableInp){
registerInpInteractionListener();
}
instrumentOutgoingRequests(client,{
traceFetch,
traceXHR,
trackFetchStreamPerformance,
tracePropagationTargets: client.getOptions().tracePropagationTargets,
shouldCreateSpanForRequest,
enableHTTPTimings,
onRequestSpanStart,
});
},
};
})satisfiesIntegrationFn;
/**
* Manually start a page load span.
* This will only do something if a browser tracing integration integration has been setup.
*
* If you provide a custom `traceOptions` object, it will be used to continue the trace
* instead of the default behavior, which is to look it up on the <meta> tags.
*/
exportfunctionstartBrowserTracingPageLoadSpan(
client: Client,
spanOptions: StartSpanOptions,
traceOptions?: {sentryTrace?: string|undefined;baggage?: string|undefined},
): Span|undefined{
client.emit('startPageLoadSpan',spanOptions,traceOptions);
getCurrentScope().setTransactionName(spanOptions.name);
returngetActiveIdleSpan(client);
}
/**
* Manually start a navigation span.
* This will only do something if a browser tracing integration has been setup.
*/
exportfunctionstartBrowserTracingNavigationSpan(
client: Client,
spanOptions: StartSpanOptions,
options?: {url?: string;isRedirect?: boolean},
): Span|undefined{
const{ url, isRedirect }=options||{};
client.emit('startNavigationSpan',spanOptions,{ isRedirect });
constscope=getCurrentScope();
scope.setTransactionName(spanOptions.name);
// We store the normalized request data on the scope, so we get the request data at time of span creation
// otherwise, the URL etc. may already be of the following navigation, and we'd report the wrong URL
if(url&&!isRedirect){
scope.setSDKProcessingMetadata({
normalizedRequest: {
...getHttpRequestData(),
url,
},
});
}
returngetActiveIdleSpan(client);
}
/** Returns the value of a meta tag */
exportfunctiongetMetaContent(metaName: string): string|undefined{
/**
* This is just a small wrapper that makes `document` optional.
* We want to be extra-safe and always check that this exists, to ensure weird environments do not blow up.
*/
constoptionalWindowDocument=WINDOW.documentas(typeofWINDOW)['document']|undefined;
constmetaTag=optionalWindowDocument?.querySelector(`meta[name=${metaName}]`);
returnmetaTag?.getAttribute('content')||undefined;
}
/** Start listener for interaction transactions */
functionregisterInteractionListener(
client: Client,
idleTimeout: BrowserTracingOptions['idleTimeout'],
finalTimeout: BrowserTracingOptions['finalTimeout'],
childSpanTimeout: BrowserTracingOptions['childSpanTimeout'],
latestRoute: RouteInfo,
): void{
/**
* This is just a small wrapper that makes `document` optional.
* We want to be extra-safe and always check that this exists, to ensure weird environments do not blow up.
*/
constoptionalWindowDocument=WINDOW.documentas(typeofWINDOW)['document']|undefined;
letinflightInteractionSpan: Span|undefined;
constregisterInteractionTransaction=(): void=>{
constop='ui.action.click';
constactiveIdleSpan=getActiveIdleSpan(client);
if(activeIdleSpan){
constcurrentRootSpanOp=spanToJSON(activeIdleSpan).op;
if(['navigation','pageload'].includes(currentRootSpanOpasstring)){
DEBUG_BUILD&&
logger.warn(`[Tracing] Did not create ${op} span because a pageload or navigation span is in progress.`);
returnundefined;
}
}
if(inflightInteractionSpan){
inflightInteractionSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,'interactionInterrupted');
inflightInteractionSpan.end();
inflightInteractionSpan=undefined;
}
if(!latestRoute.name){
DEBUG_BUILD&&logger.warn(`[Tracing] Did not create ${op} transaction because _latestRouteName is missing.`);
returnundefined;
}
inflightInteractionSpan=startIdleSpan(
{
name: latestRoute.name,
op,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: latestRoute.source||'url',
},
},
{
idleTimeout,
finalTimeout,
childSpanTimeout,
},
);
};
if(optionalWindowDocument){
addEventListener('click',registerInteractionTransaction,{capture: true});
}
}
// We store the active idle span on the client object, so we can access it from exported functions
constACTIVE_IDLE_SPAN_PROPERTY='_sentry_idleSpan';
functiongetActiveIdleSpan(client: Client): Span|undefined{
return(clientas{[ACTIVE_IDLE_SPAN_PROPERTY]?: Span})[ACTIVE_IDLE_SPAN_PROPERTY];
}
functionsetActiveIdleSpan(client: Client,span: Span|undefined): void{
addNonEnumerableProperty(client,ACTIVE_IDLE_SPAN_PROPERTY,span);
}
// The max. time in seconds between two pageload/navigation spans that makes us consider the second one a redirect
constREDIRECT_THRESHOLD=0.3;
functionisRedirect(activeSpan: Span,lastInteractionTimestamp: number|undefined): boolean{
constspanData=spanToJSON(activeSpan);
constnow=dateTimestampInSeconds();
// More than 300ms since last navigation/pageload span?
// --> never consider this a redirect
conststartTimestamp=spanData.start_timestamp;
if(now-startTimestamp>REDIRECT_THRESHOLD){
returnfalse;
}
// A click happened in the last 300ms?
// --> never consider this a redirect
if(lastInteractionTimestamp&&now-lastInteractionTimestamp<=REDIRECT_THRESHOLD){
returnfalse;

Fix in CursorFix in Web


Bug: Browser Tracing Integration Event Listener Leak

The browserTracingIntegration introduces a memory leak by adding global click and keydown event listeners for redirect detection without ever removing them. This causes listeners to accumulate when the integration is reinitialized or multiple instances are created, such as in SPAs, hot module reloading, or test environments. A cleanup mechanism is required to prevent this accumulation.

packages/browser/src/tracing/browserTracingIntegration.ts#L467-L475

if(detectRedirects&&optionalWindowDocument){
constinteractionHandler=(): void=>{
lastInteractionTimestamp=timestampInSeconds();
};
addEventListener('click',interactionHandler,{capture: true});
addEventListener('keydown',interactionHandler,{capture: true,passive: true});
}

Fix in CursorFix in Web


Was this report helpful? Give feedback by reacting with 👍 or 👎

@mydea
mydea merged commit 3e5eac5 into developJul 10, 2025
@mydea
mydea deleted the fn/detect-pageload-redirects branch July 10, 2025 08:19
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Distinguish redirects from user-initiated nagivations

5 participants

@mydea@Lms24@s1gr1d@bricefriha@edwardgou-sentry
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(browser): Detect redirects when emitting navigation spans - #16324

Merged
mydea merged 6 commits into
developfrom
fn/detect-pageload-redirects
Jul 10, 2025
Merged

feat(browser): Detect redirects when emitting navigation spans#16324
mydea merged 6 commits into
developfrom
fn/detect-pageload-redirects

Conversation

@mydea

Copy link
Copy Markdown
Member

Closes#15286

This PR adds a new option to browserTracingIntegration, detectRedirects, which is enabled by default. If this is enabled, the integration will try to detect if a navigation is actually a redirect based on a simple heuristic, and in this case, will not end the ongoing pageload/navigation, but instead let it run and create a navigation.redirect zero-duration span instead.

An example trace for this would be: https://sentry-sdks.sentry.io/explore/discover/trace/95280de69dc844448d39de7458eab527/?dataset=transactions&eventId=8a1150fd1dc846e4ac8420ccf03ad0ee&field=title&field=project&field=user.display&field=timestamp&name=All%20Errors&project=4504956726345728&query=&queryDataset=transaction-like&sort=-timestamp&source=discover&statsPeriod=5m&timestamp=1747646096&yAxis=count%28%29
image

Where the respective index route that triggered this has this code:

setTimeout(()=>{window.history.pushState({},"","/test-sub-page");fetch('https://example.com')},100);

The used heuristic is:

  • If the ongoing pageload/navigation was started less than 300ms ago...
  • ... and no click has happened in this time...
  • ... then we consider the navigation a redirect

this limit was chosen somewhat arbitrarily, open for other suggestions too.

While this logic will not be 100% bullet proof, it should be reliable enough and likely better than what we have today. Users can opt-out of this logic via browserTracingIntegration({ detectRedirects: false }), if needed.

@mydea
mydea requested review from Lms24, bcoe and s1gr1dMay 19, 2025 09:21
@mydeamydea self-assigned this May 19, 2025
@github-actions

github-actionsBot commented May 19, 2025

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser23.99 kB--
@sentry/browser - with treeshaking flags23.76 kB--
@sentry/browser (incl. Tracing)39.85 kB+0.6%+235 B 🔺
@sentry/browser (incl. Tracing, Replay)78.06 kB+0.31%+238 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags71.09 kB+0.27%+187 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)82.77 kB+0.28%+225 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)94.99 kB+0.3%+277 B 🔺
@sentry/browser (incl. Feedback)40.76 kB--
@sentry/browser (incl. sendFeedback)28.7 kB--
@sentry/browser (incl. FeedbackAsync)33.59 kB--
@sentry/react25.76 kB--
@sentry/react (incl. Tracing)41.85 kB+0.58%+239 B 🔺
@sentry/vue28.37 kB--
@sentry/vue (incl. Tracing)41.66 kB+0.6%+246 B 🔺
@sentry/svelte24.01 kB--
CDN Bundle25.5 kB--
CDN Bundle (incl. Tracing)39.82 kB+0.48%+187 B 🔺
CDN Bundle (incl. Tracing, Replay)75.8 kB+0.25%+187 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)81.27 kB+0.24%+193 B 🔺
CDN Bundle - uncompressed74.5 kB--
CDN Bundle (incl. Tracing) - uncompressed118.25 kB+0.41%+481 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed232.55 kB+0.21%+481 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed245.38 kB+0.2%+481 B 🔺
@sentry/nextjs (client)43.48 kB+0.52%+222 B 🔺
@sentry/sveltekit (client)40.32 kB+0.59%+235 B 🔺
@sentry/node161.84 kB--
@sentry/node - without tracing98.79 kB--
@sentry/aws-serverless124.61 kB--

View base workflow run

@codecov

codecovBot commented May 19, 2025

Copy link
Copy Markdown

❌ Unsupported file format

Upload processing failed due to unsupported file format. Please review the parser error message:

Error parsing JUnit XML in /home/runner/work/sentry-javascript/sentry-javascript/packages/solidstart/vitest.junit.xml at 18:17
Caused by:
RuntimeError: Error parsing XML
Caused by:
0: ill-formed document: expected `</testsuites>`, but `</testsuite>` was found
1: expected `</testsuites>`, but `</testsuite>` was found

For more help, visit our troubleshooting guide.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch 2 times, most recently from ccbd697 to eb3c0bcCompareMay 23, 2025 07:22
@mydea
mydea marked this pull request as ready for review May 23, 2025 07:22
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts Outdated
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from eb3c0bc to cb8e92eCompareMay 26, 2025 11:22

@edwardgou-sentryedwardgou-sentry left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me! There aren't many product areas in performance that specifically rely on navigations so I think this should be fine (and I think we'd consider surfacing redirects in those areas a bug anyways).

}

if (detectRedirects && optionalWindowDocument) {
addEventListener('click', () => (lastClickTimestamp = timestampInSeconds()), { capture: true, passive: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are there other events, such as key presses, that could indicate a user manually navigating?

@Lms24Lms24Jun 16, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, keypress might also be a good candidate, agreed.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 also looking at keypress

@Lms24Lms24 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the late review, but LGTM! I think we probably need to widen the timespan a bit because 300ms feel a bit fast to me (thinking of the endless redirects I get when doing SSO or stuff like this). But maybe it's good enough for now. I'd say its something we adjust on a per-feedback basis.

}

if (detectRedirects && optionalWindowDocument) {
addEventListener('click', () => (lastClickTimestamp = timestampInSeconds()), { capture: true, passive: true });

@Lms24Lms24Jun 16, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, keypress might also be a good candidate, agreed.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 19d02d3 to 67791e9CompareJune 17, 2025 10:27
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 67791e9 to e2018b5CompareJune 18, 2025 07:52
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from e2018b5 to 9dec9c3CompareJuly 7, 2025 14:42
cursor[bot]

This comment was marked as outdated.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 9dec9c3 to da0cffeCompareJuly 10, 2025 07:12

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Navigation URL Metadata Update Fails

The scope.setSDKProcessingMetadata is not updated for navigation spans if the URL is falsy or if the navigation is detected as a redirect. This prevents subsequent events from having the correct URL information on the scope. Additionally, the redirect detection logic uses inconsistent timestamp functions (timestampInSeconds vs dateTimestampInSeconds), which can lead to inaccurate timing comparisons.

packages/browser/src/tracing/browserTracingIntegration.ts#L469-L780

constinteractionHandler=(): void=>{
lastInteractionTimestamp=timestampInSeconds();
};
addEventListener('click',interactionHandler,{capture: true});
addEventListener('keydown',interactionHandler,{capture: true,passive: true});
}
functionmaybeEndActiveSpan(): void{
constactiveSpan=getActiveIdleSpan(client);
if(activeSpan&&!spanToJSON(activeSpan).timestamp){
DEBUG_BUILD&&logger.log(`[Tracing] Finishing current active span with op: ${spanToJSON(activeSpan).op}`);
// If there's an open active span, we need to finish it before creating an new one.
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,'cancelled');
activeSpan.end();
}
}
client.on('startNavigationSpan',(startSpanOptions,navigationOptions)=>{
if(getClient()!==client){
return;
}
if(navigationOptions?.isRedirect){
DEBUG_BUILD&&
logger.warn('[Tracing] Detected redirect, navigation span will not be the root span, but a child span.');
_createRouteSpan(
client,
{
op: 'navigation.redirect',
...startSpanOptions,
},
false,
);
return;
}
maybeEndActiveSpan();
getIsolationScope().setPropagationContext({traceId: generateTraceId(),sampleRand: Math.random()});
constscope=getCurrentScope();
scope.setPropagationContext({traceId: generateTraceId(),sampleRand: Math.random()});
// We reset this to ensure we do not have lingering incorrect data here
// places that call this hook may set this where appropriate - else, the URL at span sending time is used
scope.setSDKProcessingMetadata({
normalizedRequest: undefined,
});
_createRouteSpan(client,{
op: 'navigation',
...startSpanOptions,
});
});
client.on('startPageLoadSpan',(startSpanOptions,traceOptions={})=>{
if(getClient()!==client){
return;
}
maybeEndActiveSpan();
constsentryTrace=traceOptions.sentryTrace||getMetaContent('sentry-trace');
constbaggage=traceOptions.baggage||getMetaContent('baggage');
constpropagationContext=propagationContextFromHeaders(sentryTrace,baggage);
constscope=getCurrentScope();
scope.setPropagationContext(propagationContext);
// We store the normalized request data on the scope, so we get the request data at time of span creation
// otherwise, the URL etc. may already be of the following navigation, and we'd report the wrong URL
scope.setSDKProcessingMetadata({
normalizedRequest: getHttpRequestData(),
});
_createRouteSpan(client,{
op: 'pageload',
...startSpanOptions,
});
});
},
afterAllSetup(client){
letstartingUrl: string|undefined=getLocationHref();
if(linkPreviousTrace!=='off'){
linkTraces(client,{ linkPreviousTrace, consistentTraceSampling });
}
if(WINDOW.location){
if(instrumentPageLoad){
constorigin=browserPerformanceTimeOrigin();
startBrowserTracingPageLoadSpan(client,{
name: WINDOW.location.pathname,
// pageload should always start at timeOrigin (and needs to be in s, not ms)
startTime: origin ? origin/1000 : undefined,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser',
},
});
}
if(instrumentNavigation){
addHistoryInstrumentationHandler(({ to, from })=>{
/**
* This early return is there to account for some cases where a navigation transaction starts right after
* long-running pageload. We make sure that if `from` is undefined and a valid `startingURL` exists, we don't
* create an uneccessary navigation transaction.
*
* This was hard to duplicate, but this behavior stopped as soon as this fix was applied. This issue might also
* only be caused in certain development environments where the usage of a hot module reloader is causing
* errors.
*/
if(from===undefined&&startingUrl?.indexOf(to)!==-1){
startingUrl=undefined;
return;
}
startingUrl=undefined;
constparsed=parseStringToURLObject(to);
constactiveSpan=getActiveIdleSpan(client);
constnavigationIsRedirect=
activeSpan&&detectRedirects&&isRedirect(activeSpan,lastInteractionTimestamp);
startBrowserTracingNavigationSpan(
client,
{
name: parsed?.pathname||WINDOW.location.pathname,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser',
},
},
{url: to,isRedirect: navigationIsRedirect},
);
});
}
}
if(markBackgroundSpan){
registerBackgroundTabDetection();
}
if(enableInteractions){
registerInteractionListener(client,idleTimeout,finalTimeout,childSpanTimeout,latestRoute);
}
if(enableInp){
registerInpInteractionListener();
}
instrumentOutgoingRequests(client,{
traceFetch,
traceXHR,
trackFetchStreamPerformance,
tracePropagationTargets: client.getOptions().tracePropagationTargets,
shouldCreateSpanForRequest,
enableHTTPTimings,
onRequestSpanStart,
});
},
};
})satisfiesIntegrationFn;
/**
* Manually start a page load span.
* This will only do something if a browser tracing integration integration has been setup.
*
* If you provide a custom `traceOptions` object, it will be used to continue the trace
* instead of the default behavior, which is to look it up on the <meta> tags.
*/
exportfunctionstartBrowserTracingPageLoadSpan(
client: Client,
spanOptions: StartSpanOptions,
traceOptions?: {sentryTrace?: string|undefined;baggage?: string|undefined},
): Span|undefined{
client.emit('startPageLoadSpan',spanOptions,traceOptions);
getCurrentScope().setTransactionName(spanOptions.name);
returngetActiveIdleSpan(client);
}
/**
* Manually start a navigation span.
* This will only do something if a browser tracing integration has been setup.
*/
exportfunctionstartBrowserTracingNavigationSpan(
client: Client,
spanOptions: StartSpanOptions,
options?: {url?: string;isRedirect?: boolean},
): Span|undefined{
const{ url, isRedirect }=options||{};
client.emit('startNavigationSpan',spanOptions,{ isRedirect });
constscope=getCurrentScope();
scope.setTransactionName(spanOptions.name);
// We store the normalized request data on the scope, so we get the request data at time of span creation
// otherwise, the URL etc. may already be of the following navigation, and we'd report the wrong URL
if(url&&!isRedirect){
scope.setSDKProcessingMetadata({
normalizedRequest: {
...getHttpRequestData(),
url,
},
});
}
returngetActiveIdleSpan(client);
}
/** Returns the value of a meta tag */
exportfunctiongetMetaContent(metaName: string): string|undefined{
/**
* This is just a small wrapper that makes `document` optional.
* We want to be extra-safe and always check that this exists, to ensure weird environments do not blow up.
*/
constoptionalWindowDocument=WINDOW.documentas(typeofWINDOW)['document']|undefined;
constmetaTag=optionalWindowDocument?.querySelector(`meta[name=${metaName}]`);
returnmetaTag?.getAttribute('content')||undefined;
}
/** Start listener for interaction transactions */
functionregisterInteractionListener(
client: Client,
idleTimeout: BrowserTracingOptions['idleTimeout'],
finalTimeout: BrowserTracingOptions['finalTimeout'],
childSpanTimeout: BrowserTracingOptions['childSpanTimeout'],
latestRoute: RouteInfo,
): void{
/**
* This is just a small wrapper that makes `document` optional.
* We want to be extra-safe and always check that this exists, to ensure weird environments do not blow up.
*/
constoptionalWindowDocument=WINDOW.documentas(typeofWINDOW)['document']|undefined;
letinflightInteractionSpan: Span|undefined;
constregisterInteractionTransaction=(): void=>{
constop='ui.action.click';
constactiveIdleSpan=getActiveIdleSpan(client);
if(activeIdleSpan){
constcurrentRootSpanOp=spanToJSON(activeIdleSpan).op;
if(['navigation','pageload'].includes(currentRootSpanOpasstring)){
DEBUG_BUILD&&
logger.warn(`[Tracing] Did not create ${op} span because a pageload or navigation span is in progress.`);
returnundefined;
}
}
if(inflightInteractionSpan){
inflightInteractionSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,'interactionInterrupted');
inflightInteractionSpan.end();
inflightInteractionSpan=undefined;
}
if(!latestRoute.name){
DEBUG_BUILD&&logger.warn(`[Tracing] Did not create ${op} transaction because _latestRouteName is missing.`);
returnundefined;
}
inflightInteractionSpan=startIdleSpan(
{
name: latestRoute.name,
op,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: latestRoute.source||'url',
},
},
{
idleTimeout,
finalTimeout,
childSpanTimeout,
},
);
};
if(optionalWindowDocument){
addEventListener('click',registerInteractionTransaction,{capture: true});
}
}
// We store the active idle span on the client object, so we can access it from exported functions
constACTIVE_IDLE_SPAN_PROPERTY='_sentry_idleSpan';
functiongetActiveIdleSpan(client: Client): Span|undefined{
return(clientas{[ACTIVE_IDLE_SPAN_PROPERTY]?: Span})[ACTIVE_IDLE_SPAN_PROPERTY];
}
functionsetActiveIdleSpan(client: Client,span: Span|undefined): void{
addNonEnumerableProperty(client,ACTIVE_IDLE_SPAN_PROPERTY,span);
}
// The max. time in seconds between two pageload/navigation spans that makes us consider the second one a redirect
constREDIRECT_THRESHOLD=0.3;
functionisRedirect(activeSpan: Span,lastInteractionTimestamp: number|undefined): boolean{
constspanData=spanToJSON(activeSpan);
constnow=dateTimestampInSeconds();
// More than 300ms since last navigation/pageload span?
// --> never consider this a redirect
conststartTimestamp=spanData.start_timestamp;
if(now-startTimestamp>REDIRECT_THRESHOLD){
returnfalse;
}
// A click happened in the last 300ms?
// --> never consider this a redirect
if(lastInteractionTimestamp&&now-lastInteractionTimestamp<=REDIRECT_THRESHOLD){
returnfalse;

Fix in CursorFix in Web


Bug: Browser Tracing Integration Event Listener Leak

The browserTracingIntegration introduces a memory leak by adding global click and keydown event listeners for redirect detection without ever removing them. This causes listeners to accumulate when the integration is reinitialized or multiple instances are created, such as in SPAs, hot module reloading, or test environments. A cleanup mechanism is required to prevent this accumulation.

packages/browser/src/tracing/browserTracingIntegration.ts#L467-L475

if(detectRedirects&&optionalWindowDocument){
constinteractionHandler=(): void=>{
lastInteractionTimestamp=timestampInSeconds();
};
addEventListener('click',interactionHandler,{capture: true});
addEventListener('keydown',interactionHandler,{capture: true,passive: true});
}

Fix in CursorFix in Web


Was this report helpful? Give feedback by reacting with 👍 or 👎

@mydea
mydea merged commit 3e5eac5 into developJul 10, 2025
@mydea
mydea deleted the fn/detect-pageload-redirects branch July 10, 2025 08:19
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Distinguish redirects from user-initiated nagivations

5 participants

@mydea@Lms24@s1gr1d@bricefriha@edwardgou-sentry
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(browser): Detect redirects when emitting navigation spans - #16324

Merged
mydea merged 6 commits into
developfrom
fn/detect-pageload-redirects
Jul 10, 2025
Merged

feat(browser): Detect redirects when emitting navigation spans#16324
mydea merged 6 commits into
developfrom
fn/detect-pageload-redirects

Conversation

@mydea

Copy link
Copy Markdown
Member

Closes#15286

This PR adds a new option to browserTracingIntegration, detectRedirects, which is enabled by default. If this is enabled, the integration will try to detect if a navigation is actually a redirect based on a simple heuristic, and in this case, will not end the ongoing pageload/navigation, but instead let it run and create a navigation.redirect zero-duration span instead.

An example trace for this would be: https://sentry-sdks.sentry.io/explore/discover/trace/95280de69dc844448d39de7458eab527/?dataset=transactions&eventId=8a1150fd1dc846e4ac8420ccf03ad0ee&field=title&field=project&field=user.display&field=timestamp&name=All%20Errors&project=4504956726345728&query=&queryDataset=transaction-like&sort=-timestamp&source=discover&statsPeriod=5m&timestamp=1747646096&yAxis=count%28%29
image

Where the respective index route that triggered this has this code:

setTimeout(()=>{window.history.pushState({},"","/test-sub-page");fetch('https://example.com')},100);

The used heuristic is:

  • If the ongoing pageload/navigation was started less than 300ms ago...
  • ... and no click has happened in this time...
  • ... then we consider the navigation a redirect

this limit was chosen somewhat arbitrarily, open for other suggestions too.

While this logic will not be 100% bullet proof, it should be reliable enough and likely better than what we have today. Users can opt-out of this logic via browserTracingIntegration({ detectRedirects: false }), if needed.

@mydea
mydea requested review from Lms24, bcoe and s1gr1dMay 19, 2025 09:21
@mydeamydea self-assigned this May 19, 2025
@github-actions

github-actionsBot commented May 19, 2025

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser23.99 kB--
@sentry/browser - with treeshaking flags23.76 kB--
@sentry/browser (incl. Tracing)39.85 kB+0.6%+235 B 🔺
@sentry/browser (incl. Tracing, Replay)78.06 kB+0.31%+238 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags71.09 kB+0.27%+187 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)82.77 kB+0.28%+225 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)94.99 kB+0.3%+277 B 🔺
@sentry/browser (incl. Feedback)40.76 kB--
@sentry/browser (incl. sendFeedback)28.7 kB--
@sentry/browser (incl. FeedbackAsync)33.59 kB--
@sentry/react25.76 kB--
@sentry/react (incl. Tracing)41.85 kB+0.58%+239 B 🔺
@sentry/vue28.37 kB--
@sentry/vue (incl. Tracing)41.66 kB+0.6%+246 B 🔺
@sentry/svelte24.01 kB--
CDN Bundle25.5 kB--
CDN Bundle (incl. Tracing)39.82 kB+0.48%+187 B 🔺
CDN Bundle (incl. Tracing, Replay)75.8 kB+0.25%+187 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)81.27 kB+0.24%+193 B 🔺
CDN Bundle - uncompressed74.5 kB--
CDN Bundle (incl. Tracing) - uncompressed118.25 kB+0.41%+481 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed232.55 kB+0.21%+481 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed245.38 kB+0.2%+481 B 🔺
@sentry/nextjs (client)43.48 kB+0.52%+222 B 🔺
@sentry/sveltekit (client)40.32 kB+0.59%+235 B 🔺
@sentry/node161.84 kB--
@sentry/node - without tracing98.79 kB--
@sentry/aws-serverless124.61 kB--

View base workflow run

@codecov

codecovBot commented May 19, 2025

Copy link
Copy Markdown

❌ Unsupported file format

Upload processing failed due to unsupported file format. Please review the parser error message:

Error parsing JUnit XML in /home/runner/work/sentry-javascript/sentry-javascript/packages/solidstart/vitest.junit.xml at 18:17
Caused by:
RuntimeError: Error parsing XML
Caused by:
0: ill-formed document: expected `</testsuites>`, but `</testsuite>` was found
1: expected `</testsuites>`, but `</testsuite>` was found

For more help, visit our troubleshooting guide.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch 2 times, most recently from ccbd697 to eb3c0bcCompareMay 23, 2025 07:22
@mydea
mydea marked this pull request as ready for review May 23, 2025 07:22
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts Outdated
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from eb3c0bc to cb8e92eCompareMay 26, 2025 11:22

@edwardgou-sentryedwardgou-sentry left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me! There aren't many product areas in performance that specifically rely on navigations so I think this should be fine (and I think we'd consider surfacing redirects in those areas a bug anyways).

}

if (detectRedirects && optionalWindowDocument) {
addEventListener('click', () => (lastClickTimestamp = timestampInSeconds()), { capture: true, passive: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are there other events, such as key presses, that could indicate a user manually navigating?

@Lms24Lms24Jun 16, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, keypress might also be a good candidate, agreed.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 also looking at keypress

@Lms24Lms24 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the late review, but LGTM! I think we probably need to widen the timespan a bit because 300ms feel a bit fast to me (thinking of the endless redirects I get when doing SSO or stuff like this). But maybe it's good enough for now. I'd say its something we adjust on a per-feedback basis.

}

if (detectRedirects && optionalWindowDocument) {
addEventListener('click', () => (lastClickTimestamp = timestampInSeconds()), { capture: true, passive: true });

@Lms24Lms24Jun 16, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, keypress might also be a good candidate, agreed.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 19d02d3 to 67791e9CompareJune 17, 2025 10:27
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 67791e9 to e2018b5CompareJune 18, 2025 07:52
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from e2018b5 to 9dec9c3CompareJuly 7, 2025 14:42
cursor[bot]

This comment was marked as outdated.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 9dec9c3 to da0cffeCompareJuly 10, 2025 07:12

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Navigation URL Metadata Update Fails

The scope.setSDKProcessingMetadata is not updated for navigation spans if the URL is falsy or if the navigation is detected as a redirect. This prevents subsequent events from having the correct URL information on the scope. Additionally, the redirect detection logic uses inconsistent timestamp functions (timestampInSeconds vs dateTimestampInSeconds), which can lead to inaccurate timing comparisons.

packages/browser/src/tracing/browserTracingIntegration.ts#L469-L780

constinteractionHandler=(): void=>{
lastInteractionTimestamp=timestampInSeconds();
};
addEventListener('click',interactionHandler,{capture: true});
addEventListener('keydown',interactionHandler,{capture: true,passive: true});
}
functionmaybeEndActiveSpan(): void{
constactiveSpan=getActiveIdleSpan(client);
if(activeSpan&&!spanToJSON(activeSpan).timestamp){
DEBUG_BUILD&&logger.log(`[Tracing] Finishing current active span with op: ${spanToJSON(activeSpan).op}`);
// If there's an open active span, we need to finish it before creating an new one.
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,'cancelled');
activeSpan.end();
}
}
client.on('startNavigationSpan',(startSpanOptions,navigationOptions)=>{
if(getClient()!==client){
return;
}
if(navigationOptions?.isRedirect){
DEBUG_BUILD&&
logger.warn('[Tracing] Detected redirect, navigation span will not be the root span, but a child span.');
_createRouteSpan(
client,
{
op: 'navigation.redirect',
...startSpanOptions,
},
false,
);
return;
}
maybeEndActiveSpan();
getIsolationScope().setPropagationContext({traceId: generateTraceId(),sampleRand: Math.random()});
constscope=getCurrentScope();
scope.setPropagationContext({traceId: generateTraceId(),sampleRand: Math.random()});
// We reset this to ensure we do not have lingering incorrect data here
// places that call this hook may set this where appropriate - else, the URL at span sending time is used
scope.setSDKProcessingMetadata({
normalizedRequest: undefined,
});
_createRouteSpan(client,{
op: 'navigation',
...startSpanOptions,
});
});
client.on('startPageLoadSpan',(startSpanOptions,traceOptions={})=>{
if(getClient()!==client){
return;
}
maybeEndActiveSpan();
constsentryTrace=traceOptions.sentryTrace||getMetaContent('sentry-trace');
constbaggage=traceOptions.baggage||getMetaContent('baggage');
constpropagationContext=propagationContextFromHeaders(sentryTrace,baggage);
constscope=getCurrentScope();
scope.setPropagationContext(propagationContext);
// We store the normalized request data on the scope, so we get the request data at time of span creation
// otherwise, the URL etc. may already be of the following navigation, and we'd report the wrong URL
scope.setSDKProcessingMetadata({
normalizedRequest: getHttpRequestData(),
});
_createRouteSpan(client,{
op: 'pageload',
...startSpanOptions,
});
});
},
afterAllSetup(client){
letstartingUrl: string|undefined=getLocationHref();
if(linkPreviousTrace!=='off'){
linkTraces(client,{ linkPreviousTrace, consistentTraceSampling });
}
if(WINDOW.location){
if(instrumentPageLoad){
constorigin=browserPerformanceTimeOrigin();
startBrowserTracingPageLoadSpan(client,{
name: WINDOW.location.pathname,
// pageload should always start at timeOrigin (and needs to be in s, not ms)
startTime: origin ? origin/1000 : undefined,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser',
},
});
}
if(instrumentNavigation){
addHistoryInstrumentationHandler(({ to, from })=>{
/**
* This early return is there to account for some cases where a navigation transaction starts right after
* long-running pageload. We make sure that if `from` is undefined and a valid `startingURL` exists, we don't
* create an uneccessary navigation transaction.
*
* This was hard to duplicate, but this behavior stopped as soon as this fix was applied. This issue might also
* only be caused in certain development environments where the usage of a hot module reloader is causing
* errors.
*/
if(from===undefined&&startingUrl?.indexOf(to)!==-1){
startingUrl=undefined;
return;
}
startingUrl=undefined;
constparsed=parseStringToURLObject(to);
constactiveSpan=getActiveIdleSpan(client);
constnavigationIsRedirect=
activeSpan&&detectRedirects&&isRedirect(activeSpan,lastInteractionTimestamp);
startBrowserTracingNavigationSpan(
client,
{
name: parsed?.pathname||WINDOW.location.pathname,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser',
},
},
{url: to,isRedirect: navigationIsRedirect},
);
});
}
}
if(markBackgroundSpan){
registerBackgroundTabDetection();
}
if(enableInteractions){
registerInteractionListener(client,idleTimeout,finalTimeout,childSpanTimeout,latestRoute);
}
if(enableInp){
registerInpInteractionListener();
}
instrumentOutgoingRequests(client,{
traceFetch,
traceXHR,
trackFetchStreamPerformance,
tracePropagationTargets: client.getOptions().tracePropagationTargets,
shouldCreateSpanForRequest,
enableHTTPTimings,
onRequestSpanStart,
});
},
};
})satisfiesIntegrationFn;
/**
* Manually start a page load span.
* This will only do something if a browser tracing integration integration has been setup.
*
* If you provide a custom `traceOptions` object, it will be used to continue the trace
* instead of the default behavior, which is to look it up on the <meta> tags.
*/
exportfunctionstartBrowserTracingPageLoadSpan(
client: Client,
spanOptions: StartSpanOptions,
traceOptions?: {sentryTrace?: string|undefined;baggage?: string|undefined},
): Span|undefined{
client.emit('startPageLoadSpan',spanOptions,traceOptions);
getCurrentScope().setTransactionName(spanOptions.name);
returngetActiveIdleSpan(client);
}
/**
* Manually start a navigation span.
* This will only do something if a browser tracing integration has been setup.
*/
exportfunctionstartBrowserTracingNavigationSpan(
client: Client,
spanOptions: StartSpanOptions,
options?: {url?: string;isRedirect?: boolean},
): Span|undefined{
const{ url, isRedirect }=options||{};
client.emit('startNavigationSpan',spanOptions,{ isRedirect });
constscope=getCurrentScope();
scope.setTransactionName(spanOptions.name);
// We store the normalized request data on the scope, so we get the request data at time of span creation
// otherwise, the URL etc. may already be of the following navigation, and we'd report the wrong URL
if(url&&!isRedirect){
scope.setSDKProcessingMetadata({
normalizedRequest: {
...getHttpRequestData(),
url,
},
});
}
returngetActiveIdleSpan(client);
}
/** Returns the value of a meta tag */
exportfunctiongetMetaContent(metaName: string): string|undefined{
/**
* This is just a small wrapper that makes `document` optional.
* We want to be extra-safe and always check that this exists, to ensure weird environments do not blow up.
*/
constoptionalWindowDocument=WINDOW.documentas(typeofWINDOW)['document']|undefined;
constmetaTag=optionalWindowDocument?.querySelector(`meta[name=${metaName}]`);
returnmetaTag?.getAttribute('content')||undefined;
}
/** Start listener for interaction transactions */
functionregisterInteractionListener(
client: Client,
idleTimeout: BrowserTracingOptions['idleTimeout'],
finalTimeout: BrowserTracingOptions['finalTimeout'],
childSpanTimeout: BrowserTracingOptions['childSpanTimeout'],
latestRoute: RouteInfo,
): void{
/**
* This is just a small wrapper that makes `document` optional.
* We want to be extra-safe and always check that this exists, to ensure weird environments do not blow up.
*/
constoptionalWindowDocument=WINDOW.documentas(typeofWINDOW)['document']|undefined;
letinflightInteractionSpan: Span|undefined;
constregisterInteractionTransaction=(): void=>{
constop='ui.action.click';
constactiveIdleSpan=getActiveIdleSpan(client);
if(activeIdleSpan){
constcurrentRootSpanOp=spanToJSON(activeIdleSpan).op;
if(['navigation','pageload'].includes(currentRootSpanOpasstring)){
DEBUG_BUILD&&
logger.warn(`[Tracing] Did not create ${op} span because a pageload or navigation span is in progress.`);
returnundefined;
}
}
if(inflightInteractionSpan){
inflightInteractionSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,'interactionInterrupted');
inflightInteractionSpan.end();
inflightInteractionSpan=undefined;
}
if(!latestRoute.name){
DEBUG_BUILD&&logger.warn(`[Tracing] Did not create ${op} transaction because _latestRouteName is missing.`);
returnundefined;
}
inflightInteractionSpan=startIdleSpan(
{
name: latestRoute.name,
op,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: latestRoute.source||'url',
},
},
{
idleTimeout,
finalTimeout,
childSpanTimeout,
},
);
};
if(optionalWindowDocument){
addEventListener('click',registerInteractionTransaction,{capture: true});
}
}
// We store the active idle span on the client object, so we can access it from exported functions
constACTIVE_IDLE_SPAN_PROPERTY='_sentry_idleSpan';
functiongetActiveIdleSpan(client: Client): Span|undefined{
return(clientas{[ACTIVE_IDLE_SPAN_PROPERTY]?: Span})[ACTIVE_IDLE_SPAN_PROPERTY];
}
functionsetActiveIdleSpan(client: Client,span: Span|undefined): void{
addNonEnumerableProperty(client,ACTIVE_IDLE_SPAN_PROPERTY,span);
}
// The max. time in seconds between two pageload/navigation spans that makes us consider the second one a redirect
constREDIRECT_THRESHOLD=0.3;
functionisRedirect(activeSpan: Span,lastInteractionTimestamp: number|undefined): boolean{
constspanData=spanToJSON(activeSpan);
constnow=dateTimestampInSeconds();
// More than 300ms since last navigation/pageload span?
// --> never consider this a redirect
conststartTimestamp=spanData.start_timestamp;
if(now-startTimestamp>REDIRECT_THRESHOLD){
returnfalse;
}
// A click happened in the last 300ms?
// --> never consider this a redirect
if(lastInteractionTimestamp&&now-lastInteractionTimestamp<=REDIRECT_THRESHOLD){
returnfalse;

Fix in CursorFix in Web


Bug: Browser Tracing Integration Event Listener Leak

The browserTracingIntegration introduces a memory leak by adding global click and keydown event listeners for redirect detection without ever removing them. This causes listeners to accumulate when the integration is reinitialized or multiple instances are created, such as in SPAs, hot module reloading, or test environments. A cleanup mechanism is required to prevent this accumulation.

packages/browser/src/tracing/browserTracingIntegration.ts#L467-L475

if(detectRedirects&&optionalWindowDocument){
constinteractionHandler=(): void=>{
lastInteractionTimestamp=timestampInSeconds();
};
addEventListener('click',interactionHandler,{capture: true});
addEventListener('keydown',interactionHandler,{capture: true,passive: true});
}

Fix in CursorFix in Web


Was this report helpful? Give feedback by reacting with 👍 or 👎

@mydea
mydea merged commit 3e5eac5 into developJul 10, 2025
@mydea
mydea deleted the fn/detect-pageload-redirects branch July 10, 2025 08:19
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Distinguish redirects from user-initiated nagivations

5 participants

@mydea@Lms24@s1gr1d@bricefriha@edwardgou-sentry
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(browser): Detect redirects when emitting navigation spans - #16324

Merged
mydea merged 6 commits into
developfrom
fn/detect-pageload-redirects
Jul 10, 2025
Merged

feat(browser): Detect redirects when emitting navigation spans#16324
mydea merged 6 commits into
developfrom
fn/detect-pageload-redirects

Conversation

@mydea

Copy link
Copy Markdown
Member

Closes#15286

This PR adds a new option to browserTracingIntegration, detectRedirects, which is enabled by default. If this is enabled, the integration will try to detect if a navigation is actually a redirect based on a simple heuristic, and in this case, will not end the ongoing pageload/navigation, but instead let it run and create a navigation.redirect zero-duration span instead.

An example trace for this would be: https://sentry-sdks.sentry.io/explore/discover/trace/95280de69dc844448d39de7458eab527/?dataset=transactions&eventId=8a1150fd1dc846e4ac8420ccf03ad0ee&field=title&field=project&field=user.display&field=timestamp&name=All%20Errors&project=4504956726345728&query=&queryDataset=transaction-like&sort=-timestamp&source=discover&statsPeriod=5m&timestamp=1747646096&yAxis=count%28%29
image

Where the respective index route that triggered this has this code:

setTimeout(()=>{window.history.pushState({},"","/test-sub-page");fetch('https://example.com')},100);

The used heuristic is:

  • If the ongoing pageload/navigation was started less than 300ms ago...
  • ... and no click has happened in this time...
  • ... then we consider the navigation a redirect

this limit was chosen somewhat arbitrarily, open for other suggestions too.

While this logic will not be 100% bullet proof, it should be reliable enough and likely better than what we have today. Users can opt-out of this logic via browserTracingIntegration({ detectRedirects: false }), if needed.

@mydea
mydea requested review from Lms24, bcoe and s1gr1dMay 19, 2025 09:21
@mydeamydea self-assigned this May 19, 2025
@github-actions

github-actionsBot commented May 19, 2025

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser23.99 kB--
@sentry/browser - with treeshaking flags23.76 kB--
@sentry/browser (incl. Tracing)39.85 kB+0.6%+235 B 🔺
@sentry/browser (incl. Tracing, Replay)78.06 kB+0.31%+238 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags71.09 kB+0.27%+187 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)82.77 kB+0.28%+225 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)94.99 kB+0.3%+277 B 🔺
@sentry/browser (incl. Feedback)40.76 kB--
@sentry/browser (incl. sendFeedback)28.7 kB--
@sentry/browser (incl. FeedbackAsync)33.59 kB--
@sentry/react25.76 kB--
@sentry/react (incl. Tracing)41.85 kB+0.58%+239 B 🔺
@sentry/vue28.37 kB--
@sentry/vue (incl. Tracing)41.66 kB+0.6%+246 B 🔺
@sentry/svelte24.01 kB--
CDN Bundle25.5 kB--
CDN Bundle (incl. Tracing)39.82 kB+0.48%+187 B 🔺
CDN Bundle (incl. Tracing, Replay)75.8 kB+0.25%+187 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)81.27 kB+0.24%+193 B 🔺
CDN Bundle - uncompressed74.5 kB--
CDN Bundle (incl. Tracing) - uncompressed118.25 kB+0.41%+481 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed232.55 kB+0.21%+481 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed245.38 kB+0.2%+481 B 🔺
@sentry/nextjs (client)43.48 kB+0.52%+222 B 🔺
@sentry/sveltekit (client)40.32 kB+0.59%+235 B 🔺
@sentry/node161.84 kB--
@sentry/node - without tracing98.79 kB--
@sentry/aws-serverless124.61 kB--

View base workflow run

@codecov

codecovBot commented May 19, 2025

Copy link
Copy Markdown

❌ Unsupported file format

Upload processing failed due to unsupported file format. Please review the parser error message:

Error parsing JUnit XML in /home/runner/work/sentry-javascript/sentry-javascript/packages/solidstart/vitest.junit.xml at 18:17
Caused by:
RuntimeError: Error parsing XML
Caused by:
0: ill-formed document: expected `</testsuites>`, but `</testsuite>` was found
1: expected `</testsuites>`, but `</testsuite>` was found

For more help, visit our troubleshooting guide.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch 2 times, most recently from ccbd697 to eb3c0bcCompareMay 23, 2025 07:22
@mydea
mydea marked this pull request as ready for review May 23, 2025 07:22
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts Outdated
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from eb3c0bc to cb8e92eCompareMay 26, 2025 11:22

@edwardgou-sentryedwardgou-sentry left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me! There aren't many product areas in performance that specifically rely on navigations so I think this should be fine (and I think we'd consider surfacing redirects in those areas a bug anyways).

}

if (detectRedirects && optionalWindowDocument) {
addEventListener('click', () => (lastClickTimestamp = timestampInSeconds()), { capture: true, passive: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are there other events, such as key presses, that could indicate a user manually navigating?

@Lms24Lms24Jun 16, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, keypress might also be a good candidate, agreed.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 also looking at keypress

@Lms24Lms24 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the late review, but LGTM! I think we probably need to widen the timespan a bit because 300ms feel a bit fast to me (thinking of the endless redirects I get when doing SSO or stuff like this). But maybe it's good enough for now. I'd say its something we adjust on a per-feedback basis.

}

if (detectRedirects && optionalWindowDocument) {
addEventListener('click', () => (lastClickTimestamp = timestampInSeconds()), { capture: true, passive: true });

@Lms24Lms24Jun 16, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, keypress might also be a good candidate, agreed.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 19d02d3 to 67791e9CompareJune 17, 2025 10:27
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 67791e9 to e2018b5CompareJune 18, 2025 07:52
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from e2018b5 to 9dec9c3CompareJuly 7, 2025 14:42
cursor[bot]

This comment was marked as outdated.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 9dec9c3 to da0cffeCompareJuly 10, 2025 07:12

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Navigation URL Metadata Update Fails

The scope.setSDKProcessingMetadata is not updated for navigation spans if the URL is falsy or if the navigation is detected as a redirect. This prevents subsequent events from having the correct URL information on the scope. Additionally, the redirect detection logic uses inconsistent timestamp functions (timestampInSeconds vs dateTimestampInSeconds), which can lead to inaccurate timing comparisons.

packages/browser/src/tracing/browserTracingIntegration.ts#L469-L780

constinteractionHandler=(): void=>{
lastInteractionTimestamp=timestampInSeconds();
};
addEventListener('click',interactionHandler,{capture: true});
addEventListener('keydown',interactionHandler,{capture: true,passive: true});
}
functionmaybeEndActiveSpan(): void{
constactiveSpan=getActiveIdleSpan(client);
if(activeSpan&&!spanToJSON(activeSpan).timestamp){
DEBUG_BUILD&&logger.log(`[Tracing] Finishing current active span with op: ${spanToJSON(activeSpan).op}`);
// If there's an open active span, we need to finish it before creating an new one.
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,'cancelled');
activeSpan.end();
}
}
client.on('startNavigationSpan',(startSpanOptions,navigationOptions)=>{
if(getClient()!==client){
return;
}
if(navigationOptions?.isRedirect){
DEBUG_BUILD&&
logger.warn('[Tracing] Detected redirect, navigation span will not be the root span, but a child span.');
_createRouteSpan(
client,
{
op: 'navigation.redirect',
...startSpanOptions,
},
false,
);
return;
}
maybeEndActiveSpan();
getIsolationScope().setPropagationContext({traceId: generateTraceId(),sampleRand: Math.random()});
constscope=getCurrentScope();
scope.setPropagationContext({traceId: generateTraceId(),sampleRand: Math.random()});
// We reset this to ensure we do not have lingering incorrect data here
// places that call this hook may set this where appropriate - else, the URL at span sending time is used
scope.setSDKProcessingMetadata({
normalizedRequest: undefined,
});
_createRouteSpan(client,{
op: 'navigation',
...startSpanOptions,
});
});
client.on('startPageLoadSpan',(startSpanOptions,traceOptions={})=>{
if(getClient()!==client){
return;
}
maybeEndActiveSpan();
constsentryTrace=traceOptions.sentryTrace||getMetaContent('sentry-trace');
constbaggage=traceOptions.baggage||getMetaContent('baggage');
constpropagationContext=propagationContextFromHeaders(sentryTrace,baggage);
constscope=getCurrentScope();
scope.setPropagationContext(propagationContext);
// We store the normalized request data on the scope, so we get the request data at time of span creation
// otherwise, the URL etc. may already be of the following navigation, and we'd report the wrong URL
scope.setSDKProcessingMetadata({
normalizedRequest: getHttpRequestData(),
});
_createRouteSpan(client,{
op: 'pageload',
...startSpanOptions,
});
});
},
afterAllSetup(client){
letstartingUrl: string|undefined=getLocationHref();
if(linkPreviousTrace!=='off'){
linkTraces(client,{ linkPreviousTrace, consistentTraceSampling });
}
if(WINDOW.location){
if(instrumentPageLoad){
constorigin=browserPerformanceTimeOrigin();
startBrowserTracingPageLoadSpan(client,{
name: WINDOW.location.pathname,
// pageload should always start at timeOrigin (and needs to be in s, not ms)
startTime: origin ? origin/1000 : undefined,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser',
},
});
}
if(instrumentNavigation){
addHistoryInstrumentationHandler(({ to, from })=>{
/**
* This early return is there to account for some cases where a navigation transaction starts right after
* long-running pageload. We make sure that if `from` is undefined and a valid `startingURL` exists, we don't
* create an uneccessary navigation transaction.
*
* This was hard to duplicate, but this behavior stopped as soon as this fix was applied. This issue might also
* only be caused in certain development environments where the usage of a hot module reloader is causing
* errors.
*/
if(from===undefined&&startingUrl?.indexOf(to)!==-1){
startingUrl=undefined;
return;
}
startingUrl=undefined;
constparsed=parseStringToURLObject(to);
constactiveSpan=getActiveIdleSpan(client);
constnavigationIsRedirect=
activeSpan&&detectRedirects&&isRedirect(activeSpan,lastInteractionTimestamp);
startBrowserTracingNavigationSpan(
client,
{
name: parsed?.pathname||WINDOW.location.pathname,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser',
},
},
{url: to,isRedirect: navigationIsRedirect},
);
});
}
}
if(markBackgroundSpan){
registerBackgroundTabDetection();
}
if(enableInteractions){
registerInteractionListener(client,idleTimeout,finalTimeout,childSpanTimeout,latestRoute);
}
if(enableInp){
registerInpInteractionListener();
}
instrumentOutgoingRequests(client,{
traceFetch,
traceXHR,
trackFetchStreamPerformance,
tracePropagationTargets: client.getOptions().tracePropagationTargets,
shouldCreateSpanForRequest,
enableHTTPTimings,
onRequestSpanStart,
});
},
};
})satisfiesIntegrationFn;
/**
* Manually start a page load span.
* This will only do something if a browser tracing integration integration has been setup.
*
* If you provide a custom `traceOptions` object, it will be used to continue the trace
* instead of the default behavior, which is to look it up on the <meta> tags.
*/
exportfunctionstartBrowserTracingPageLoadSpan(
client: Client,
spanOptions: StartSpanOptions,
traceOptions?: {sentryTrace?: string|undefined;baggage?: string|undefined},
): Span|undefined{
client.emit('startPageLoadSpan',spanOptions,traceOptions);
getCurrentScope().setTransactionName(spanOptions.name);
returngetActiveIdleSpan(client);
}
/**
* Manually start a navigation span.
* This will only do something if a browser tracing integration has been setup.
*/
exportfunctionstartBrowserTracingNavigationSpan(
client: Client,
spanOptions: StartSpanOptions,
options?: {url?: string;isRedirect?: boolean},
): Span|undefined{
const{ url, isRedirect }=options||{};
client.emit('startNavigationSpan',spanOptions,{ isRedirect });
constscope=getCurrentScope();
scope.setTransactionName(spanOptions.name);
// We store the normalized request data on the scope, so we get the request data at time of span creation
// otherwise, the URL etc. may already be of the following navigation, and we'd report the wrong URL
if(url&&!isRedirect){
scope.setSDKProcessingMetadata({
normalizedRequest: {
...getHttpRequestData(),
url,
},
});
}
returngetActiveIdleSpan(client);
}
/** Returns the value of a meta tag */
exportfunctiongetMetaContent(metaName: string): string|undefined{
/**
* This is just a small wrapper that makes `document` optional.
* We want to be extra-safe and always check that this exists, to ensure weird environments do not blow up.
*/
constoptionalWindowDocument=WINDOW.documentas(typeofWINDOW)['document']|undefined;
constmetaTag=optionalWindowDocument?.querySelector(`meta[name=${metaName}]`);
returnmetaTag?.getAttribute('content')||undefined;
}
/** Start listener for interaction transactions */
functionregisterInteractionListener(
client: Client,
idleTimeout: BrowserTracingOptions['idleTimeout'],
finalTimeout: BrowserTracingOptions['finalTimeout'],
childSpanTimeout: BrowserTracingOptions['childSpanTimeout'],
latestRoute: RouteInfo,
): void{
/**
* This is just a small wrapper that makes `document` optional.
* We want to be extra-safe and always check that this exists, to ensure weird environments do not blow up.
*/
constoptionalWindowDocument=WINDOW.documentas(typeofWINDOW)['document']|undefined;
letinflightInteractionSpan: Span|undefined;
constregisterInteractionTransaction=(): void=>{
constop='ui.action.click';
constactiveIdleSpan=getActiveIdleSpan(client);
if(activeIdleSpan){
constcurrentRootSpanOp=spanToJSON(activeIdleSpan).op;
if(['navigation','pageload'].includes(currentRootSpanOpasstring)){
DEBUG_BUILD&&
logger.warn(`[Tracing] Did not create ${op} span because a pageload or navigation span is in progress.`);
returnundefined;
}
}
if(inflightInteractionSpan){
inflightInteractionSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,'interactionInterrupted');
inflightInteractionSpan.end();
inflightInteractionSpan=undefined;
}
if(!latestRoute.name){
DEBUG_BUILD&&logger.warn(`[Tracing] Did not create ${op} transaction because _latestRouteName is missing.`);
returnundefined;
}
inflightInteractionSpan=startIdleSpan(
{
name: latestRoute.name,
op,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: latestRoute.source||'url',
},
},
{
idleTimeout,
finalTimeout,
childSpanTimeout,
},
);
};
if(optionalWindowDocument){
addEventListener('click',registerInteractionTransaction,{capture: true});
}
}
// We store the active idle span on the client object, so we can access it from exported functions
constACTIVE_IDLE_SPAN_PROPERTY='_sentry_idleSpan';
functiongetActiveIdleSpan(client: Client): Span|undefined{
return(clientas{[ACTIVE_IDLE_SPAN_PROPERTY]?: Span})[ACTIVE_IDLE_SPAN_PROPERTY];
}
functionsetActiveIdleSpan(client: Client,span: Span|undefined): void{
addNonEnumerableProperty(client,ACTIVE_IDLE_SPAN_PROPERTY,span);
}
// The max. time in seconds between two pageload/navigation spans that makes us consider the second one a redirect
constREDIRECT_THRESHOLD=0.3;
functionisRedirect(activeSpan: Span,lastInteractionTimestamp: number|undefined): boolean{
constspanData=spanToJSON(activeSpan);
constnow=dateTimestampInSeconds();
// More than 300ms since last navigation/pageload span?
// --> never consider this a redirect
conststartTimestamp=spanData.start_timestamp;
if(now-startTimestamp>REDIRECT_THRESHOLD){
returnfalse;
}
// A click happened in the last 300ms?
// --> never consider this a redirect
if(lastInteractionTimestamp&&now-lastInteractionTimestamp<=REDIRECT_THRESHOLD){
returnfalse;

Fix in CursorFix in Web


Bug: Browser Tracing Integration Event Listener Leak

The browserTracingIntegration introduces a memory leak by adding global click and keydown event listeners for redirect detection without ever removing them. This causes listeners to accumulate when the integration is reinitialized or multiple instances are created, such as in SPAs, hot module reloading, or test environments. A cleanup mechanism is required to prevent this accumulation.

packages/browser/src/tracing/browserTracingIntegration.ts#L467-L475

if(detectRedirects&&optionalWindowDocument){
constinteractionHandler=(): void=>{
lastInteractionTimestamp=timestampInSeconds();
};
addEventListener('click',interactionHandler,{capture: true});
addEventListener('keydown',interactionHandler,{capture: true,passive: true});
}

Fix in CursorFix in Web


Was this report helpful? Give feedback by reacting with 👍 or 👎

@mydea
mydea merged commit 3e5eac5 into developJul 10, 2025
@mydea
mydea deleted the fn/detect-pageload-redirects branch July 10, 2025 08:19
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Distinguish redirects from user-initiated nagivations

5 participants

@mydea@Lms24@s1gr1d@bricefriha@edwardgou-sentry
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(browser): Detect redirects when emitting navigation spans - #16324

Merged
mydea merged 6 commits into
developfrom
fn/detect-pageload-redirects
Jul 10, 2025
Merged

feat(browser): Detect redirects when emitting navigation spans#16324
mydea merged 6 commits into
developfrom
fn/detect-pageload-redirects

Conversation

@mydea

Copy link
Copy Markdown
Member

Closes#15286

This PR adds a new option to browserTracingIntegration, detectRedirects, which is enabled by default. If this is enabled, the integration will try to detect if a navigation is actually a redirect based on a simple heuristic, and in this case, will not end the ongoing pageload/navigation, but instead let it run and create a navigation.redirect zero-duration span instead.

An example trace for this would be: https://sentry-sdks.sentry.io/explore/discover/trace/95280de69dc844448d39de7458eab527/?dataset=transactions&eventId=8a1150fd1dc846e4ac8420ccf03ad0ee&field=title&field=project&field=user.display&field=timestamp&name=All%20Errors&project=4504956726345728&query=&queryDataset=transaction-like&sort=-timestamp&source=discover&statsPeriod=5m&timestamp=1747646096&yAxis=count%28%29
image

Where the respective index route that triggered this has this code:

setTimeout(()=>{window.history.pushState({},"","/test-sub-page");fetch('https://example.com')},100);

The used heuristic is:

  • If the ongoing pageload/navigation was started less than 300ms ago...
  • ... and no click has happened in this time...
  • ... then we consider the navigation a redirect

this limit was chosen somewhat arbitrarily, open for other suggestions too.

While this logic will not be 100% bullet proof, it should be reliable enough and likely better than what we have today. Users can opt-out of this logic via browserTracingIntegration({ detectRedirects: false }), if needed.

@mydea
mydea requested review from Lms24, bcoe and s1gr1dMay 19, 2025 09:21
@mydeamydea self-assigned this May 19, 2025
@github-actions

github-actionsBot commented May 19, 2025

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser23.99 kB--
@sentry/browser - with treeshaking flags23.76 kB--
@sentry/browser (incl. Tracing)39.85 kB+0.6%+235 B 🔺
@sentry/browser (incl. Tracing, Replay)78.06 kB+0.31%+238 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags71.09 kB+0.27%+187 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)82.77 kB+0.28%+225 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)94.99 kB+0.3%+277 B 🔺
@sentry/browser (incl. Feedback)40.76 kB--
@sentry/browser (incl. sendFeedback)28.7 kB--
@sentry/browser (incl. FeedbackAsync)33.59 kB--
@sentry/react25.76 kB--
@sentry/react (incl. Tracing)41.85 kB+0.58%+239 B 🔺
@sentry/vue28.37 kB--
@sentry/vue (incl. Tracing)41.66 kB+0.6%+246 B 🔺
@sentry/svelte24.01 kB--
CDN Bundle25.5 kB--
CDN Bundle (incl. Tracing)39.82 kB+0.48%+187 B 🔺
CDN Bundle (incl. Tracing, Replay)75.8 kB+0.25%+187 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)81.27 kB+0.24%+193 B 🔺
CDN Bundle - uncompressed74.5 kB--
CDN Bundle (incl. Tracing) - uncompressed118.25 kB+0.41%+481 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed232.55 kB+0.21%+481 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed245.38 kB+0.2%+481 B 🔺
@sentry/nextjs (client)43.48 kB+0.52%+222 B 🔺
@sentry/sveltekit (client)40.32 kB+0.59%+235 B 🔺
@sentry/node161.84 kB--
@sentry/node - without tracing98.79 kB--
@sentry/aws-serverless124.61 kB--

View base workflow run

@codecov

codecovBot commented May 19, 2025

Copy link
Copy Markdown

❌ Unsupported file format

Upload processing failed due to unsupported file format. Please review the parser error message:

Error parsing JUnit XML in /home/runner/work/sentry-javascript/sentry-javascript/packages/solidstart/vitest.junit.xml at 18:17
Caused by:
RuntimeError: Error parsing XML
Caused by:
0: ill-formed document: expected `</testsuites>`, but `</testsuite>` was found
1: expected `</testsuites>`, but `</testsuite>` was found

For more help, visit our troubleshooting guide.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch 2 times, most recently from ccbd697 to eb3c0bcCompareMay 23, 2025 07:22
@mydea
mydea marked this pull request as ready for review May 23, 2025 07:22
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts Outdated
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from eb3c0bc to cb8e92eCompareMay 26, 2025 11:22

@edwardgou-sentryedwardgou-sentry left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me! There aren't many product areas in performance that specifically rely on navigations so I think this should be fine (and I think we'd consider surfacing redirects in those areas a bug anyways).

}

if (detectRedirects && optionalWindowDocument) {
addEventListener('click', () => (lastClickTimestamp = timestampInSeconds()), { capture: true, passive: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are there other events, such as key presses, that could indicate a user manually navigating?

@Lms24Lms24Jun 16, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, keypress might also be a good candidate, agreed.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 also looking at keypress

@Lms24Lms24 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the late review, but LGTM! I think we probably need to widen the timespan a bit because 300ms feel a bit fast to me (thinking of the endless redirects I get when doing SSO or stuff like this). But maybe it's good enough for now. I'd say its something we adjust on a per-feedback basis.

}

if (detectRedirects && optionalWindowDocument) {
addEventListener('click', () => (lastClickTimestamp = timestampInSeconds()), { capture: true, passive: true });

@Lms24Lms24Jun 16, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, keypress might also be a good candidate, agreed.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 19d02d3 to 67791e9CompareJune 17, 2025 10:27
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
Comment threadpackages/browser/src/tracing/browserTracingIntegration.ts
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 67791e9 to e2018b5CompareJune 18, 2025 07:52
@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from e2018b5 to 9dec9c3CompareJuly 7, 2025 14:42
cursor[bot]

This comment was marked as outdated.

@mydea
mydeaforce-pushed the fn/detect-pageload-redirects branch from 9dec9c3 to da0cffeCompareJuly 10, 2025 07:12

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Navigation URL Metadata Update Fails

The scope.setSDKProcessingMetadata is not updated for navigation spans if the URL is falsy or if the navigation is detected as a redirect. This prevents subsequent events from having the correct URL information on the scope. Additionally, the redirect detection logic uses inconsistent timestamp functions (timestampInSeconds vs dateTimestampInSeconds), which can lead to inaccurate timing comparisons.

packages/browser/src/tracing/browserTracingIntegration.ts#L469-L780

constinteractionHandler=(): void=>{
lastInteractionTimestamp=timestampInSeconds();
};
addEventListener('click',interactionHandler,{capture: true});
addEventListener('keydown',interactionHandler,{capture: true,passive: true});
}
functionmaybeEndActiveSpan(): void{
constactiveSpan=getActiveIdleSpan(client);
if(activeSpan&&!spanToJSON(activeSpan).timestamp){
DEBUG_BUILD&&logger.log(`[Tracing] Finishing current active span with op: ${spanToJSON(activeSpan).op}`);
// If there's an open active span, we need to finish it before creating an new one.
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,'cancelled');
activeSpan.end();
}
}
client.on('startNavigationSpan',(startSpanOptions,navigationOptions)=>{
if(getClient()!==client){
return;
}
if(navigationOptions?.isRedirect){
DEBUG_BUILD&&
logger.warn('[Tracing] Detected redirect, navigation span will not be the root span, but a child span.');
_createRouteSpan(
client,
{
op: 'navigation.redirect',
...startSpanOptions,
},
false,
);
return;
}
maybeEndActiveSpan();
getIsolationScope().setPropagationContext({traceId: generateTraceId(),sampleRand: Math.random()});
constscope=getCurrentScope();
scope.setPropagationContext({traceId: generateTraceId(),sampleRand: Math.random()});
// We reset this to ensure we do not have lingering incorrect data here
// places that call this hook may set this where appropriate - else, the URL at span sending time is used
scope.setSDKProcessingMetadata({
normalizedRequest: undefined,
});
_createRouteSpan(client,{
op: 'navigation',
...startSpanOptions,
});
});
client.on('startPageLoadSpan',(startSpanOptions,traceOptions={})=>{
if(getClient()!==client){
return;
}
maybeEndActiveSpan();
constsentryTrace=traceOptions.sentryTrace||getMetaContent('sentry-trace');
constbaggage=traceOptions.baggage||getMetaContent('baggage');
constpropagationContext=propagationContextFromHeaders(sentryTrace,baggage);
constscope=getCurrentScope();
scope.setPropagationContext(propagationContext);
// We store the normalized request data on the scope, so we get the request data at time of span creation
// otherwise, the URL etc. may already be of the following navigation, and we'd report the wrong URL
scope.setSDKProcessingMetadata({
normalizedRequest: getHttpRequestData(),
});
_createRouteSpan(client,{
op: 'pageload',
...startSpanOptions,
});
});
},
afterAllSetup(client){
letstartingUrl: string|undefined=getLocationHref();
if(linkPreviousTrace!=='off'){
linkTraces(client,{ linkPreviousTrace, consistentTraceSampling });
}
if(WINDOW.location){
if(instrumentPageLoad){
constorigin=browserPerformanceTimeOrigin();
startBrowserTracingPageLoadSpan(client,{
name: WINDOW.location.pathname,
// pageload should always start at timeOrigin (and needs to be in s, not ms)
startTime: origin ? origin/1000 : undefined,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser',
},
});
}
if(instrumentNavigation){
addHistoryInstrumentationHandler(({ to, from })=>{
/**
* This early return is there to account for some cases where a navigation transaction starts right after
* long-running pageload. We make sure that if `from` is undefined and a valid `startingURL` exists, we don't
* create an uneccessary navigation transaction.
*
* This was hard to duplicate, but this behavior stopped as soon as this fix was applied. This issue might also
* only be caused in certain development environments where the usage of a hot module reloader is causing
* errors.
*/
if(from===undefined&&startingUrl?.indexOf(to)!==-1){
startingUrl=undefined;
return;
}
startingUrl=undefined;
constparsed=parseStringToURLObject(to);
constactiveSpan=getActiveIdleSpan(client);
constnavigationIsRedirect=
activeSpan&&detectRedirects&&isRedirect(activeSpan,lastInteractionTimestamp);
startBrowserTracingNavigationSpan(
client,
{
name: parsed?.pathname||WINDOW.location.pathname,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser',
},
},
{url: to,isRedirect: navigationIsRedirect},
);
});
}
}
if(markBackgroundSpan){
registerBackgroundTabDetection();
}
if(enableInteractions){
registerInteractionListener(client,idleTimeout,finalTimeout,childSpanTimeout,latestRoute);
}
if(enableInp){
registerInpInteractionListener();
}
instrumentOutgoingRequests(client,{
traceFetch,
traceXHR,
trackFetchStreamPerformance,
tracePropagationTargets: client.getOptions().tracePropagationTargets,
shouldCreateSpanForRequest,
enableHTTPTimings,
onRequestSpanStart,
});
},
};
})satisfiesIntegrationFn;
/**
* Manually start a page load span.
* This will only do something if a browser tracing integration integration has been setup.
*
* If you provide a custom `traceOptions` object, it will be used to continue the trace
* instead of the default behavior, which is to look it up on the <meta> tags.
*/
exportfunctionstartBrowserTracingPageLoadSpan(
client: Client,
spanOptions: StartSpanOptions,
traceOptions?: {sentryTrace?: string|undefined;baggage?: string|undefined},
): Span|undefined{
client.emit('startPageLoadSpan',spanOptions,traceOptions);
getCurrentScope().setTransactionName(spanOptions.name);
returngetActiveIdleSpan(client);
}
/**
* Manually start a navigation span.
* This will only do something if a browser tracing integration has been setup.
*/
exportfunctionstartBrowserTracingNavigationSpan(
client: Client,
spanOptions: StartSpanOptions,
options?: {url?: string;isRedirect?: boolean},
): Span|undefined{
const{ url, isRedirect }=options||{};
client.emit('startNavigationSpan',spanOptions,{ isRedirect });
constscope=getCurrentScope();
scope.setTransactionName(spanOptions.name);
// We store the normalized request data on the scope, so we get the request data at time of span creation
// otherwise, the URL etc. may already be of the following navigation, and we'd report the wrong URL
if(url&&!isRedirect){
scope.setSDKProcessingMetadata({
normalizedRequest: {
...getHttpRequestData(),
url,
},
});
}
returngetActiveIdleSpan(client);
}
/** Returns the value of a meta tag */
exportfunctiongetMetaContent(metaName: string): string|undefined{
/**
* This is just a small wrapper that makes `document` optional.
* We want to be extra-safe and always check that this exists, to ensure weird environments do not blow up.
*/
constoptionalWindowDocument=WINDOW.documentas(typeofWINDOW)['document']|undefined;
constmetaTag=optionalWindowDocument?.querySelector(`meta[name=${metaName}]`);
returnmetaTag?.getAttribute('content')||undefined;
}
/** Start listener for interaction transactions */
functionregisterInteractionListener(
client: Client,
idleTimeout: BrowserTracingOptions['idleTimeout'],
finalTimeout: BrowserTracingOptions['finalTimeout'],
childSpanTimeout: BrowserTracingOptions['childSpanTimeout'],
latestRoute: RouteInfo,
): void{
/**
* This is just a small wrapper that makes `document` optional.
* We want to be extra-safe and always check that this exists, to ensure weird environments do not blow up.
*/
constoptionalWindowDocument=WINDOW.documentas(typeofWINDOW)['document']|undefined;
letinflightInteractionSpan: Span|undefined;
constregisterInteractionTransaction=(): void=>{
constop='ui.action.click';
constactiveIdleSpan=getActiveIdleSpan(client);
if(activeIdleSpan){
constcurrentRootSpanOp=spanToJSON(activeIdleSpan).op;
if(['navigation','pageload'].includes(currentRootSpanOpasstring)){
DEBUG_BUILD&&
logger.warn(`[Tracing] Did not create ${op} span because a pageload or navigation span is in progress.`);
returnundefined;
}
}
if(inflightInteractionSpan){
inflightInteractionSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,'interactionInterrupted');
inflightInteractionSpan.end();
inflightInteractionSpan=undefined;
}
if(!latestRoute.name){
DEBUG_BUILD&&logger.warn(`[Tracing] Did not create ${op} transaction because _latestRouteName is missing.`);
returnundefined;
}
inflightInteractionSpan=startIdleSpan(
{
name: latestRoute.name,
op,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: latestRoute.source||'url',
},
},
{
idleTimeout,
finalTimeout,
childSpanTimeout,
},
);
};
if(optionalWindowDocument){
addEventListener('click',registerInteractionTransaction,{capture: true});
}
}
// We store the active idle span on the client object, so we can access it from exported functions
constACTIVE_IDLE_SPAN_PROPERTY='_sentry_idleSpan';
functiongetActiveIdleSpan(client: Client): Span|undefined{
return(clientas{[ACTIVE_IDLE_SPAN_PROPERTY]?: Span})[ACTIVE_IDLE_SPAN_PROPERTY];
}
functionsetActiveIdleSpan(client: Client,span: Span|undefined): void{
addNonEnumerableProperty(client,ACTIVE_IDLE_SPAN_PROPERTY,span);
}
// The max. time in seconds between two pageload/navigation spans that makes us consider the second one a redirect
constREDIRECT_THRESHOLD=0.3;
functionisRedirect(activeSpan: Span,lastInteractionTimestamp: number|undefined): boolean{
constspanData=spanToJSON(activeSpan);
constnow=dateTimestampInSeconds();
// More than 300ms since last navigation/pageload span?
// --> never consider this a redirect
conststartTimestamp=spanData.start_timestamp;
if(now-startTimestamp>REDIRECT_THRESHOLD){
returnfalse;
}
// A click happened in the last 300ms?
// --> never consider this a redirect
if(lastInteractionTimestamp&&now-lastInteractionTimestamp<=REDIRECT_THRESHOLD){
returnfalse;

Fix in CursorFix in Web


Bug: Browser Tracing Integration Event Listener Leak

The browserTracingIntegration introduces a memory leak by adding global click and keydown event listeners for redirect detection without ever removing them. This causes listeners to accumulate when the integration is reinitialized or multiple instances are created, such as in SPAs, hot module reloading, or test environments. A cleanup mechanism is required to prevent this accumulation.

packages/browser/src/tracing/browserTracingIntegration.ts#L467-L475

if(detectRedirects&&optionalWindowDocument){
constinteractionHandler=(): void=>{
lastInteractionTimestamp=timestampInSeconds();
};
addEventListener('click',interactionHandler,{capture: true});
addEventListener('keydown',interactionHandler,{capture: true,passive: true});
}

Fix in CursorFix in Web


Was this report helpful? Give feedback by reacting with 👍 or 👎

@mydea
mydea merged commit 3e5eac5 into developJul 10, 2025
@mydea
mydea deleted the fn/detect-pageload-redirects branch July 10, 2025 08:19
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Distinguish redirects from user-initiated nagivations

5 participants

@mydea@Lms24@s1gr1d@bricefriha@edwardgou-sentry