diff --git a/packages/browser-integration-tests/suites/replay/slowClick/template.html b/packages/browser-integration-tests/suites/replay/slowClick/template.html
index 1cf757f7b974..763a24430b71 100644
--- a/packages/browser-integration-tests/suites/replay/slowClick/template.html
+++ b/packages/browser-integration-tests/suites/replay/slowClick/template.html
@@ -18,6 +18,7 @@
+
Link
Link external
@@ -69,6 +70,11 @@
Bottom
console.log('DONE');
}, 3001);
});
+ document.getElementById('windowOpenButton').addEventListener('click', () => {
+ setTimeout(() => {
+ window.open('.');
+ }, 3001);
+ });
// Do nothing on these elements
document
diff --git a/packages/browser-integration-tests/suites/replay/slowClick/windowOpen/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/windowOpen/test.ts
new file mode 100644
index 000000000000..d4cc0ae7de5a
--- /dev/null
+++ b/packages/browser-integration-tests/suites/replay/slowClick/windowOpen/test.ts
@@ -0,0 +1,62 @@
+import { expect } from '@playwright/test';
+
+import { sentryTest } from '../../../../utils/fixtures';
+import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers';
+
+sentryTest('window.open() is considered for slow click', async ({ getLocalTestUrl, page }) => {
+ if (shouldSkipReplayTest()) {
+ sentryTest.skip();
+ }
+
+ const reqPromise0 = waitForReplayRequest(page, 0);
+
+ await page.route('https://dsn.ingest.sentry.io/**/*', route => {
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ id: 'test-id' }),
+ });
+ });
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await page.goto(url);
+ await reqPromise0;
+
+ const reqPromise1 = waitForReplayRequest(page, (event, res) => {
+ const { breadcrumbs } = getCustomRecordingEvents(res);
+
+ return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected');
+ });
+
+ await page.click('#windowOpenButton');
+
+ const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
+
+ const slowClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.slowClickDetected');
+
+ expect(slowClickBreadcrumbs).toEqual([
+ {
+ category: 'ui.slowClickDetected',
+ data: {
+ endReason: 'window.open',
+ node: {
+ attributes: {
+ id: 'windowOpenButton',
+ },
+ id: expect.any(Number),
+ tagName: 'button',
+ textContent: '****** ****',
+ },
+ nodeId: expect.any(Number),
+ timeAfterClickMs: expect.any(Number),
+ url: 'http://sentry-test.io/index.html',
+ },
+ message: 'body > button#windowOpenButton',
+ timestamp: expect.any(Number),
+ },
+ ]);
+
+ expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeGreaterThan(3000);
+ expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(3100);
+});
diff --git a/packages/replay/src/coreHandlers/handleSlowClick.ts b/packages/replay/src/coreHandlers/handleSlowClick.ts
index c939a990f87a..9e486a060706 100644
--- a/packages/replay/src/coreHandlers/handleSlowClick.ts
+++ b/packages/replay/src/coreHandlers/handleSlowClick.ts
@@ -3,6 +3,7 @@ import type { Breadcrumb } from '@sentry/types';
import { WINDOW } from '../constants';
import type { ReplayContainer, SlowClickConfig } from '../types';
import { addBreadcrumbEvent } from './util/addBreadcrumbEvent';
+import { onWindowOpen } from './util/onWindowOpen';
type ClickBreadcrumb = Breadcrumb & {
timestamp: number;
@@ -61,6 +62,11 @@ export function detectSlowClick(
WINDOW.addEventListener('scroll', scrollHandler);
+ const cleanupWindowOpen = onWindowOpen(() => {
+ maybeHandleSlowClick(replay, clickBreadcrumb, config.threshold, config.timeout, 'window.open');
+ cleanup();
+ });
+
// Stop listening to scroll timeouts early
const scrollTimeout = setTimeout(() => {
WINDOW.removeEventListener('scroll', scrollHandler);
@@ -71,6 +77,7 @@ export function detectSlowClick(
clearTimeout(scrollTimeout);
obs.disconnect();
WINDOW.removeEventListener('scroll', scrollHandler);
+ cleanupWindowOpen();
};
}
diff --git a/packages/replay/src/coreHandlers/util/onWindowOpen.ts b/packages/replay/src/coreHandlers/util/onWindowOpen.ts
new file mode 100644
index 000000000000..e3b6b7ac92ed
--- /dev/null
+++ b/packages/replay/src/coreHandlers/util/onWindowOpen.ts
@@ -0,0 +1,44 @@
+import { fill } from '@sentry/utils';
+
+import { WINDOW } from '../../constants';
+
+type WindowOpenHandler = () => void;
+
+let handlers: undefined | WindowOpenHandler[];
+
+/**
+ * Register a handler to be called when `window.open()` is called.
+ * Returns a cleanup function.
+ */
+export function onWindowOpen(cb: WindowOpenHandler): () => void {
+ // Ensure to only register this once
+ if (!handlers) {
+ handlers = [];
+ monkeyPatchWindowOpen();
+ }
+
+ handlers.push(cb);
+
+ return () => {
+ const pos = handlers ? handlers.indexOf(cb) : -1;
+ if (pos > -1) {
+ (handlers as WindowOpenHandler[]).splice(pos, 1);
+ }
+ };
+}
+
+function monkeyPatchWindowOpen(): void {
+ fill(WINDOW, 'open', function (originalWindowOpen: () => void): () => void {
+ return function (...args: unknown[]): void {
+ if (handlers) {
+ try {
+ handlers.forEach(handler => handler());
+ } catch (e) {
+ // ignore errors in here
+ }
+ }
+
+ return originalWindowOpen.apply(WINDOW, args);
+ };
+ });
+}