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) { + this.name = ReplayCanvas.id; + this._canvasOptions = { + quality: (options && options.quality) || 'medium', + }; + } + + /** @inheritdoc */ + public setupOnce(): void { + // noop + } + + /** + * Get the options that should be merged into replay options. + * This is what is actually called by the Replay integration to setup canvas. + */ + public getOptions(): Partial { + return { + _experiments: { + canvas: { + ...this._canvasOptions, + manager: getCanvasManager, + quality: this._canvasOptions.quality, + }, + }, + }; + } +} diff --git a/packages/replay/src/index.ts b/packages/replay/src/index.ts index 412354f1dc54..d6ea1de76732 100644 --- a/packages/replay/src/index.ts +++ b/packages/replay/src/index.ts @@ -1,4 +1,5 @@ export { Replay } from './integration'; +export { ReplayCanvas } from './canvas'; export type { ReplayEventType, diff --git a/packages/replay/src/integration.ts b/packages/replay/src/integration.ts index 80bbeca5fdf3..26cac38b1e56 100644 --- a/packages/replay/src/integration.ts +++ b/packages/replay/src/integration.ts @@ -318,6 +318,7 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, return this._replay.getSessionId(); } + /** * Initializes replay. */ @@ -326,6 +327,12 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, return; } + // We have to run this in _initialize, because this runs in setTimeout + // So when this runs all integrations have been added + // Before this, we cannot access integrations on the client, + // so we need to mutate the options here + this._maybeLoadFromReplayCanvasIntegration(); + this._replay.initializeSampling(); } @@ -339,6 +346,40 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, recordingOptions: this._recordingOptions, }); } + + /** Get canvas options from ReplayCanvas integration, if it is also added. */ + private _maybeLoadFromReplayCanvasIntegration(): void { + // If already defined, skip this... + if (this._initialOptions._experiments.canvas) { + return; + } + + // To save bundle size, we skip checking for stuff here + // and instead just try-catch everything - as generally this should all be defined + /* eslint-disable @typescript-eslint/no-non-null-assertion */ + try { + const client = getClient()!; + const canvasIntegration = client.getIntegrationById!('ReplayCanvas') as Integration & { + getOptions(): Partial; + }; + if (!canvasIntegration) { + return; + } + const additionalOptions = canvasIntegration.getOptions(); + + const mergedExperimentsOptions = { + ...this._initialOptions._experiments, + ...additionalOptions._experiments, + }; + + this._initialOptions._experiments = mergedExperimentsOptions; + + this._replay!.getOptions()._experiments = mergedExperimentsOptions; + } catch { + // ignore errors here + } + /* eslint-enable @typescript-eslint/no-non-null-assertion */ + } } /** Parse Replay-related options from SDK options */ diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index c9c37349306d..9faa21f93a26 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -138,9 +138,12 @@ export interface Client { */ getEventProcessors?(): EventProcessor[]; - /** Returns the client's instance of the given integration class, it any. */ + /** Returns the client's instance of the given integration class, if it exists. */ getIntegration(integration: IntegrationClass): T | null; + /** Returns the client's instance of the given integration name, if it exists. */ + getIntegrationById?(integrationId: string): Integration | undefined; + /** * Add an integration to the client. * This can be used to e.g. lazy load integrations. diff --git a/yarn.lock b/yarn.lock index 48857bd5a97d..d5709ca33d33 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5231,33 +5231,33 @@ semver "7.3.2" semver-intersect "1.4.0" -"@sentry-internal/rrdom@2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/rrdom/-/rrdom-2.6.0.tgz#19b5ab7a01ad5031be2d4bcedd4afedb44ee2bed" - integrity sha512-XqxOhLk/CdrKh0toOKeQ6mOcjLDK3B1KY/UVqM9VwhdVhiHeMwPj6GjJUoNkEXh0MwkDM0pzIMv95oSq7hGhPg== +"@sentry-internal/rrdom@2.7.3": + version "2.7.3" + resolved "https://registry.yarnpkg.com/@sentry-internal/rrdom/-/rrdom-2.7.3.tgz#2efe68a9cf23de9a8970acf4303748cdd7866b20" + integrity sha512-XD14G4Lv3ppvJlR7VkkCgHTKu1ylh7yvXdSsN5/FyGTH+IAXQIKL5nINIgWZTN3noNBWV9R0vcHDufXG/WktWA== dependencies: - "@sentry-internal/rrweb-snapshot" "2.6.0" + "@sentry-internal/rrweb-snapshot" "2.7.3" -"@sentry-internal/rrweb-snapshot@2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb-snapshot/-/rrweb-snapshot-2.6.0.tgz#1b214c9ab4645138a02ef255c95d811bd852f996" - integrity sha512-dlduO37avs5HBP8zRxFHlhRb7ZP6p3SrgMSztPCCnfYr/XAB/rn5yeVn9U2FDYdrgyUzPjFWfYWFvm1eJuEMSg== +"@sentry-internal/rrweb-snapshot@2.7.3": + version "2.7.3" + resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb-snapshot/-/rrweb-snapshot-2.7.3.tgz#9a7173825a31c07ccf27a5956f154400e11fdd97" + integrity sha512-mSZuBPmWia3x9wCuaJiZMD9ZVDnFv7TSG1Nz9X4ZqWb3DdaxB2MogGUU/2aTVqmRj6F91nl+GHb5NpmNYojUsw== -"@sentry-internal/rrweb-types@2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb-types/-/rrweb-types-2.6.0.tgz#e526994125db6684ce9402d96f64318f062bebb0" - integrity sha512-mPPumdbyNHF24zShvZqzqgkZRsJHhlNpglGTS0cR/PkX2QdG0CtsPVFpaYj6UQAFGpfb2Aj7VdkKuuzX4RX69w== +"@sentry-internal/rrweb-types@2.7.3": + version "2.7.3" + resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb-types/-/rrweb-types-2.7.3.tgz#e38737bc5c31aa9dfb8ce8faf46af374c2aa7cbb" + integrity sha512-EqALxhZtvH0rimYfj7J48DRC+fj+AGsZ/VDdOPKh3MQXptTyHncoWBj4ZtB1AaH7foYUr+2wkyxl3HqMVwe+6g== dependencies: - "@sentry-internal/rrweb-snapshot" "2.6.0" + "@sentry-internal/rrweb-snapshot" "2.7.3" -"@sentry-internal/rrweb@2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb/-/rrweb-2.6.0.tgz#0976e0d021c965b491a5193546a735f78dad9107" - integrity sha512-N+v0cgft/mikwIH5MPIspWNEqHa3E/01rA+IwozTs/TUp2e8sJmF3qxR0+OeBGZU0ln4soG1o18FjsCmadqbeQ== +"@sentry-internal/rrweb@2.7.3": + version "2.7.3" + resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb/-/rrweb-2.7.3.tgz#900ce7b1bd8ec43b5557d73698cc1b4dc9f47f72" + integrity sha512-K2pksQ1FgDumv54r1o0+RAKB3Qx3Zgx6OrQAkU/SI6/7HcBIxe7b4zepg80NyvnfohUs/relw2EoD3K3kqd8tg== dependencies: - "@sentry-internal/rrdom" "2.6.0" - "@sentry-internal/rrweb-snapshot" "2.6.0" - "@sentry-internal/rrweb-types" "2.6.0" + "@sentry-internal/rrdom" "2.7.3" + "@sentry-internal/rrweb-snapshot" "2.7.3" + "@sentry-internal/rrweb-types" "2.7.3" "@types/css-font-loading-module" "0.0.7" "@xstate/fsm" "^1.4.0" base64-arraybuffer "^1.0.1"