diff --git a/CHANGELOG.md b/CHANGELOG.md
index 090ec65be827..8c123a1545b5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,15 @@
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
+## 7.55.0
+
+- feat(replay): Capture slow clicks (GA) (#8298)
+- feat(replay): Improve types for replay recording events (#8224)
+- fix(nextjs): Strip query params from transaction names of navigations to unknown routes (#8278)
+- fix(replay): Ignore max session life for buffered sessions (#8258)
+- fix(sveltekit): Export captureCheckIn (#8313)
+- ref(svelte): Add Svelte 4 as a peer dependency (#8280)
+
## 7.54.0
### Important Changes
diff --git a/packages/browser-integration-tests/suites/replay/slowClick/disable/init.js b/packages/browser-integration-tests/suites/replay/slowClick/disable/init.js
new file mode 100644
index 000000000000..28bb6ed8778e
--- /dev/null
+++ b/packages/browser-integration-tests/suites/replay/slowClick/disable/init.js
@@ -0,0 +1,17 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+window.Replay = new Sentry.Replay({
+ flushMinDelay: 500,
+ flushMaxDelay: 500,
+ slowClickTimeout: 0,
+});
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ sampleRate: 0,
+ replaysSessionSampleRate: 1.0,
+ replaysOnErrorSampleRate: 0.0,
+
+ integrations: [window.Replay],
+});
diff --git a/packages/browser-integration-tests/suites/replay/slowClick/disable/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/disable/test.ts
new file mode 100644
index 000000000000..1a88d992714e
--- /dev/null
+++ b/packages/browser-integration-tests/suites/replay/slowClick/disable/test.ts
@@ -0,0 +1,55 @@
+import { expect } from '@playwright/test';
+
+import { sentryTest } from '../../../../utils/fixtures';
+import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers';
+
+sentryTest('does not capture slow click when slowClickTimeout === 0', 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('#mutationButton');
+
+ const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
+
+ expect(breadcrumbs).toEqual([
+ {
+ category: 'ui.click',
+ data: {
+ node: {
+ attributes: {
+ id: 'mutationButton',
+ },
+ id: expect.any(Number),
+ tagName: 'button',
+ textContent: '******* ********',
+ },
+ nodeId: expect.any(Number),
+ },
+ message: 'body > button#mutationButton',
+ timestamp: expect.any(Number),
+ type: 'default',
+ },
+ ]);
+});
diff --git a/packages/browser-integration-tests/suites/replay/slowClick/ignore/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/ignore/test.ts
index c3cdb6e35c65..15e891b22e52 100644
--- a/packages/browser-integration-tests/suites/replay/slowClick/ignore/test.ts
+++ b/packages/browser-integration-tests/suites/replay/slowClick/ignore/test.ts
@@ -54,3 +54,54 @@ sentryTest('click is ignored on ignoreSelectors', async ({ getLocalTestUrl, page
},
]);
});
+
+sentryTest('click is ignored on div', 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('#mutationDiv');
+
+ const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
+
+ expect(breadcrumbs).toEqual([
+ {
+ category: 'ui.click',
+ data: {
+ node: {
+ attributes: {
+ id: 'mutationDiv',
+ },
+ id: expect.any(Number),
+ tagName: 'div',
+ textContent: '******* ********',
+ },
+ nodeId: expect.any(Number),
+ },
+ message: 'body > div#mutationDiv',
+ timestamp: expect.any(Number),
+ type: 'default',
+ },
+ ]);
+});
diff --git a/packages/browser-integration-tests/suites/replay/slowClick/init.js b/packages/browser-integration-tests/suites/replay/slowClick/init.js
index 2fc5dab81aea..1699d299530e 100644
--- a/packages/browser-integration-tests/suites/replay/slowClick/init.js
+++ b/packages/browser-integration-tests/suites/replay/slowClick/init.js
@@ -4,14 +4,8 @@ window.Sentry = Sentry;
window.Replay = new Sentry.Replay({
flushMinDelay: 500,
flushMaxDelay: 500,
- _experiments: {
- slowClicks: {
- threshold: 300,
- scrollThreshold: 300,
- timeout: 2000,
- ignoreSelectors: ['.ignore-class', '[ignore-attribute]'],
- },
- },
+ slowClickTimeout: 3100,
+ slowClickIgnoreSelectors: ['.ignore-class', '[ignore-attribute]'],
});
Sentry.init({
diff --git a/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts
index d7b5800f9eea..8a169a982aa8 100644
--- a/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts
+++ b/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts
@@ -59,8 +59,8 @@ sentryTest('mutation after threshold results in slow click', async ({ getLocalTe
},
]);
- expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeGreaterThan(300);
- expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(2000);
+ expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeGreaterThan(3000);
+ expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(3100);
});
sentryTest('immediate mutation does not trigger slow click', async ({ browserName, getLocalTestUrl, page }) => {
@@ -165,56 +165,3 @@ sentryTest('inline click handler does not trigger slow click', async ({ getLocal
},
]);
});
-
-sentryTest('click is not ignored on div', 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('#mutationDiv');
-
- const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
-
- expect(breadcrumbs.filter(({ category }) => category === 'ui.slowClickDetected')).toEqual([
- {
- category: 'ui.slowClickDetected',
- data: {
- endReason: 'mutation',
- node: {
- attributes: {
- id: 'mutationDiv',
- },
- id: expect.any(Number),
- tagName: 'div',
- textContent: '******* ********',
- },
- nodeId: expect.any(Number),
- timeAfterClickMs: expect.any(Number),
- url: 'http://sentry-test.io/index.html',
- },
- message: 'body > div#mutationDiv',
- timestamp: expect.any(Number),
- },
- ]);
-});
diff --git a/packages/browser-integration-tests/suites/replay/slowClick/template.html b/packages/browser-integration-tests/suites/replay/slowClick/template.html
index 07e12cc088f3..1cf757f7b974 100644
--- a/packages/browser-integration-tests/suites/replay/slowClick/template.html
+++ b/packages/browser-integration-tests/suites/replay/slowClick/template.html
@@ -36,22 +36,22 @@
Bottom
document.getElementById('mutationButton').addEventListener('click', () => {
setTimeout(() => {
document.getElementById('out').innerHTML += 'mutationButton clicked
';
- }, 400);
+ }, 3001);
});
document.getElementById('mutationIgnoreButton').addEventListener('click', () => {
setTimeout(() => {
document.getElementById('out').innerHTML += 'mutationIgnoreButton clicked
';
- }, 400);
+ }, 3001);
});
document.getElementById('mutationDiv').addEventListener('click', () => {
setTimeout(() => {
document.getElementById('out').innerHTML += 'mutationDiv clicked
';
- }, 400);
+ }, 3001);
});
document.getElementById('mutationButtonLate').addEventListener('click', () => {
setTimeout(() => {
document.getElementById('out').innerHTML += 'mutationButtonLate clicked
';
- }, 3000);
+ }, 3101);
});
document.getElementById('mutationButtonImmediately').addEventListener('click', () => {
document.getElementById('out').innerHTML += 'mutationButtonImmediately clicked
';
@@ -62,12 +62,12 @@ Bottom
document.getElementById('scrollLateButton').addEventListener('click', () => {
setTimeout(() => {
document.getElementById('h2').scrollIntoView({ behavior: 'smooth' });
- }, 400);
+ }, 3001);
});
document.getElementById('consoleLogButton').addEventListener('click', () => {
setTimeout(() => {
console.log('DONE');
- }, 400);
+ }, 3001);
});
// Do nothing on these elements
diff --git a/packages/browser-integration-tests/suites/replay/slowClick/timeout/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/timeout/test.ts
index e3fba57cd2b9..fef742681614 100644
--- a/packages/browser-integration-tests/suites/replay/slowClick/timeout/test.ts
+++ b/packages/browser-integration-tests/suites/replay/slowClick/timeout/test.ts
@@ -49,7 +49,7 @@ sentryTest('mutation after timeout results in slow click', async ({ getLocalTest
textContent: '******* ******** ****',
},
nodeId: expect.any(Number),
- timeAfterClickMs: 2000,
+ timeAfterClickMs: 3100,
url: 'http://sentry-test.io/index.html',
},
message: 'body > button#mutationButtonLate',
@@ -104,7 +104,7 @@ sentryTest('console.log results in slow click', async ({ getLocalTestUrl, page }
textContent: '******* ******* ***',
},
nodeId: expect.any(Number),
- timeAfterClickMs: 2000,
+ timeAfterClickMs: 3100,
url: 'http://sentry-test.io/index.html',
},
message: 'body > button#consoleLogButton',
diff --git a/packages/e2e-tests/lib/runAllTestApps.ts b/packages/e2e-tests/lib/runAllTestApps.ts
index ad30c34e4738..d5f4c247f2e6 100644
--- a/packages/e2e-tests/lib/runAllTestApps.ts
+++ b/packages/e2e-tests/lib/runAllTestApps.ts
@@ -8,7 +8,11 @@ export async function runAllTestApps(
recipePaths: string[],
envVarsToInject: Record,
): Promise {
- const maxParallel = process.env.CI ? 3 : 6;
+ const maxParallel = process.env.CANARY_E2E_TEST
+ ? 1 // TODO: figure out why concurrent tests fail for Next.js and remove this concurrency limitation
+ : process.env.CI
+ ? 3
+ : 6;
const recipeInstances = constructRecipeInstances(recipePaths);
diff --git a/packages/nextjs/src/client/performance.ts b/packages/nextjs/src/client/performance.ts
index 2212092d5045..0dd0b109408f 100644
--- a/packages/nextjs/src/client/performance.ts
+++ b/packages/nextjs/src/client/performance.ts
@@ -143,7 +143,8 @@ export function nextRouterInstrumentation(
if (startTransactionOnLocationChange) {
Router.events.on('routeChangeStart', (navigationTarget: string) => {
- const matchedRoute = getNextRouteFromPathname(stripUrlQueryAndFragment(navigationTarget));
+ const strippedNavigationTarget = stripUrlQueryAndFragment(navigationTarget);
+ const matchedRoute = getNextRouteFromPathname(strippedNavigationTarget);
let transactionName: string;
let transactionSource: TransactionSource;
@@ -152,7 +153,7 @@ export function nextRouterInstrumentation(
transactionName = matchedRoute;
transactionSource = 'route';
} else {
- transactionName = navigationTarget;
+ transactionName = strippedNavigationTarget;
transactionSource = 'url';
}
diff --git a/packages/nextjs/test/performance/client.test.ts b/packages/nextjs/test/performance/client.test.ts
index 0b3ae0e7437e..cc0f212cbf18 100644
--- a/packages/nextjs/test/performance/client.test.ts
+++ b/packages/nextjs/test/performance/client.test.ts
@@ -231,6 +231,7 @@ describe('nextRouterInstrumentation', () => {
['/news', '/news', 'route'],
['/news/', '/news', 'route'],
['/some-route-that-is-not-defined-12332', '/some-route-that-is-not-defined-12332', 'url'], // unknown route
+ ['/some-route-that-is-not-defined-12332?q=42', '/some-route-that-is-not-defined-12332', 'url'], // unknown route w/ query param
['/posts/42', '/posts/[id]', 'route'],
['/posts/42/', '/posts/[id]', 'route'],
['/posts/42?someParam=1', '/posts/[id]', 'route'], // query params are ignored
diff --git a/packages/replay/src/constants.ts b/packages/replay/src/constants.ts
index f9b452d3f04f..1801c34a4e8e 100644
--- a/packages/replay/src/constants.ts
+++ b/packages/replay/src/constants.ts
@@ -37,3 +37,8 @@ export const NETWORK_BODY_MAX_SIZE = 150_000;
/* The max size of a single console arg that is captured. Any arg larger than this will be truncated. */
export const CONSOLE_ARG_MAX_SIZE = 5_000;
+
+/* Min. time to wait before we consider something a slow click. */
+export const SLOW_CLICK_THRESHOLD = 3_000;
+/* For scroll actions after a click, we only look for a very short time period to detect programmatic scrolling. */
+export const SLOW_CLICK_SCROLL_TIMEOUT = 300;
diff --git a/packages/replay/src/coreHandlers/handleDom.ts b/packages/replay/src/coreHandlers/handleDom.ts
index 1e46a18864d5..54ab7ec8bb09 100644
--- a/packages/replay/src/coreHandlers/handleDom.ts
+++ b/packages/replay/src/coreHandlers/handleDom.ts
@@ -3,6 +3,7 @@ import { NodeType } from '@sentry-internal/rrweb-snapshot';
import type { Breadcrumb } from '@sentry/types';
import { htmlTreeAsString } from '@sentry/utils';
+import { SLOW_CLICK_SCROLL_TIMEOUT, SLOW_CLICK_THRESHOLD } from '../constants';
import type { ReplayContainer, SlowClickConfig } from '../types';
import { createBreadcrumb } from '../util/createBreadcrumb';
import { detectSlowClick } from './handleSlowClick';
@@ -17,14 +18,14 @@ export interface DomHandlerData {
export const handleDomListener: (replay: ReplayContainer) => (handlerData: DomHandlerData) => void = (
replay: ReplayContainer,
) => {
- const slowClickExperiment = replay.getOptions()._experiments.slowClicks;
+ const { slowClickTimeout, slowClickIgnoreSelectors } = replay.getOptions();
- const slowClickConfig: SlowClickConfig | undefined = slowClickExperiment
+ const slowClickConfig: SlowClickConfig | undefined = slowClickTimeout
? {
- threshold: slowClickExperiment.threshold,
- timeout: slowClickExperiment.timeout,
- scrollTimeout: slowClickExperiment.scrollTimeout,
- ignoreSelector: slowClickExperiment.ignoreSelectors ? slowClickExperiment.ignoreSelectors.join(',') : '',
+ threshold: Math.min(SLOW_CLICK_THRESHOLD, slowClickTimeout),
+ timeout: slowClickTimeout,
+ scrollTimeout: SLOW_CLICK_SCROLL_TIMEOUT,
+ ignoreSelector: slowClickIgnoreSelectors ? slowClickIgnoreSelectors.join(',') : '',
}
: undefined;
diff --git a/packages/replay/src/coreHandlers/handleKeyboardEvent.ts b/packages/replay/src/coreHandlers/handleKeyboardEvent.ts
index f7943d34fa4f..0f7560f39584 100644
--- a/packages/replay/src/coreHandlers/handleKeyboardEvent.ts
+++ b/packages/replay/src/coreHandlers/handleKeyboardEvent.ts
@@ -12,7 +12,10 @@ export function handleKeyboardEvent(replay: ReplayContainer, event: KeyboardEven
return;
}
- replay.triggerUserActivity();
+ // Update user activity, but do not restart recording as it can create
+ // noisy/low-value replays (e.g. user comes back from idle, hits alt-tab, new
+ // session with a single "keydown" breadcrumb is created)
+ replay.updateUserActivity();
const breadcrumb = getKeyboardBreadcrumb(event);
diff --git a/packages/replay/src/coreHandlers/handleScope.ts b/packages/replay/src/coreHandlers/handleScope.ts
index 8f937b140882..78d0b6dd3fd3 100644
--- a/packages/replay/src/coreHandlers/handleScope.ts
+++ b/packages/replay/src/coreHandlers/handleScope.ts
@@ -3,12 +3,19 @@ import { normalize } from '@sentry/utils';
import { CONSOLE_ARG_MAX_SIZE } from '../constants';
import type { ReplayContainer } from '../types';
+import type { ReplayFrame } from '../types/replayFrame';
import { createBreadcrumb } from '../util/createBreadcrumb';
import { fixJson } from '../util/truncateJson/fixJson';
import { addBreadcrumbEvent } from './util/addBreadcrumbEvent';
let _LAST_BREADCRUMB: null | Breadcrumb = null;
+type BreadcrumbWithCategory = Required>;
+
+function isBreadcrumbWithCategory(breadcrumb: Breadcrumb): breadcrumb is BreadcrumbWithCategory {
+ return !!breadcrumb.category;
+}
+
export const handleScopeListener: (replay: ReplayContainer) => (scope: Scope) => void =
(replay: ReplayContainer) =>
(scope: Scope): void => {
@@ -44,9 +51,9 @@ export function handleScope(scope: Scope): Breadcrumb | null {
_LAST_BREADCRUMB = newBreadcrumb;
if (
- newBreadcrumb.category &&
- (['fetch', 'xhr', 'sentry.event', 'sentry.transaction'].includes(newBreadcrumb.category) ||
- newBreadcrumb.category.startsWith('ui.'))
+ !isBreadcrumbWithCategory(newBreadcrumb) ||
+ ['fetch', 'xhr', 'sentry.event', 'sentry.transaction'].includes(newBreadcrumb.category) ||
+ newBreadcrumb.category.startsWith('ui.')
) {
return null;
}
@@ -59,7 +66,9 @@ export function handleScope(scope: Scope): Breadcrumb | null {
}
/** exported for tests only */
-export function normalizeConsoleBreadcrumb(breadcrumb: Breadcrumb): Breadcrumb {
+export function normalizeConsoleBreadcrumb(
+ breadcrumb: Omit & BreadcrumbWithCategory,
+): ReplayFrame {
const args = breadcrumb.data && breadcrumb.data.arguments;
if (!Array.isArray(args) || args.length === 0) {
diff --git a/packages/replay/src/coreHandlers/handleSlowClick.ts b/packages/replay/src/coreHandlers/handleSlowClick.ts
index c8a867209ffd..c939a990f87a 100644
--- a/packages/replay/src/coreHandlers/handleSlowClick.ts
+++ b/packages/replay/src/coreHandlers/handleSlowClick.ts
@@ -114,15 +114,16 @@ function handleSlowClick(
addBreadcrumbEvent(replay, breadcrumb);
}
-const SLOW_CLICK_IGNORE_TAGS = ['SELECT', 'OPTION'];
+const SLOW_CLICK_TAGS = ['A', 'BUTTON', 'INPUT'];
-function ignoreElement(node: HTMLElement, config: SlowClickConfig): boolean {
- // If tag, we only want to consider input[type='submit'] & input[type='button']
- if (node.tagName === 'INPUT' && !['submit', 'button'].includes(node.getAttribute('type') || '')) {
+/** exported for tests only */
+export function ignoreElement(node: HTMLElement, config: SlowClickConfig): boolean {
+ if (!SLOW_CLICK_TAGS.includes(node.tagName)) {
return true;
}
- if (SLOW_CLICK_IGNORE_TAGS.includes(node.tagName)) {
+ // If tag, we only want to consider input[type='submit'] & input[type='button']
+ if (node.tagName === 'INPUT' && !['submit', 'button'].includes(node.getAttribute('type') || '')) {
return true;
}
diff --git a/packages/replay/src/coreHandlers/util/getAttributesToRecord.ts b/packages/replay/src/coreHandlers/util/getAttributesToRecord.ts
index 7168a3243add..13c756901028 100644
--- a/packages/replay/src/coreHandlers/util/getAttributesToRecord.ts
+++ b/packages/replay/src/coreHandlers/util/getAttributesToRecord.ts
@@ -10,6 +10,8 @@ const ATTRIBUTES_TO_RECORD = new Set([
'title',
'data-test-id',
'data-testid',
+ 'disabled',
+ 'aria-disabled',
]);
/**
diff --git a/packages/replay/src/index.ts b/packages/replay/src/index.ts
index 0baefd4e9c37..3e6b01f39f5c 100644
--- a/packages/replay/src/index.ts
+++ b/packages/replay/src/index.ts
@@ -1 +1,14 @@
export { Replay } from './integration';
+export type {
+ BreadcrumbFrame,
+ BreadcrumbFrameEvent,
+ OptionFrameEvent,
+ ReplayFrame,
+ ReplayFrameEvent,
+ SpanFrame,
+ SpanFrameEvent,
+} from './types';
+export { EventType } from '@sentry-internal/rrweb';
+export { NodeType } from '@sentry-internal/rrweb-snapshot';
+export type { eventWithTime, fullSnapshotEvent } from '@sentry-internal/rrweb';
+export type { serializedNodeWithId } from '@sentry-internal/rrweb-snapshot';
diff --git a/packages/replay/src/integration.ts b/packages/replay/src/integration.ts
index 0a8813c14d38..2baf117b5c38 100644
--- a/packages/replay/src/integration.ts
+++ b/packages/replay/src/integration.ts
@@ -63,6 +63,9 @@ export class Replay implements Integration {
mutationBreadcrumbLimit = 750,
mutationLimit = 10_000,
+ slowClickTimeout = 7_000,
+ slowClickIgnoreSelectors = [],
+
networkDetailAllowUrls = [],
networkCaptureBodies = true,
networkRequestHeaders = [],
@@ -132,6 +135,8 @@ export class Replay implements Integration {
maskAllText,
mutationBreadcrumbLimit,
mutationLimit,
+ slowClickTimeout,
+ slowClickIgnoreSelectors,
networkDetailAllowUrls,
networkCaptureBodies,
networkRequestHeaders: _getMergedNetworkHeaders(networkRequestHeaders),
diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts
index 718b658ad82d..a00b96f23c19 100644
--- a/packages/replay/src/replay.ts
+++ b/packages/replay/src/replay.ts
@@ -1,7 +1,7 @@
/* eslint-disable max-lines */ // TODO: We might want to split this file up
import { EventType, record } from '@sentry-internal/rrweb';
import { captureException, getCurrentHub } from '@sentry/core';
-import type { Breadcrumb, ReplayRecordingMode, Transaction } from '@sentry/types';
+import type { ReplayRecordingMode, Transaction } from '@sentry/types';
import { logger } from '@sentry/utils';
import {
@@ -21,6 +21,7 @@ import type {
AddEventResult,
AddUpdateCallback,
AllPerformanceEntry,
+ BreadcrumbFrame,
EventBuffer,
InternalEventContext,
PopEventContext,
@@ -402,6 +403,8 @@ export class ReplayContainer implements ReplayContainerInterface {
return this.flushImmediate();
}
+ const activityTime = Date.now();
+
// Allow flush to complete before resuming as a session recording, otherwise
// the checkout from `startRecording` may be included in the payload.
// Prefer to keep the error replay as a separate (and smaller) segment
@@ -423,6 +426,18 @@ export class ReplayContainer implements ReplayContainerInterface {
// Once this session ends, we do not want to refresh it
if (this.session) {
this.session.shouldRefresh = false;
+
+ // It's possible that the session lifespan is > max session lifespan
+ // because we have been buffering beyond max session lifespan (we ignore
+ // expiration given that `shouldRefresh` is true). Since we flip
+ // `shouldRefresh`, the session could be considered expired due to
+ // lifespan, which is not what we want. Update session start date to be
+ // the current timestamp, so that session is not considered to be
+ // expired. This means that max replay duration can be MAX_SESSION_LIFE +
+ // (length of buffer), which we are ok with.
+ this._updateUserActivity(activityTime);
+ this._updateSessionActivity(activityTime);
+ this.session.started = activityTime;
this._maybeSaveSession();
}
@@ -486,6 +501,18 @@ export class ReplayContainer implements ReplayContainerInterface {
this._updateSessionActivity();
}
+ /**
+ * Updates the user activity timestamp *without* resuming
+ * recording. Some user events (e.g. keydown) can be create
+ * low-value replays that only contain the keypress as a
+ * breadcrumb. Instead this would require other events to
+ * create a new replay after a session has expired.
+ */
+ public updateUserActivity(): void {
+ this._updateUserActivity();
+ this._updateSessionActivity();
+ }
+
/**
* Only flush if `this.recordingMode === 'session'`
*/
@@ -676,7 +703,7 @@ export class ReplayContainer implements ReplayContainerInterface {
stickySession: Boolean(this._options.stickySession),
currentSession: this.session,
sessionSampleRate: this._options.sessionSampleRate,
- allowBuffering: this._options.errorSampleRate > 0,
+ allowBuffering: this._options.errorSampleRate > 0 || this.recordingMode === 'buffer',
});
// If session was newly created (i.e. was not loaded from storage), then
@@ -796,7 +823,7 @@ export class ReplayContainer implements ReplayContainerInterface {
/**
* Tasks to run when we consider a page to be hidden (via blurring and/or visibility)
*/
- private _doChangeToBackgroundTasks(breadcrumb?: Breadcrumb): void {
+ private _doChangeToBackgroundTasks(breadcrumb?: BreadcrumbFrame): void {
if (!this.session) {
return;
}
@@ -816,7 +843,7 @@ export class ReplayContainer implements ReplayContainerInterface {
/**
* Tasks to run when we consider a page to be visible (via focus and/or visibility)
*/
- private _doChangeToForegroundTasks(breadcrumb?: Breadcrumb): void {
+ private _doChangeToForegroundTasks(breadcrumb?: BreadcrumbFrame): void {
if (!this.session) {
return;
}
@@ -869,7 +896,7 @@ export class ReplayContainer implements ReplayContainerInterface {
/**
* Helper to create (and buffer) a replay breadcrumb from a core SDK breadcrumb
*/
- private _createCustomBreadcrumb(breadcrumb: Breadcrumb): void {
+ private _createCustomBreadcrumb(breadcrumb: BreadcrumbFrame): void {
this.addUpdate(() => {
void this.throttledAddEvent({
type: EventType.Custom,
diff --git a/packages/replay/src/session/getSession.ts b/packages/replay/src/session/getSession.ts
index ff993887e64b..73554a8860de 100644
--- a/packages/replay/src/session/getSession.ts
+++ b/packages/replay/src/session/getSession.ts
@@ -34,11 +34,13 @@ export function getSession({
// within "max session time").
const isExpired = isSessionExpired(session, timeouts);
- if (!isExpired) {
+ if (!isExpired || (allowBuffering && session.shouldRefresh)) {
return { type: 'saved', session };
} else if (!session.shouldRefresh) {
- // In this case, stop
- // This is the case if we have an error session that is completed (=triggered an error)
+ // This is the case if we have an error session that is completed
+ // (=triggered an error). Session will continue as session-based replay,
+ // and when this session is expired, it will not be renewed until user
+ // reloads.
const discardedSession = makeSession({ sampled: false });
return { type: 'new', session: discardedSession };
} else {
diff --git a/packages/replay/src/types/index.ts b/packages/replay/src/types/index.ts
new file mode 100644
index 000000000000..0fc39c9cd411
--- /dev/null
+++ b/packages/replay/src/types/index.ts
@@ -0,0 +1,5 @@
+export * from './performance';
+export * from './replay';
+export * from './replayFrame';
+export * from './request';
+export * from './rrweb';
diff --git a/packages/replay/src/types/performance.ts b/packages/replay/src/types/performance.ts
new file mode 100644
index 000000000000..49590a361cdb
--- /dev/null
+++ b/packages/replay/src/types/performance.ts
@@ -0,0 +1,164 @@
+import type { ReplayNetworkRequestOrResponse } from './request';
+
+export type AllPerformanceEntry = PerformancePaintTiming | PerformanceResourceTiming | PerformanceNavigationTiming;
+
+// PerformancePaintTiming and PerformanceNavigationTiming are only available with TS 4.4 and newer
+// Therefore, we're exporting them here to make them available in older TS versions
+export type PerformancePaintTiming = PerformanceEntry;
+export type PerformanceNavigationTiming = PerformanceEntry &
+ PerformanceResourceTiming & {
+ type: string;
+ transferSize: number;
+
+ /**
+ * A DOMHighResTimeStamp representing the time immediately before the user agent
+ * sets the document's readyState to "interactive".
+ */
+ domInteractive: number;
+
+ /**
+ * A DOMHighResTimeStamp representing the time immediately before the current
+ * document's DOMContentLoaded event handler starts.
+ */
+ domContentLoadedEventStart: number;
+ /**
+ * A DOMHighResTimeStamp representing the time immediately after the current
+ * document's DOMContentLoaded event handler completes.
+ */
+ domContentLoadedEventEnd: number;
+
+ /**
+ * A DOMHighResTimeStamp representing the time immediately before the current
+ * document's load event handler starts.
+ */
+ loadEventStart: number;
+
+ /**
+ * A DOMHighResTimeStamp representing the time immediately after the current
+ * document's load event handler completes.
+ */
+ loadEventEnd: number;
+
+ /**
+ * A DOMHighResTimeStamp representing the time immediately before the user agent
+ * sets the document's readyState to "complete".
+ */
+ domComplete: number;
+
+ /**
+ * A number representing the number of redirects since the last non-redirect
+ * navigation in the current browsing context.
+ */
+ redirectCount: number;
+ };
+export type ExperimentalPerformanceResourceTiming = PerformanceResourceTiming & {
+ // Experimental, see: https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/responseStatus
+ // Requires Chrome 109
+ responseStatus?: number;
+};
+
+export type PaintData = undefined;
+
+/**
+ * See https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming
+ *
+ * Note `navigation.push` will not have any data
+ */
+export type NavigationData = Partial<
+ Pick<
+ PerformanceNavigationTiming,
+ | 'decodedBodySize'
+ | 'encodedBodySize'
+ | 'duration'
+ | 'domInteractive'
+ | 'domContentLoadedEventEnd'
+ | 'domContentLoadedEventStart'
+ | 'loadEventStart'
+ | 'loadEventEnd'
+ | 'domComplete'
+ | 'redirectCount'
+ >
+> & {
+ /**
+ * Transfer size of resource
+ */
+ size?: number;
+};
+
+export type ResourceData = Pick & {
+ /**
+ * Transfer size of resource
+ */
+ size: number;
+ /**
+ * HTTP status code. Note this is experimental and not available on all browsers.
+ */
+ statusCode?: number;
+};
+
+export interface LargestContentfulPaintData {
+ /**
+ * Render time (in ms) of the LCP
+ */
+ value: number;
+ size: number;
+ /**
+ * The recording id of the LCP node. -1 if not found
+ */
+ nodeId?: number;
+}
+
+/**
+ * Entries that come from window.performance
+ */
+export type AllPerformanceEntryData = PaintData | NavigationData | ResourceData | LargestContentfulPaintData;
+
+export interface MemoryData {
+ memory: {
+ jsHeapSizeLimit: number;
+ totalJSHeapSize: number;
+ usedJSHeapSize: number;
+ };
+}
+
+export interface NetworkRequestData {
+ method?: string;
+ statusCode?: number;
+ requestBodySize?: number;
+ responseBodySize?: number;
+ request?: ReplayNetworkRequestOrResponse;
+ response?: ReplayNetworkRequestOrResponse;
+}
+
+export interface HistoryData {
+ previous: string;
+}
+
+export type AllEntryData = AllPerformanceEntryData | MemoryData | NetworkRequestData | HistoryData;
+
+export interface ReplayPerformanceEntry {
+ /**
+ * One of these types https://developer.mozilla.org/en-US/docs/Web/API/PerformanceEntry/entryType
+ */
+ type: string;
+
+ /**
+ * A more specific description of the performance entry
+ */
+ name: string;
+
+ /**
+ * The start timestamp in seconds
+ */
+ start: number;
+
+ /**
+ * The end timestamp in seconds
+ */
+ end: number;
+
+ /**
+ * Additional unstructured data to be included
+ */
+ data: T;
+}
diff --git a/packages/replay/src/types.ts b/packages/replay/src/types/replay.ts
similarity index 70%
rename from packages/replay/src/types.ts
rename to packages/replay/src/types/replay.ts
index 42758c1b06d9..2ec6e18346ee 100644
--- a/packages/replay/src/types.ts
+++ b/packages/replay/src/types/replay.ts
@@ -8,14 +8,15 @@ import type {
XhrBreadcrumbHint,
} from '@sentry/types';
-import type { eventWithTime, recordOptions } from './types/rrweb';
-import type { SKIPPED, THROTTLED } from './util/throttle';
+import type { SKIPPED, THROTTLED } from '../util/throttle';
+import type { AllPerformanceEntry } from './performance';
+import type { ReplayFrameEvent } from './replayFrame';
+import type { ReplayNetworkRequestOrResponse } from './request';
+import type { eventWithTime, recordOptions } from './rrweb';
-export type RecordingEvent = eventWithTime;
+export type RecordingEvent = ReplayFrameEvent | eventWithTime;
export type RecordingOptions = recordOptions;
-export type AllPerformanceEntry = PerformancePaintTiming | PerformanceResourceTiming | PerformanceNavigationTiming;
-
export interface SendReplayData {
recordingData: ReplayRecordingData;
replayId: string;
@@ -41,138 +42,6 @@ export interface WorkerRequest {
arg?: string;
}
-// PerformancePaintTiming and PerformanceNavigationTiming are only available with TS 4.4 and newer
-// Therefore, we're exporting them here to make them available in older TS versions
-export type PerformancePaintTiming = PerformanceEntry;
-export type PerformanceNavigationTiming = PerformanceEntry &
- PerformanceResourceTiming & {
- type: string;
- transferSize: number;
-
- /**
- * A DOMHighResTimeStamp representing the time immediately before the user agent
- * sets the document's readyState to "interactive".
- */
- domInteractive: number;
-
- /**
- * A DOMHighResTimeStamp representing the time immediately before the current
- * document's DOMContentLoaded event handler starts.
- */
- domContentLoadedEventStart: number;
- /**
- * A DOMHighResTimeStamp representing the time immediately after the current
- * document's DOMContentLoaded event handler completes.
- */
- domContentLoadedEventEnd: number;
-
- /**
- * A DOMHighResTimeStamp representing the time immediately before the current
- * document's load event handler starts.
- */
- loadEventStart: number;
-
- /**
- * A DOMHighResTimeStamp representing the time immediately after the current
- * document's load event handler completes.
- */
- loadEventEnd: number;
-
- /**
- * A DOMHighResTimeStamp representing the time immediately before the user agent
- * sets the document's readyState to "complete".
- */
- domComplete: number;
-
- /**
- * A number representing the number of redirects since the last non-redirect
- * navigation in the current browsing context.
- */
- redirectCount: number;
- };
-export type ExperimentalPerformanceResourceTiming = PerformanceResourceTiming & {
- // Experimental, see: https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/responseStatus
- // Requires Chrome 109
- responseStatus?: number;
-};
-
-export type PaintData = undefined;
-
-/**
- * See https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming
- *
- * Note `navigation.push` will not have any data
- */
-export type NavigationData = Partial<
- Pick<
- PerformanceNavigationTiming,
- | 'decodedBodySize'
- | 'encodedBodySize'
- | 'duration'
- | 'domInteractive'
- | 'domContentLoadedEventEnd'
- | 'domContentLoadedEventStart'
- | 'loadEventStart'
- | 'loadEventEnd'
- | 'domComplete'
- | 'redirectCount'
- >
-> & {
- /**
- * Transfer size of resource
- */
- size?: number;
-};
-
-export type ResourceData = Pick & {
- /**
- * Transfer size of resource
- */
- size: number;
- /**
- * HTTP status code. Note this is experimental and not available on all browsers.
- */
- statusCode?: number;
-};
-
-export interface LargestContentfulPaintData {
- /**
- * Render time (in ms) of the LCP
- */
- value: number;
- size: number;
- /**
- * The recording id of the LCP node. -1 if not found
- */
- nodeId?: number;
-}
-
-/**
- * Entries that come from window.performance
- */
-export type AllPerformanceEntryData = PaintData | NavigationData | ResourceData | LargestContentfulPaintData;
-
-export interface MemoryData {
- memory: {
- jsHeapSizeLimit: number;
- totalJSHeapSize: number;
- usedJSHeapSize: number;
- };
-}
-
-export interface NetworkRequestData {
- method?: string;
- statusCode?: number;
- requestBodySize?: number;
- responseBodySize?: number;
-}
-
-export interface HistoryData {
- previous: string;
-}
-
-export type AllEntryData = AllPerformanceEntryData | MemoryData | NetworkRequestData | HistoryData;
-
/**
* The response from the worker
*/
@@ -186,7 +55,7 @@ export interface WorkerResponse {
export type AddEventResult = void;
export interface BeforeAddRecordingEvent {
- (event: RecordingEvent): RecordingEvent | null | undefined;
+ (event: ReplayFrameEvent): ReplayFrameEvent | null | undefined;
}
export interface ReplayNetworkOptions {
@@ -287,6 +156,20 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions {
*/
mutationLimit: number;
+ /**
+ * The max. time in ms to wait for a slow click to finish.
+ * After this amount of time we stop waiting for actions after a click happened.
+ * Set this to 0 to disable slow click capture.
+ *
+ * Default: 7000ms
+ */
+ slowClickTimeout: number;
+
+ /**
+ * Ignore clicks on elements matching the given selectors for slow click detection.
+ */
+ slowClickIgnoreSelectors: string[];
+
/**
* Callback before adding a custom recording event
*
@@ -310,12 +193,6 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions {
_experiments: Partial<{
captureExceptions: boolean;
traceInternals: boolean;
- slowClicks: {
- threshold: number;
- timeout: number;
- scrollTimeout: number;
- ignoreSelectors: string[];
- };
delayFlushOnCheckout: number;
}>;
}
@@ -556,6 +433,7 @@ export interface ReplayContainer {
flushImmediate(): Promise;
cancelFlush(): void;
triggerUserActivity(): void;
+ updateUserActivity(): void;
addUpdate(cb: AddUpdateCallback): void;
getOptions(): ReplayPluginOptions;
getSessionId(): string | undefined;
@@ -564,33 +442,6 @@ export interface ReplayContainer {
getCurrentRoute(): string | undefined;
}
-export interface ReplayPerformanceEntry {
- /**
- * One of these types https://developer.mozilla.org/en-US/docs/Web/API/PerformanceEntry/entryType
- */
- type: string;
-
- /**
- * A more specific description of the performance entry
- */
- name: string;
-
- /**
- * The start timestamp in seconds
- */
- start: number;
-
- /**
- * The end timestamp in seconds
- */
- end: number;
-
- /**
- * Additional unstructured data to be included
- */
- data: T;
-}
-
type RequestBody = null | Blob | BufferSource | FormData | URLSearchParams | string;
export type XhrHint = XhrBreadcrumbHint & {
@@ -602,24 +453,6 @@ export type FetchHint = FetchBreadcrumbHint & {
response: Response;
};
-type JsonObject = Record;
-type JsonArray = unknown[];
-
-export type NetworkBody = JsonObject | JsonArray | string;
-
-export type NetworkMetaWarning = 'JSON_TRUNCATED' | 'TEXT_TRUNCATED' | 'INVALID_JSON' | 'URL_SKIPPED';
-
-interface NetworkMeta {
- warnings?: NetworkMetaWarning[];
-}
-
-export interface ReplayNetworkRequestOrResponse {
- size?: number;
- body?: NetworkBody;
- headers: Record;
- _meta?: NetworkMeta;
-}
-
export type ReplayNetworkRequestData = {
startTimestamp: number;
endTimestamp: number;
diff --git a/packages/replay/src/types/replayFrame.ts b/packages/replay/src/types/replayFrame.ts
new file mode 100644
index 000000000000..529b1b428a6f
--- /dev/null
+++ b/packages/replay/src/types/replayFrame.ts
@@ -0,0 +1,213 @@
+import type { EventType } from '@sentry-internal/rrweb';
+import type { Breadcrumb } from '@sentry/types';
+
+import type {
+ HistoryData,
+ LargestContentfulPaintData,
+ MemoryData,
+ NavigationData,
+ NetworkRequestData,
+ PaintData,
+ ResourceData,
+} from './performance';
+
+interface BaseBreadcrumbFrame {
+ timestamp: number;
+ /**
+ * For compatibility reasons
+ */
+ type: string;
+ category: string;
+ data?: Record;
+ message?: string;
+}
+
+interface BaseDomFrameData {
+ nodeId?: number;
+ node?: {
+ id: number;
+ tagName: string;
+ textContent: string;
+ attributes: Record;
+ };
+}
+
+/* Breadcrumbs from Core SDK */
+interface ConsoleFrameData {
+ logger: string;
+ arguments?: unknown[];
+}
+interface ConsoleFrame extends BaseBreadcrumbFrame {
+ category: 'console';
+ level: Breadcrumb['level'];
+ message: string;
+ data: ConsoleFrameData;
+}
+
+type ClickFrameData = BaseDomFrameData;
+interface ClickFrame extends BaseBreadcrumbFrame {
+ category: 'ui.click';
+ message: string;
+ data: ClickFrameData;
+}
+
+interface InputFrame extends BaseBreadcrumbFrame {
+ category: 'ui.input';
+ message: string;
+}
+
+/* Breadcrumbs from Replay */
+interface MutationFrameData {
+ count: number;
+ limit: boolean;
+}
+interface MutationFrame extends BaseBreadcrumbFrame {
+ category: 'replay.mutations';
+ data: MutationFrameData;
+}
+
+interface KeyboardEventFrameData extends BaseDomFrameData {
+ metaKey: boolean;
+ shiftKey: boolean;
+ ctrlKey: boolean;
+ altKey: boolean;
+ key: string;
+}
+interface KeyboardEventFrame extends BaseBreadcrumbFrame {
+ category: 'ui.keyDown';
+ data: KeyboardEventFrameData;
+}
+
+interface BlurFrame extends BaseBreadcrumbFrame {
+ category: 'ui.blur';
+}
+
+interface FocusFrame extends BaseBreadcrumbFrame {
+ category: 'ui.focus';
+}
+
+interface SlowClickFrameData extends ClickFrameData {
+ url: string;
+ timeAfterClickFs: number;
+ endReason: string;
+}
+interface SlowClickFrame extends BaseBreadcrumbFrame {
+ category: 'ui.slowClickDetected';
+ data: SlowClickFrameData;
+}
+
+interface OptionFrame {
+ blockAllMedia: boolean;
+ errorSampleRate: number;
+ maskAllInputs: boolean;
+ maskAllText: boolean;
+ networkCaptureBodies: boolean;
+ networkDetailHasUrls: boolean;
+ networkRequestHasHeaders: boolean;
+ networkResponseHasHeaders: boolean;
+ sessionSampleRate: number;
+ useCompression: boolean;
+ useCompressionOption: boolean;
+}
+
+export type BreadcrumbFrame =
+ | ConsoleFrame
+ | ClickFrame
+ | InputFrame
+ | KeyboardEventFrame
+ | BlurFrame
+ | FocusFrame
+ | SlowClickFrame
+ | MutationFrame
+ | BaseBreadcrumbFrame;
+
+interface BaseSpanFrame {
+ op: string;
+ description: string;
+ startTimestamp: number;
+ endTimestamp: number;
+ data?: undefined | Record;
+}
+
+interface HistoryFrame extends BaseSpanFrame {
+ data: HistoryData;
+ op: 'navigation.push';
+}
+
+interface LargestContentfulPaintFrame extends BaseSpanFrame {
+ data: LargestContentfulPaintData;
+ op: 'largest-contentful-paint';
+}
+
+interface MemoryFrame extends BaseSpanFrame {
+ data: MemoryData;
+ op: 'memory';
+}
+
+interface NavigationFrame extends BaseSpanFrame {
+ data: NavigationData;
+ op: 'navigation.navigate' | 'navigation.reload' | 'navigation.back_forward';
+}
+
+interface PaintFrame extends BaseSpanFrame {
+ data: PaintData;
+ op: 'paint';
+}
+
+interface RequestFrame extends BaseSpanFrame {
+ data: NetworkRequestData;
+ op: 'resource.fetch' | 'resource.xhr';
+}
+
+interface ResourceFrame extends BaseSpanFrame {
+ data: ResourceData;
+ op: 'resource.css' | 'resource.iframe' | 'resource.img' | 'resource.link' | 'resource.other' | 'resource.script';
+}
+
+export type SpanFrame =
+ | BaseSpanFrame
+ | HistoryFrame
+ | RequestFrame
+ | LargestContentfulPaintFrame
+ | MemoryFrame
+ | NavigationFrame
+ | PaintFrame
+ | ResourceFrame;
+
+export type ReplayFrame = BreadcrumbFrame | SpanFrame;
+
+interface RecordingCustomEvent {
+ type: EventType.Custom;
+ timestamp: number;
+ data: {
+ tag: string;
+ payload: unknown;
+ };
+}
+
+export interface BreadcrumbFrameEvent extends RecordingCustomEvent {
+ data: {
+ tag: 'breadcrumb';
+ payload: BreadcrumbFrame;
+ /**
+ * This will indicate to backend to additionally log as a metric
+ */
+ metric?: boolean;
+ };
+}
+
+export interface SpanFrameEvent extends RecordingCustomEvent {
+ data: {
+ tag: 'performanceSpan';
+ payload: SpanFrame;
+ };
+}
+
+export interface OptionFrameEvent extends RecordingCustomEvent {
+ data: {
+ tag: 'options';
+ payload: OptionFrame;
+ };
+}
+
+export type ReplayFrameEvent = BreadcrumbFrameEvent | SpanFrameEvent | OptionFrameEvent;
diff --git a/packages/replay/src/types/request.ts b/packages/replay/src/types/request.ts
new file mode 100644
index 000000000000..ae24574d4b7e
--- /dev/null
+++ b/packages/replay/src/types/request.ts
@@ -0,0 +1,17 @@
+type JsonObject = Record;
+type JsonArray = unknown[];
+
+export type NetworkBody = JsonObject | JsonArray | string;
+
+export type NetworkMetaWarning = 'JSON_TRUNCATED' | 'TEXT_TRUNCATED' | 'INVALID_JSON' | 'URL_SKIPPED';
+
+interface NetworkMeta {
+ warnings?: NetworkMetaWarning[];
+}
+
+export interface ReplayNetworkRequestOrResponse {
+ size?: number;
+ body?: NetworkBody;
+ headers: Record;
+ _meta?: NetworkMeta;
+}
diff --git a/packages/replay/src/types/rrweb.ts b/packages/replay/src/types/rrweb.ts
index 7f2dfec78110..cfbe4cddf4c3 100644
--- a/packages/replay/src/types/rrweb.ts
+++ b/packages/replay/src/types/rrweb.ts
@@ -1,18 +1,10 @@
/* eslint-disable @typescript-eslint/naming-convention */
+import type { EventType } from '@sentry-internal/rrweb';
+
type blockClass = string | RegExp;
type maskTextClass = string | RegExp;
-export enum EventType {
- DomContentLoaded = 0,
- Load = 1,
- FullSnapshot = 2,
- IncrementalSnapshot = 3,
- Meta = 4,
- Custom = 5,
- Plugin = 6,
-}
-
/**
* This is a partial copy of rrweb's eventWithTime type which only contains the properties
* we specifcally need in the SDK.
diff --git a/packages/replay/src/util/addEvent.ts b/packages/replay/src/util/addEvent.ts
index e40a9c2f6486..16f653e9fc5d 100644
--- a/packages/replay/src/util/addEvent.ts
+++ b/packages/replay/src/util/addEvent.ts
@@ -1,10 +1,14 @@
+import { EventType } from '@sentry-internal/rrweb';
import { getCurrentHub } from '@sentry/core';
import { logger } from '@sentry/utils';
-import type { AddEventResult, RecordingEvent, ReplayContainer } from '../types';
-import { EventType } from '../types/rrweb';
+import type { AddEventResult, RecordingEvent, ReplayContainer, ReplayFrameEvent } from '../types';
import { timestampToMs } from './timestampToMs';
+function isCustomEvent(event: RecordingEvent): event is ReplayFrameEvent {
+ return event.type === EventType.Custom;
+}
+
/**
* Add an event to the event buffer.
* `isCheckout` is true if this is either the very first event, or an event triggered by `checkoutEveryNms`.
@@ -42,7 +46,7 @@ export async function addEvent(
const replayOptions = replay.getOptions();
const eventAfterPossibleCallback =
- typeof replayOptions.beforeAddRecordingEvent === 'function' && event.type === EventType.Custom
+ typeof replayOptions.beforeAddRecordingEvent === 'function' && isCustomEvent(event)
? replayOptions.beforeAddRecordingEvent(event)
: event;
diff --git a/packages/replay/src/util/createBreadcrumb.ts b/packages/replay/src/util/createBreadcrumb.ts
index b8ff6097d571..5cf044333876 100644
--- a/packages/replay/src/util/createBreadcrumb.ts
+++ b/packages/replay/src/util/createBreadcrumb.ts
@@ -1,13 +1,11 @@
-import type { Breadcrumb } from '@sentry/types';
-
-type RequiredProperties = 'category' | 'message';
+import type { BreadcrumbFrame } from '../types/replayFrame';
/**
* Create a breadcrumb for a replay.
*/
export function createBreadcrumb(
- breadcrumb: Pick & Partial>,
-): Breadcrumb {
+ breadcrumb: Omit & Partial>,
+): BreadcrumbFrame {
return {
timestamp: Date.now() / 1000,
type: 'default',
diff --git a/packages/replay/src/util/handleRecordingEmit.ts b/packages/replay/src/util/handleRecordingEmit.ts
index 3a9dcc211edd..cc7c87afed48 100644
--- a/packages/replay/src/util/handleRecordingEmit.ts
+++ b/packages/replay/src/util/handleRecordingEmit.ts
@@ -1,8 +1,8 @@
+import { EventType } from '@sentry-internal/rrweb';
import { logger } from '@sentry/utils';
import { saveSession } from '../session/saveSession';
-import type { AddEventResult, RecordingEvent, ReplayContainer } from '../types';
-import { EventType } from '../types/rrweb';
+import type { AddEventResult, OptionFrameEvent, RecordingEvent, ReplayContainer } from '../types';
import { addEvent } from './addEvent';
type RecordingEmitCallback = (event: RecordingEvent, isCheckout?: boolean) => void;
@@ -89,6 +89,10 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa
// a previous session ID. In this case, we want to buffer events
// for a set amount of time before flushing. This can help avoid
// capturing replays of users that immediately close the window.
+ // TODO: We should check `recordingMode` here and do nothing if it's
+ // buffer, instead of checking inside of timeout, this will make our
+ // tests a bit cleaner as we will need to wait on the delay in order to
+ // do nothing.
setTimeout(() => replay.conditionalFlush(), options._experiments.delayFlushOnCheckout);
// Cancel any previously debounced flushes to ensure there are no [near]
@@ -121,7 +125,7 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa
/**
* Exported for tests
*/
-export function createOptionsEvent(replay: ReplayContainer): RecordingEvent {
+export function createOptionsEvent(replay: ReplayContainer): OptionFrameEvent {
const options = replay.getOptions();
return {
type: EventType.Custom,
diff --git a/packages/replay/test/integration/beforeAddRecordingEvent.test.ts b/packages/replay/test/integration/beforeAddRecordingEvent.test.ts
index c01140045389..ba1a63d904fd 100644
--- a/packages/replay/test/integration/beforeAddRecordingEvent.test.ts
+++ b/packages/replay/test/integration/beforeAddRecordingEvent.test.ts
@@ -2,7 +2,7 @@ import * as SentryCore from '@sentry/core';
import type { Transport } from '@sentry/types';
import * as SentryUtils from '@sentry/utils';
-import type { Replay } from '../../src';
+import type { EventType, Replay } from '../../src';
import type { ReplayContainer } from '../../src/replay';
import { clearSession } from '../../src/session/clearSession';
import * as SendReplayRequest from '../../src/util/sendReplayRequest';
@@ -37,7 +37,7 @@ describe('Integration | beforeAddRecordingEvent', () => {
({ replay, integration } = await mockSdk({
replayOptions: {
beforeAddRecordingEvent: event => {
- const eventData = event.data as Record;
+ const eventData = event.data;
if (eventData.tag === 'breadcrumb' && eventData.payload.category === 'ui.click') {
return {
@@ -53,8 +53,8 @@ describe('Integration | beforeAddRecordingEvent', () => {
}
// This should not do anything because callback should not be called
- // for `event.type != 5`
- if (event.type === 2) {
+ // for `event.type != 5` - but we guard anyhow to be safe
+ if ((event.type as EventType) === 2) {
return null;
}
diff --git a/packages/replay/test/integration/coreHandlers/handleScope.test.ts b/packages/replay/test/integration/coreHandlers/handleScope.test.ts
index ec17d430ff52..d9d30d710a6a 100644
--- a/packages/replay/test/integration/coreHandlers/handleScope.test.ts
+++ b/packages/replay/test/integration/coreHandlers/handleScope.test.ts
@@ -23,10 +23,10 @@ describe('Integration | coreHandlers | handleScope', () => {
expect(mockHandleScopeListener).toHaveBeenCalledTimes(1);
- getCurrentHub().getScope()?.addBreadcrumb({ message: 'testing' });
+ getCurrentHub().getScope()?.addBreadcrumb({ category: 'console', message: 'testing' });
expect(mockHandleScope).toHaveBeenCalledTimes(1);
- expect(mockHandleScope).toHaveReturnedWith(expect.objectContaining({ message: 'testing' }));
+ expect(mockHandleScope).toHaveReturnedWith(expect.objectContaining({ category: 'console', message: 'testing' }));
mockHandleScope.mockClear();
diff --git a/packages/replay/test/integration/errorSampleRate-delayFlush.test.ts b/packages/replay/test/integration/errorSampleRate-delayFlush.test.ts
index 20645b1b85a4..f691d8e953c1 100644
--- a/packages/replay/test/integration/errorSampleRate-delayFlush.test.ts
+++ b/packages/replay/test/integration/errorSampleRate-delayFlush.test.ts
@@ -573,6 +573,7 @@ describe('Integration | errorSampleRate with delayed flush', () => {
it('has correct timestamps when error occurs much later than initial pageload/checkout', async () => {
const ELAPSED = BUFFER_CHECKOUT_TIME;
+ const TICK = 20;
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
mockRecord._emitter(TEST_EVENT);
@@ -593,26 +594,29 @@ describe('Integration | errorSampleRate with delayed flush', () => {
const optionsEvent = createOptionsEvent(replay);
jest.runAllTimers();
- jest.advanceTimersByTime(20);
await new Promise(process.nextTick);
+ expect(replay).not.toHaveLastSentReplay();
+
captureException(new Error('testing'));
await waitForBufferFlush();
- expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + 20);
+ // See comments in `handleRecordingEmit.ts`, we perform a setTimeout into a
+ // noop when it can be skipped altogether
+ expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + DEFAULT_FLUSH_MIN_DELAY + TICK + TICK);
// Does not capture mouse click
expect(replay).toHaveSentReplay({
recordingPayloadHeader: { segment_id: 0 },
replayEventPayload: expect.objectContaining({
// Make sure the old performance event is thrown out
- replay_start_timestamp: (BASE_TIMESTAMP + ELAPSED + 20) / 1000,
+ replay_start_timestamp: (BASE_TIMESTAMP + ELAPSED + TICK) / 1000,
}),
recordingData: JSON.stringify([
{
data: { isCheckout: true },
- timestamp: BASE_TIMESTAMP + ELAPSED + 20,
+ timestamp: BASE_TIMESTAMP + ELAPSED + TICK,
type: 2,
},
optionsEvent,
@@ -662,7 +666,8 @@ describe('Integration | errorSampleRate with delayed flush', () => {
expect(replay.isEnabled()).toBe(false);
});
- it('stops replay when session exceeds max length', async () => {
+ it('stops replay when session exceeds max length after latest captured error', async () => {
+ const sessionId = replay.session?.id;
jest.setSystemTime(BASE_TIMESTAMP);
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
@@ -674,34 +679,120 @@ describe('Integration | errorSampleRate with delayed flush', () => {
jest.runAllTimers();
await new Promise(process.nextTick);
+ jest.advanceTimersByTime(2 * MAX_SESSION_LIFE);
+
captureException(new Error('testing'));
- jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
+ // Flush due to exception
+ await new Promise(process.nextTick);
+ await waitForFlush();
+
+ expect(replay.session?.id).toBe(sessionId);
+ expect(replay).toHaveLastSentReplay({
+ recordingPayloadHeader: { segment_id: 0 },
+ });
+
+ // This comes from `startRecording()` in `sendBufferedReplayOrFlush()`
+ await waitForFlush();
+ expect(replay).toHaveLastSentReplay({
+ recordingPayloadHeader: { segment_id: 1 },
+ recordingData: JSON.stringify([
+ {
+ data: {
+ isCheckout: true,
+ },
+ timestamp: BASE_TIMESTAMP + 2 * MAX_SESSION_LIFE + DEFAULT_FLUSH_MIN_DELAY + 40,
+ type: 2,
+ },
+ ]),
+ });
+
+ // Now wait after session expires - should stop recording
+ mockRecord.takeFullSnapshot.mockClear();
+ (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear();
+
+ jest.advanceTimersByTime(MAX_SESSION_LIFE);
+ await new Promise(process.nextTick);
+
+ mockRecord._emitter(TEST_EVENT);
+ jest.runAllTimers();
await new Promise(process.nextTick);
expect(replay).not.toHaveLastSentReplay();
+ expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0);
+ expect(replay.isEnabled()).toBe(false);
+
+ // Once the session is stopped after capturing a replay already
+ // (buffer-mode), another error will not trigger a new replay
+ captureException(new Error('testing'));
- // Wait a bit, shortly before session expires
- jest.advanceTimersByTime(MAX_SESSION_LIFE - 1000);
await new Promise(process.nextTick);
+ jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
+ await new Promise(process.nextTick);
+ expect(replay).not.toHaveLastSentReplay();
+ });
+ it('does not stop replay based on earliest event in buffer', async () => {
+ jest.setSystemTime(BASE_TIMESTAMP);
+
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP - 60000, type: 3 };
mockRecord._emitter(TEST_EVENT);
- replay.triggerUserActivity();
+
+ expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+ expect(replay).not.toHaveLastSentReplay();
+
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+
+ expect(replay).not.toHaveLastSentReplay();
+ captureException(new Error('testing'));
+
+ await waitForBufferFlush();
expect(replay).toHaveLastSentReplay();
+ // Flush from calling `stopRecording`
+ await waitForFlush();
+
// Now wait after session expires - should stop recording
mockRecord.takeFullSnapshot.mockClear();
(getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear();
- jest.advanceTimersByTime(10_000);
+ expect(replay).not.toHaveLastSentReplay();
+
+ const TICKS = 80;
+
+ // We advance time so that we are on the border of expiring, taking into
+ // account that TEST_EVENT timestamp is 60000 ms before BASE_TIMESTAMP. The
+ // 3 DEFAULT_FLUSH_MIN_DELAY is to account for the `waitForFlush` that has
+ // happened, and for the next two that will happen. The first following
+ // `waitForFlush` does not expire session, but the following one will.
+ jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION - 60000 - 3 * DEFAULT_FLUSH_MIN_DELAY - TICKS);
await new Promise(process.nextTick);
mockRecord._emitter(TEST_EVENT);
- replay.triggerUserActivity();
+ expect(replay).not.toHaveLastSentReplay();
+ await waitForFlush();
- jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
- await new Promise(process.nextTick);
+ expect(replay).not.toHaveLastSentReplay();
+ expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0);
+ expect(replay.isEnabled()).toBe(true);
+
+ mockRecord._emitter(TEST_EVENT);
+ expect(replay).not.toHaveLastSentReplay();
+ await waitForFlush();
+
+ expect(replay).not.toHaveLastSentReplay();
+ expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0);
+ expect(replay.isEnabled()).toBe(true);
+
+ // It's hard to test, but if we advance the below time less 1 ms, it should
+ // be enabled, but we can't trigger a session check via flush without
+ // incurring another DEFAULT_FLUSH_MIN_DELAY timeout.
+ jest.advanceTimersByTime(60000 - DEFAULT_FLUSH_MIN_DELAY);
+ mockRecord._emitter(TEST_EVENT);
+ expect(replay).not.toHaveLastSentReplay();
+ await waitForFlush();
expect(replay).not.toHaveLastSentReplay();
expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0);
diff --git a/packages/replay/test/integration/errorSampleRate.test.ts b/packages/replay/test/integration/errorSampleRate.test.ts
index 3145ba37e7f9..ea1825dd8429 100644
--- a/packages/replay/test/integration/errorSampleRate.test.ts
+++ b/packages/replay/test/integration/errorSampleRate.test.ts
@@ -432,6 +432,9 @@ describe('Integration | errorSampleRate', () => {
['MAX_SESSION_LIFE', MAX_SESSION_LIFE],
['SESSION_IDLE_EXPIRE_DURATION', SESSION_IDLE_EXPIRE_DURATION],
])('continues buffering replay if session had no error and exceeds %s', async (_label, waitTime) => {
+ const oldSessionId = replay.session?.id;
+ expect(oldSessionId).toBeDefined();
+
expect(replay).not.toHaveLastSentReplay();
// Idle for given time
@@ -475,13 +478,24 @@ describe('Integration | errorSampleRate', () => {
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
await new Promise(process.nextTick);
- expect(replay).toHaveLastSentReplay({
+ expect(replay.session?.id).toBe(oldSessionId);
+
+ // Flush of buffered events
+ expect(replay).toHaveSentReplay({
recordingPayloadHeader: { segment_id: 0 },
replayEventPayload: expect.objectContaining({
replay_type: 'buffer',
}),
});
+ // Checkout from `startRecording`
+ expect(replay).toHaveLastSentReplay({
+ recordingPayloadHeader: { segment_id: 1 },
+ replayEventPayload: expect.objectContaining({
+ replay_type: 'buffer',
+ }),
+ });
+
expect(replay.isEnabled()).toBe(true);
expect(replay.isPaused()).toBe(false);
expect(replay.recordingMode).toBe('session');
@@ -491,6 +505,9 @@ describe('Integration | errorSampleRate', () => {
// Should behave the same as above test
it('stops replay if user has been idle for more than SESSION_IDLE_EXPIRE_DURATION and does not start a new session thereafter', async () => {
+ const oldSessionId = replay.session?.id;
+ expect(oldSessionId).toBeDefined();
+
// Idle for 15 minutes
jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION + 1);
@@ -517,14 +534,24 @@ describe('Integration | errorSampleRate', () => {
await new Promise(process.nextTick);
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
await new Promise(process.nextTick);
+ expect(replay.session?.id).toBe(oldSessionId);
- expect(replay).toHaveLastSentReplay({
+ // buffered events
+ expect(replay).toHaveSentReplay({
recordingPayloadHeader: { segment_id: 0 },
replayEventPayload: expect.objectContaining({
replay_type: 'buffer',
}),
});
+ // `startRecording` full checkout
+ expect(replay).toHaveLastSentReplay({
+ recordingPayloadHeader: { segment_id: 1 },
+ replayEventPayload: expect.objectContaining({
+ replay_type: 'buffer',
+ }),
+ });
+
expect(replay.isEnabled()).toBe(true);
expect(replay.isPaused()).toBe(false);
expect(replay.recordingMode).toBe('session');
@@ -595,17 +622,15 @@ describe('Integration | errorSampleRate', () => {
const optionsEvent = createOptionsEvent(replay);
jest.runAllTimers();
- jest.advanceTimersByTime(20);
await new Promise(process.nextTick);
captureException(new Error('testing'));
await new Promise(process.nextTick);
jest.runAllTimers();
- jest.advanceTimersByTime(20);
await new Promise(process.nextTick);
- expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + 20);
+ expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + 40);
// Does not capture mouse click
expect(replay).toHaveSentReplay({
@@ -667,7 +692,8 @@ describe('Integration | errorSampleRate', () => {
expect(replay.isEnabled()).toBe(false);
});
- it('stops replay when session exceeds max length', async () => {
+ it('stops replay when session exceeds max length after latest captured error', async () => {
+ const sessionId = replay.session?.id;
jest.setSystemTime(BASE_TIMESTAMP);
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
@@ -679,37 +705,40 @@ describe('Integration | errorSampleRate', () => {
jest.runAllTimers();
await new Promise(process.nextTick);
+ jest.advanceTimersByTime(2 * MAX_SESSION_LIFE);
+
captureException(new Error('testing'));
- jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
+ // Flush due to exception
await new Promise(process.nextTick);
- expect(replay).not.toHaveLastSentReplay();
-
- // Wait a bit, shortly before session expires
- jest.advanceTimersByTime(MAX_SESSION_LIFE - 1000);
+ jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
await new Promise(process.nextTick);
-
- mockRecord._emitter(TEST_EVENT);
- replay.triggerUserActivity();
-
+ expect(replay.session?.id).toBe(sessionId);
expect(replay).toHaveLastSentReplay();
- // Now wait after session expires - should stop recording
+ // Now wait after session expires - should re-start into buffering mode
mockRecord.takeFullSnapshot.mockClear();
(getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear();
- jest.advanceTimersByTime(10_000);
+ jest.advanceTimersByTime(MAX_SESSION_LIFE);
await new Promise(process.nextTick);
mockRecord._emitter(TEST_EVENT);
- replay.triggerUserActivity();
-
- jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
+ jest.runAllTimers();
await new Promise(process.nextTick);
expect(replay).not.toHaveLastSentReplay();
expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0);
expect(replay.isEnabled()).toBe(false);
+
+ // Once the session is stopped after capturing a replay already
+ // (buffer-mode), another error should trigger a new replay
+ captureException(new Error('testing'));
+
+ await new Promise(process.nextTick);
+ jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
+ await new Promise(process.nextTick);
+ expect(replay).not.toHaveLastSentReplay();
});
});
diff --git a/packages/replay/test/unit/coreHandlers/handleScope.test.ts b/packages/replay/test/unit/coreHandlers/handleScope.test.ts
index 1bce28f860c8..9ce56b89d1e0 100644
--- a/packages/replay/test/unit/coreHandlers/handleScope.test.ts
+++ b/packages/replay/test/unit/coreHandlers/handleScope.test.ts
@@ -63,21 +63,21 @@ describe('Unit | coreHandlers | handleScope', () => {
describe('normalizeConsoleBreadcrumb', () => {
it('handles console messages with no arguments', () => {
- const breadcrumb: Breadcrumb = { category: 'console', message: 'test' };
+ const breadcrumb = { category: 'console', message: 'test' };
const actual = HandleScope.normalizeConsoleBreadcrumb(breadcrumb);
expect(actual).toMatchObject({ category: 'console', message: 'test' });
});
it('handles console messages with empty arguments', () => {
- const breadcrumb: Breadcrumb = { category: 'console', message: 'test', data: { arguments: [] } };
+ const breadcrumb = { category: 'console', message: 'test', data: { arguments: [] } };
const actual = HandleScope.normalizeConsoleBreadcrumb(breadcrumb);
expect(actual).toMatchObject({ category: 'console', message: 'test', data: { arguments: [] } });
});
it('handles console messages with simple arguments', () => {
- const breadcrumb: Breadcrumb = {
+ const breadcrumb = {
category: 'console',
message: 'test',
data: { arguments: [1, 'a', true, null, undefined] },
@@ -94,7 +94,7 @@ describe('Unit | coreHandlers | handleScope', () => {
});
it('truncates large strings', () => {
- const breadcrumb: Breadcrumb = {
+ const breadcrumb = {
category: 'console',
message: 'test',
data: {
@@ -114,7 +114,7 @@ describe('Unit | coreHandlers | handleScope', () => {
});
it('truncates large JSON objects', () => {
- const breadcrumb: Breadcrumb = {
+ const breadcrumb = {
category: 'console',
message: 'test',
data: {
diff --git a/packages/replay/test/unit/coreHandlers/handleSlowClick.test.ts b/packages/replay/test/unit/coreHandlers/handleSlowClick.test.ts
new file mode 100644
index 000000000000..2d0922272115
--- /dev/null
+++ b/packages/replay/test/unit/coreHandlers/handleSlowClick.test.ts
@@ -0,0 +1,34 @@
+import { ignoreElement } from '../../../src/coreHandlers/handleSlowClick';
+import type { SlowClickConfig } from '../../../src/types';
+
+describe('Unit | coreHandlers | handleSlowClick', () => {
+ describe('ignoreElement', () => {
+ it.each([
+ ['div', {}, true],
+ ['button', {}, false],
+ ['a', {}, false],
+ ['input', {}, true],
+ ['input', { type: 'text' }, true],
+ ['input', { type: 'button' }, false],
+ ['input', { type: 'submit' }, false],
+ ['a', { target: '_self' }, false],
+ ['a', { target: '_blank' }, true],
+ ['a', { download: '' }, true],
+ ['a', { href: 'xx' }, false],
+ ])('it works with <%s> & %p', (tagName, attributes, expected) => {
+ const node = document.createElement(tagName);
+ Object.entries(attributes).forEach(([key, value]) => {
+ node.setAttribute(key, value);
+ });
+ expect(ignoreElement(node, {} as SlowClickConfig)).toBe(expected);
+ });
+
+ test('it ignored selectors matching ignoreSelector', () => {
+ const button = document.createElement('button');
+ const a = document.createElement('a');
+
+ expect(ignoreElement(button, { ignoreSelector: 'button' } as SlowClickConfig)).toBe(true);
+ expect(ignoreElement(a, { ignoreSelector: 'button' } as SlowClickConfig)).toBe(false);
+ });
+ });
+});
diff --git a/packages/replay/test/unit/session/getSession.test.ts b/packages/replay/test/unit/session/getSession.test.ts
index 2905e1bd72d6..aa3110d114f2 100644
--- a/packages/replay/test/unit/session/getSession.test.ts
+++ b/packages/replay/test/unit/session/getSession.test.ts
@@ -229,4 +229,63 @@ describe('Unit | session | getSession', () => {
expect(session.id).toBe('test_session_uuid_2');
expect(session.segmentId).toBe(0);
});
+
+ it('re-uses the same "buffer" session if it is expired and has never sent a buffered replay', function () {
+ const { type, session } = getSession({
+ timeouts: {
+ sessionIdlePause: SESSION_IDLE_PAUSE_DURATION,
+ sessionIdleExpire: 1000,
+ maxSessionLife: MAX_SESSION_LIFE,
+ },
+ stickySession: false,
+ ...SAMPLE_OPTIONS,
+ currentSession: makeSession({
+ id: 'test_session_uuid_2',
+ lastActivity: +new Date() - MAX_SESSION_LIFE - 1,
+ started: +new Date() - MAX_SESSION_LIFE - 1,
+ segmentId: 0,
+ sampled: 'buffer',
+ }),
+ allowBuffering: true,
+ });
+
+ expect(FetchSession.fetchSession).not.toHaveBeenCalled();
+ expect(CreateSession.createSession).not.toHaveBeenCalled();
+
+ expect(type).toBe('saved');
+ expect(session.id).toBe('test_session_uuid_2');
+ expect(session.sampled).toBe('buffer');
+ expect(session.segmentId).toBe(0);
+ });
+
+ it('creates a new session if it is expired and it was a "buffer" session that has sent a replay', function () {
+ const currentSession = makeSession({
+ id: 'test_session_uuid_2',
+ lastActivity: +new Date() - MAX_SESSION_LIFE - 1,
+ started: +new Date() - MAX_SESSION_LIFE - 1,
+ segmentId: 0,
+ sampled: 'buffer',
+ });
+ currentSession.shouldRefresh = false;
+
+ const { type, session } = getSession({
+ timeouts: {
+ sessionIdlePause: SESSION_IDLE_PAUSE_DURATION,
+ sessionIdleExpire: 1000,
+ maxSessionLife: MAX_SESSION_LIFE,
+ },
+ stickySession: false,
+ ...SAMPLE_OPTIONS,
+ currentSession,
+ allowBuffering: true,
+ });
+
+ expect(FetchSession.fetchSession).not.toHaveBeenCalled();
+ expect(CreateSession.createSession).not.toHaveBeenCalled();
+
+ expect(type).toBe('new');
+ expect(session.id).not.toBe('test_session_uuid_2');
+ expect(session.sampled).toBe(false);
+ expect(session.segmentId).toBe(0);
+ });
});
diff --git a/packages/replay/test/unit/util/handleRecordingEmit.test.ts b/packages/replay/test/unit/util/handleRecordingEmit.test.ts
index a4c7f82c425d..7978939291bd 100644
--- a/packages/replay/test/unit/util/handleRecordingEmit.test.ts
+++ b/packages/replay/test/unit/util/handleRecordingEmit.test.ts
@@ -1,7 +1,7 @@
import { EventType } from '@sentry-internal/rrweb';
import { BASE_TIMESTAMP } from '../..';
-import type { RecordingEvent } from '../../../src/types';
+import type { OptionFrameEvent } from '../../../src/types';
import * as SentryAddEvent from '../../../src/util/addEvent';
import { createOptionsEvent, getHandleRecordingEmit } from '../../../src/util/handleRecordingEmit';
import { setupReplayContainer } from '../../utils/setupReplayContainer';
@@ -9,7 +9,7 @@ import { useFakeTimers } from '../../utils/use-fake-timers';
useFakeTimers();
-let optionsEvent: RecordingEvent;
+let optionsEvent: OptionFrameEvent;
describe('Unit | util | handleRecordingEmit', () => {
let addEventMock: jest.SpyInstance;
diff --git a/packages/replay/test/utils/setupReplayContainer.ts b/packages/replay/test/utils/setupReplayContainer.ts
index c7c302cce72b..0237afddb538 100644
--- a/packages/replay/test/utils/setupReplayContainer.ts
+++ b/packages/replay/test/utils/setupReplayContainer.ts
@@ -17,6 +17,8 @@ const DEFAULT_OPTIONS = {
networkResponseHeaders: [],
mutationLimit: 1500,
mutationBreadcrumbLimit: 500,
+ slowClickTimeout: 7_000,
+ slowClickIgnoreSelectors: [],
_experiments: {},
};
diff --git a/packages/svelte/package.json b/packages/svelte/package.json
index fa63ba9d68e3..33114f3b9f3d 100644
--- a/packages/svelte/package.json
+++ b/packages/svelte/package.json
@@ -23,7 +23,7 @@
"tslib": "^1.9.3"
},
"peerDependencies": {
- "svelte": "3.x"
+ "svelte": "3.x || 4.x"
},
"devDependencies": {
"@testing-library/svelte": "^3.2.1",
diff --git a/packages/sveltekit/README.md b/packages/sveltekit/README.md
index 309b59fcd2ac..5ca2cff3e73d 100644
--- a/packages/sveltekit/README.md
+++ b/packages/sveltekit/README.md
@@ -16,10 +16,19 @@
## Compatibility
-Currently, the minimum supported version of SvelteKit is `1.0.0`.
+The minimum supported version of SvelteKit is `1.0.0`.
The SDK works best with Vite 4.2 and newer.
Older Vite versions might not generate source maps correctly.
+The SDK supports the following SvelteKit adapters:
+- `@sveltejs/adapter-auto` - for Vercel with the Node runtime. Other deployment targets might work but we don't guarantee compatibility.
+- `@sveltejs/adapter-vercel` - only for Node (Lambda) runtimes, not yet Vercel's edge runtime
+- `@sveltejs/adapter-node`
+
+If you use the SDK with other adapters, we cannot guarantee that everything works as expected.
+You might need to [manually configure source maps upload](#-configuring-source-maps-upload).
+The SDK is currently not compatible with none-Node server runtimes, such as Vercel's Edge runtime or Cloudflare workers.
+
## General
This package is a wrapper around `@sentry/node` for the server and `@sentry/svelte` for the client side, with added functionality related to SvelteKit.
@@ -40,9 +49,7 @@ If the setup through the wizard doesn't work for you, you can also set up the SD
### 1. Prerequesits & Installation
-1. Ensure you've set up the [`@sveltejs/adapter-node` adapter](https://kit.svelte.dev/docs/adapter-node)
-
-2. Install the Sentry SvelteKit SDK:
+1. Install the Sentry SvelteKit SDK:
```bash
# Using npm
@@ -310,12 +317,3 @@ export const load = wrapServerLoadWithSentry((event) => {
});
```
-
-## Known Limitations
-
-This SDK is still under active development.
-Take a look at our [SvelteKit SDK Development Roadmap](https://github.com/getsentry/sentry-javascript/issues/6692) to follow the progress.
-
-- **Adapters** other than `@sveltejs/adapter-node` are currently not supported.
- We haven't yet tested other platforms like Vercel.
- This is on our roadmap but it will come at a later time.
diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json
index bdd7487c5e81..95289150547e 100644
--- a/packages/sveltekit/package.json
+++ b/packages/sveltekit/package.json
@@ -36,7 +36,7 @@
"rollup": "^3.20.2",
"svelte": "^3.44.0",
"typescript": "^4.9.3",
- "vite": "4.0.0"
+ "vite": "4.0.5"
},
"scripts": {
"build": "run-p build:transpile build:types",
diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts
index 37143c09fea3..3475a3dbd894 100644
--- a/packages/sveltekit/src/server/index.ts
+++ b/packages/sveltekit/src/server/index.ts
@@ -9,6 +9,7 @@ export {
captureException,
captureEvent,
captureMessage,
+ captureCheckIn,
configureScope,
createTransport,
extractTraceparentData,
diff --git a/yarn.lock b/yarn.lock
index d118d336daf6..b4ab26edf619 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -18928,11 +18928,16 @@ nan@^2.12.1:
resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.2.tgz#f5376400695168f4cc694ac9393d0c9585eeea19"
integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==
-nanoid@^3.1.16, nanoid@^3.1.20, nanoid@^3.1.23, nanoid@^3.3.4:
+nanoid@^3.1.16, nanoid@^3.1.20, nanoid@^3.1.23:
version "3.3.4"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
+nanoid@^3.3.6:
+ version "3.3.6"
+ resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
+ integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
+
nanomatch@^1.2.9:
version "1.2.13"
resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"
@@ -22042,21 +22047,12 @@ postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.17, postcss@^7.0.2
picocolors "^0.2.1"
source-map "^0.6.1"
-postcss@^8.1.10, postcss@^8.1.7, postcss@^8.2.15:
- version "8.4.19"
- resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.19.tgz#61178e2add236b17351897c8bcc0b4c8ecab56fc"
- integrity sha512-h+pbPsyhlYj6N2ozBmHhHrs9DzGmbaarbLvWipMRO7RLS+v4onj26MPFXA5OBYFxyqYhUJK456SwDcY9H2/zsA==
+postcss@^8.1.10, postcss@^8.1.7, postcss@^8.2.15, postcss@^8.2.4, postcss@^8.3.5, postcss@^8.3.7, postcss@^8.4.20, postcss@^8.4.21:
+ version "8.4.24"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.24.tgz#f714dba9b2284be3cc07dbd2fc57ee4dc972d2df"
+ integrity sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==
dependencies:
- nanoid "^3.3.4"
- picocolors "^1.0.0"
- source-map-js "^1.0.2"
-
-postcss@^8.2.4, postcss@^8.3.5, postcss@^8.3.7, postcss@^8.4.19, postcss@^8.4.21:
- version "8.4.21"
- resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.21.tgz#c639b719a57efc3187b13a1d765675485f4134f4"
- integrity sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==
- dependencies:
- nanoid "^3.3.4"
+ nanoid "^3.3.6"
picocolors "^1.0.0"
source-map-js "^1.0.2"
@@ -23638,41 +23634,27 @@ rollup@2.26.5:
optionalDependencies:
fsevents "~2.1.2"
-rollup@2.78.0, rollup@^2.67.1, rollup@^2.8.0:
+rollup@2.78.0:
version "2.78.0"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.78.0.tgz#00995deae70c0f712ea79ad904d5f6b033209d9e"
integrity sha512-4+YfbQC9QEVvKTanHhIAFVUFSRsezvQF8vFOJwtGfb9Bb+r014S+qryr9PSmw8x6sMnPkmFBGAvIFVQxvJxjtg==
optionalDependencies:
fsevents "~2.3.2"
-rollup@^2.45.1:
+rollup@^2.45.1, rollup@^2.67.1, rollup@^2.8.0:
version "2.79.1"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7"
integrity sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==
optionalDependencies:
fsevents "~2.3.2"
-rollup@^3.10.0:
- version "3.19.1"
- resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.19.1.tgz#2b3a31ac1ff9f3afab2e523fa687fef5b0ee20fc"
- integrity sha512-lAbrdN7neYCg/8WaoWn/ckzCtz+jr70GFfYdlf50OF7387HTg+wiuiqJRFYawwSPpqfqDNYqK7smY/ks2iAudg==
- optionalDependencies:
- fsevents "~2.3.2"
-
-rollup@^3.20.2:
+rollup@^3.10.0, rollup@^3.20.2, rollup@^3.7.0:
version "3.20.2"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.20.2.tgz#f798c600317f216de2e4ad9f4d9ab30a89b690ff"
integrity sha512-3zwkBQl7Ai7MFYQE0y1MeQ15+9jsi7XxfrqwTb/9EK8D9C9+//EBR4M+CuA1KODRaNbFez/lWxA5vhEGZp4MUg==
optionalDependencies:
fsevents "~2.3.2"
-rollup@^3.7.0:
- version "3.18.0"
- resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.18.0.tgz#2354ba63ba66d6a09c652c3ea0dbcd9dad72bbde"
- integrity sha512-J8C6VfEBjkvYPESMQYxKHxNOh4A5a3FlP+0BETGo34HEcE4eTlgCrO2+eWzlu2a/sHs2QUkZco+wscH7jhhgWg==
- optionalDependencies:
- fsevents "~2.3.2"
-
rsvp@^3.0.14, rsvp@^3.0.17, rsvp@^3.0.18, rsvp@^3.0.21, rsvp@^3.0.6, rsvp@^3.1.0:
version "3.6.2"
resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-3.6.2.tgz#2e96491599a96cde1b515d5674a8f7a91452926a"
@@ -27017,13 +26999,13 @@ vite-node@0.29.2:
picocolors "^1.0.0"
vite "^3.0.0 || ^4.0.0"
-vite@4.0.0:
- version "4.0.0"
- resolved "https://registry.yarnpkg.com/vite/-/vite-4.0.0.tgz#b81b88349a06b2faaa53ae14cf96c942548e3454"
- integrity sha512-ynad+4kYs8Jcnn8J7SacS9vAbk7eMy0xWg6E7bAhS1s79TK+D7tVFGXVZ55S7RNLRROU1rxoKlvZ/qjaB41DGA==
+vite@4.0.5:
+ version "4.0.5"
+ resolved "https://registry.yarnpkg.com/vite/-/vite-4.0.5.tgz#634f0bd1edf8bb8468ed42a1c3fd938c67d2f94b"
+ integrity sha512-7m87RC+caiAxG+8j3jObveRLqaWA/neAdCat6JAZwMkSWqFHOvg8MYe5fAQxVBRAuKAQ1S6XDh3CBQuLNbY33w==
dependencies:
esbuild "^0.16.3"
- postcss "^8.4.19"
+ postcss "^8.4.20"
resolve "^1.22.1"
rollup "^3.7.0"
optionalDependencies: