diff --git a/.size-limit.js b/.size-limit.js index b29677fce51a..7fd24ce7efa1 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -14,6 +14,13 @@ module.exports = [ gzip: true, limit: '75 KB', }, + { + name: '@sentry/browser (incl. Tracing, Replay with Canvas) - Webpack (gzipped)', + path: 'packages/browser/build/npm/esm/index.js', + import: '{ init, Replay, BrowserTracing, ReplayCanvas }', + gzip: true, + limit: '90 KB', + }, { name: '@sentry/browser (incl. Tracing, Replay) - Webpack with treeshaking flags (gzipped)', path: 'packages/browser/build/npm/esm/index.js', diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 5c880aa59b8c..7641db7f8499 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -45,7 +45,7 @@ "dependencies": { "@babel/preset-typescript": "^7.16.7", "@playwright/test": "^1.31.1", - "@sentry-internal/rrweb": "2.6.0", + "@sentry-internal/rrweb": "2.7.3", "@sentry/browser": "7.92.0", "@sentry/tracing": "7.92.0", "axios": "1.6.0", diff --git a/dev-packages/browser-integration-tests/utils/replayHelpers.ts b/dev-packages/browser-integration-tests/utils/replayHelpers.ts index 613bd5b447f1..2a951e4a215e 100644 --- a/dev-packages/browser-integration-tests/utils/replayHelpers.ts +++ b/dev-packages/browser-integration-tests/utils/replayHelpers.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import type { fullSnapshotEvent, incrementalSnapshotEvent } from '@sentry-internal/rrweb'; import { EventType } from '@sentry-internal/rrweb'; import type { ReplayEventWithTime } from '@sentry/browser'; @@ -5,6 +6,7 @@ import type { InternalEventContext, RecordingEvent, ReplayContainer, + ReplayPluginOptions, Session, } from '@sentry/replay/build/npm/types/types'; import type { Breadcrumb, Event, ReplayEvent, ReplayRecordingMode } from '@sentry/types'; @@ -171,6 +173,8 @@ export function getReplaySnapshot(page: Page): Promise<{ _isPaused: boolean; _isEnabled: boolean; _context: InternalEventContext; + _options: ReplayPluginOptions; + _hasCanvas: boolean; session: Session | undefined; recordingMode: ReplayRecordingMode; }> { @@ -182,6 +186,9 @@ export function getReplaySnapshot(page: Page): Promise<{ _isPaused: replay.isPaused(), _isEnabled: replay.isEnabled(), _context: replay.getContext(), + _options: replay.getOptions(), + // We cannot pass the function through as this is serialized + _hasCanvas: typeof replay.getOptions()._experiments.canvas?.manager === 'function', session: replay.session, recordingMode: replay.recordingMode, }; diff --git a/packages/browser-integration-tests/suites/replay/canvas/template.html b/packages/browser-integration-tests/suites/replay/canvas/template.html new file mode 100644 index 000000000000..2b3e2f0b27b4 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/canvas/template.html @@ -0,0 +1,9 @@ + + +
+ + + + + + diff --git a/packages/browser-integration-tests/suites/replay/canvas/withCanvasIntegrationFirst/init.js b/packages/browser-integration-tests/suites/replay/canvas/withCanvasIntegrationFirst/init.js new file mode 100644 index 000000000000..fa3248066150 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/canvas/withCanvasIntegrationFirst/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = new Sentry.Replay({ + flushMinDelay: 200, + flushMaxDelay: 200, + minReplayDuration: 0, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 0, + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + debug: true, + + integrations: [new Sentry.ReplayCanvas(), window.Replay], +}); diff --git a/packages/browser-integration-tests/suites/replay/canvas/withCanvasIntegrationFirst/test.ts b/packages/browser-integration-tests/suites/replay/canvas/withCanvasIntegrationFirst/test.ts new file mode 100644 index 000000000000..7326bed95ae6 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/canvas/withCanvasIntegrationFirst/test.ts @@ -0,0 +1,28 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getReplaySnapshot, shouldSkipReplayTest } from '../../../../utils/replayHelpers'; + +sentryTest('sets up canvas when adding ReplayCanvas integration first', async ({ getLocalTestUrl, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + 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); + + const replay = await getReplaySnapshot(page); + const canvasOptions = replay._options._experiments?.canvas; + expect(canvasOptions.fps).toBe(4); + expect(canvasOptions.quality).toBe(0.6); + expect(replay._hasCanvas).toBe(true); +}); diff --git a/packages/browser-integration-tests/suites/replay/canvas/withCanvasIntegrationSecond/init.js b/packages/browser-integration-tests/suites/replay/canvas/withCanvasIntegrationSecond/init.js new file mode 100644 index 000000000000..1a9d5d179c4a --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/canvas/withCanvasIntegrationSecond/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = new Sentry.Replay({ + flushMinDelay: 200, + flushMaxDelay: 200, + minReplayDuration: 0, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 0, + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + debug: true, + + integrations: [window.Replay, new Sentry.ReplayCanvas()], +}); diff --git a/packages/browser-integration-tests/suites/replay/canvas/withCanvasIntegrationSecond/test.ts b/packages/browser-integration-tests/suites/replay/canvas/withCanvasIntegrationSecond/test.ts new file mode 100644 index 000000000000..9168864db7e0 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/canvas/withCanvasIntegrationSecond/test.ts @@ -0,0 +1,28 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getReplaySnapshot, shouldSkipReplayTest } from '../../../../utils/replayHelpers'; + +sentryTest('sets up canvas when adding ReplayCanvas integration after Replay', async ({ getLocalTestUrl, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + 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); + + const replay = await getReplaySnapshot(page); + const canvasOptions = replay._options._experiments?.canvas; + expect(canvasOptions.fps).toBe(4); + expect(canvasOptions.quality).toBe(0.6); + expect(replay._hasCanvas).toBe(true); +}); diff --git a/packages/browser-integration-tests/suites/replay/canvas/withoutCanvasIntegration/init.js b/packages/browser-integration-tests/suites/replay/canvas/withoutCanvasIntegration/init.js new file mode 100644 index 000000000000..92a463a4bc84 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/canvas/withoutCanvasIntegration/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = new Sentry.Replay({ + flushMinDelay: 200, + flushMaxDelay: 200, + minReplayDuration: 0, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 0, + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + debug: true, + + integrations: [window.Replay], +}); diff --git a/packages/browser-integration-tests/suites/replay/canvas/withoutCanvasIntegration/test.ts b/packages/browser-integration-tests/suites/replay/canvas/withoutCanvasIntegration/test.ts new file mode 100644 index 000000000000..b2ce69211be0 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/canvas/withoutCanvasIntegration/test.ts @@ -0,0 +1,27 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getReplaySnapshot, shouldSkipReplayTest } from '../../../../utils/replayHelpers'; + +sentryTest('does not setup up canvas without ReplayCanvas integration', async ({ getLocalTestUrl, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + 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); + + const replay = await getReplaySnapshot(page); + const canvasOptions = replay._options._experiments?.canvas; + expect(canvasOptions).toBe(undefined); + expect(replay._hasCanvas).toBe(false); +}); diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index d9afa470b2ba..1c782ffb3251 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -20,7 +20,7 @@ const INTEGRATIONS = { export { INTEGRATIONS as Integrations }; -export { Replay } from '@sentry/replay'; +export { Replay, ReplayCanvas } from '@sentry/replay'; export type { ReplayEventType, ReplayEventWithTime, diff --git a/packages/replay/package.json b/packages/replay/package.json index 54e8f7f11988..7cb1051110a6 100644 --- a/packages/replay/package.json +++ b/packages/replay/package.json @@ -54,8 +54,8 @@ "devDependencies": { "@babel/core": "^7.17.5", "@sentry-internal/replay-worker": "7.92.0", - "@sentry-internal/rrweb": "2.6.0", - "@sentry-internal/rrweb-snapshot": "2.6.0", + "@sentry-internal/rrweb": "2.7.3", + "@sentry-internal/rrweb-snapshot": "2.7.3", "fflate": "^0.8.1", "jsdom-worker": "^0.2.1" }, diff --git a/packages/replay/rollup.bundle.config.mjs b/packages/replay/rollup.bundle.config.mjs index a209b8d41af4..b254dae8d8d2 100644 --- a/packages/replay/rollup.bundle.config.mjs +++ b/packages/replay/rollup.bundle.config.mjs @@ -2,12 +2,20 @@ import { makeBaseBundleConfig, makeBundleConfigVariants } from '@sentry-internal const baseBundleConfig = makeBaseBundleConfig({ bundleType: 'addon', - entrypoints: ['src/index.ts'], + entrypoints: ['src/integration.ts'], jsVersion: 'es6', licenseTitle: '@sentry/replay', outputFileBase: () => 'bundles/replay', }); -const builds = makeBundleConfigVariants(baseBundleConfig); +const baseCanvasBundleConfig = makeBaseBundleConfig({ + bundleType: 'addon', + entrypoints: ['src/canvas.ts'], + jsVersion: 'es6', + licenseTitle: '@sentry/replaycanvas', + outputFileBase: () => 'bundles/replaycanvas', +}); + +const builds = [...makeBundleConfigVariants(baseBundleConfig), ...makeBundleConfigVariants(baseCanvasBundleConfig)]; export default builds; diff --git a/packages/replay/src/canvas.ts b/packages/replay/src/canvas.ts new file mode 100644 index 000000000000..d4b869c2f024 --- /dev/null +++ b/packages/replay/src/canvas.ts @@ -0,0 +1,50 @@ +import { getCanvasManager } from '@sentry-internal/rrweb'; +import type { Integration } from '@sentry/types'; +import type { ReplayConfiguration } from './types'; + +interface ReplayCanvasOptions { + quality: 'low' | 'medium' | 'high'; +} + +/** An integration to add canvas recording to replay. */ +export class ReplayCanvas implements Integration { + /** + * @inheritDoc + */ + public static id: string = 'ReplayCanvas'; + + /** + * @inheritDoc + */ + public name: string; + + private _canvasOptions: ReplayCanvasOptions; + + public constructor(options?: Partial