diff --git a/packages/browser-integration-tests/suites/replay/slowClick/template.html b/packages/browser-integration-tests/suites/replay/slowClick/template.html
index f49c8b1d410d..83bda10053d1 100644
--- a/packages/browser-integration-tests/suites/replay/slowClick/template.html
+++ b/packages/browser-integration-tests/suites/replay/slowClick/template.html
@@ -19,6 +19,7 @@
+
Link
Link external
@@ -73,6 +74,9 @@
Bottom
document.getElementById('mouseDownButton').addEventListener('mousedown', () => {
document.getElementById('out').innerHTML += 'mutationButton clicked
';
});
+ document.getElementById('windowOpenButton').addEventListener('click', () => {
+ window.open('https://example.com/', '_self');
+ });
// 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..04dc4bb745e7
--- /dev/null
+++ b/packages/browser-integration-tests/suites/replay/slowClick/windowOpen/test.ts
@@ -0,0 +1,61 @@
+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.click');
+ });
+
+ await page.click('#windowOpenButton');
+ const navPromise = page.waitForURL('https://example.com/');
+
+ const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
+
+ expect(breadcrumbs).toEqual([
+ {
+ category: 'ui.click',
+ data: {
+ node: {
+ attributes: {
+ id: 'windowOpenButton',
+ },
+ id: expect.any(Number),
+ tagName: 'button',
+ textContent: '****** ****',
+ },
+ nodeId: expect.any(Number),
+ },
+ message: 'body > button#windowOpenButton',
+ timestamp: expect.any(Number),
+ type: 'default',
+ },
+ ]);
+
+ await navPromise;
+
+ // Ensure window.open() still works as expected
+ expect(await page.url()).toBe('https://example.com/');
+});
diff --git a/packages/replay/src/coreHandlers/handleClick.ts b/packages/replay/src/coreHandlers/handleClick.ts
index 5a75413b00d1..c5a65ddfdca0 100644
--- a/packages/replay/src/coreHandlers/handleClick.ts
+++ b/packages/replay/src/coreHandlers/handleClick.ts
@@ -4,6 +4,7 @@ import { WINDOW } from '../constants';
import type { MultiClickFrame, ReplayClickDetector, ReplayContainer, SlowClickConfig, SlowClickFrame } from '../types';
import { addBreadcrumbEvent } from './util/addBreadcrumbEvent';
import { getClickTargetNode } from './util/domUtils';
+import { onWindowOpen } from './util/onWindowOpen';
type ClickBreadcrumb = Breadcrumb & {
timestamp: number;
@@ -68,6 +69,11 @@ export class ClickDetector implements ReplayClickDetector {
this._lastScroll = nowInSeconds();
};
+ const cleanupWindowOpen = onWindowOpen(() => {
+ // Treat window.open as mutation
+ this._lastMutation = nowInSeconds();
+ });
+
const clickHandler = (event: MouseEvent): void => {
if (!event.target) {
return;
@@ -94,6 +100,7 @@ export class ClickDetector implements ReplayClickDetector {
this._teardown = () => {
WINDOW.removeEventListener('scroll', scrollHandler);
WINDOW.removeEventListener('click', clickHandler);
+ cleanupWindowOpen();
obs.disconnect();
this._clicks = [];
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);
+ };
+ });
+}