Skip to content

Commit c7a12b5

Browse files
committed
feat(replay): Add ReplayCanvas integration
Adding this integration in addition to `Replay` will set up canvas recording.
1 parent 91a6b4e commit c7a12b5

File tree

16 files changed

+317
-4
lines changed

16 files changed

+317
-4
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<button onclick="console.log('Test log')">Click me</button>
8+
</body>
9+
</html>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.Replay = new Sentry.Replay({
5+
flushMinDelay: 200,
6+
flushMaxDelay: 200,
7+
minReplayDuration: 0,
8+
});
9+
10+
Sentry.init({
11+
dsn: 'https://[email protected]/1337',
12+
sampleRate: 0,
13+
replaysSessionSampleRate: 1.0,
14+
replaysOnErrorSampleRate: 0.0,
15+
debug: true,
16+
17+
integrations: [new Sentry.ReplayCanvas(), window.Replay],
18+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { getReplaySnapshot, shouldSkipReplayTest } from '../../../../utils/replayHelpers';
5+
6+
sentryTest('sets up canvas when adding ReplayCanvas integration first', async ({ getLocalTestUrl, page }) => {
7+
if (shouldSkipReplayTest()) {
8+
sentryTest.skip();
9+
}
10+
11+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
12+
return route.fulfill({
13+
status: 200,
14+
contentType: 'application/json',
15+
body: JSON.stringify({ id: 'test-id' }),
16+
});
17+
});
18+
19+
const url = await getLocalTestUrl({ testDir: __dirname });
20+
21+
await page.goto(url);
22+
23+
const replay = await getReplaySnapshot(page);
24+
const canvasOptions = replay._options._experiments?.canvas;
25+
expect(canvasOptions.fps).toBe(4);
26+
expect(canvasOptions.quality).toBe(0.6);
27+
expect(replay._hasCanvas).toBe(true);
28+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.Replay = new Sentry.Replay({
5+
flushMinDelay: 200,
6+
flushMaxDelay: 200,
7+
minReplayDuration: 0,
8+
});
9+
10+
Sentry.init({
11+
dsn: 'https://[email protected]/1337',
12+
sampleRate: 0,
13+
replaysSessionSampleRate: 1.0,
14+
replaysOnErrorSampleRate: 0.0,
15+
debug: true,
16+
17+
integrations: [window.Replay, new Sentry.ReplayCanvas()],
18+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { getReplaySnapshot, shouldSkipReplayTest } from '../../../../utils/replayHelpers';
5+
6+
sentryTest('sets up canvas when adding ReplayCanvas integration after Replay', async ({ getLocalTestUrl, page }) => {
7+
if (shouldSkipReplayTest()) {
8+
sentryTest.skip();
9+
}
10+
11+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
12+
return route.fulfill({
13+
status: 200,
14+
contentType: 'application/json',
15+
body: JSON.stringify({ id: 'test-id' }),
16+
});
17+
});
18+
19+
const url = await getLocalTestUrl({ testDir: __dirname });
20+
21+
await page.goto(url);
22+
23+
const replay = await getReplaySnapshot(page);
24+
const canvasOptions = replay._options._experiments?.canvas;
25+
expect(canvasOptions.fps).toBe(4);
26+
expect(canvasOptions.quality).toBe(0.6);
27+
expect(replay._hasCanvas).toBe(true);
28+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.Replay = new Sentry.Replay({
5+
flushMinDelay: 200,
6+
flushMaxDelay: 200,
7+
minReplayDuration: 0,
8+
_experiments: {
9+
captureExceptions: true,
10+
},
11+
});
12+
13+
Sentry.init({
14+
dsn: 'https://[email protected]/1337',
15+
sampleRate: 0,
16+
replaysSessionSampleRate: 1.0,
17+
replaysOnErrorSampleRate: 0.0,
18+
debug: true,
19+
20+
integrations: [new Sentry.ReplayCanvas({ fps: 10, quality: 0.1 }), window.Replay],
21+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { getReplaySnapshot, shouldSkipReplayTest } from '../../../../utils/replayHelpers';
5+
6+
sentryTest('sets up canvas with custom options for ReplayCanvas integration', async ({ getLocalTestUrl, page }) => {
7+
if (shouldSkipReplayTest()) {
8+
sentryTest.skip();
9+
}
10+
11+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
12+
return route.fulfill({
13+
status: 200,
14+
contentType: 'application/json',
15+
body: JSON.stringify({ id: 'test-id' }),
16+
});
17+
});
18+
19+
const url = await getLocalTestUrl({ testDir: __dirname });
20+
21+
await page.goto(url);
22+
23+
const replay = await getReplaySnapshot(page);
24+
expect(replay._options._experiments).toEqual({
25+
// other options here are kept
26+
captureExceptions: true,
27+
canvas: {
28+
fps: 10,
29+
quality: 0.1,
30+
// manager is not serialized, as it is a function
31+
},
32+
});
33+
expect(replay._hasCanvas).toBe(true);
34+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.Replay = new Sentry.Replay({
5+
flushMinDelay: 200,
6+
flushMaxDelay: 200,
7+
minReplayDuration: 0,
8+
});
9+
10+
Sentry.init({
11+
dsn: 'https://[email protected]/1337',
12+
sampleRate: 0,
13+
replaysSessionSampleRate: 1.0,
14+
replaysOnErrorSampleRate: 0.0,
15+
debug: true,
16+
17+
integrations: [window.Replay],
18+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { getReplaySnapshot, shouldSkipReplayTest } from '../../../../utils/replayHelpers';
5+
6+
sentryTest('does not setup up canvas without ReplayCanvas integration', async ({ getLocalTestUrl, page }) => {
7+
if (shouldSkipReplayTest()) {
8+
sentryTest.skip();
9+
}
10+
11+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
12+
return route.fulfill({
13+
status: 200,
14+
contentType: 'application/json',
15+
body: JSON.stringify({ id: 'test-id' }),
16+
});
17+
});
18+
19+
const url = await getLocalTestUrl({ testDir: __dirname });
20+
21+
await page.goto(url);
22+
23+
const replay = await getReplaySnapshot(page);
24+
const canvasOptions = replay._options._experiments?.canvas;
25+
expect(canvasOptions).toBe(undefined);
26+
expect(replay._hasCanvas).toBe(false);
27+
});

packages/browser-integration-tests/utils/replayHelpers.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
/* eslint-disable max-lines */
12
import type { fullSnapshotEvent, incrementalSnapshotEvent } from '@sentry-internal/rrweb';
23
import { EventType } from '@sentry-internal/rrweb';
34
import type { ReplayEventWithTime } from '@sentry/browser';
45
import type {
56
InternalEventContext,
67
RecordingEvent,
78
ReplayContainer,
9+
ReplayPluginOptions,
810
Session,
911
} from '@sentry/replay/build/npm/types/types';
1012
import type { Breadcrumb, Event, ReplayEvent, ReplayRecordingMode } from '@sentry/types';
@@ -176,6 +178,8 @@ export function getReplaySnapshot(page: Page): Promise<{
176178
_isPaused: boolean;
177179
_isEnabled: boolean;
178180
_context: InternalEventContext;
181+
_options: ReplayPluginOptions;
182+
_hasCanvas: boolean;
179183
session: Session | undefined;
180184
recordingMode: ReplayRecordingMode;
181185
}> {
@@ -187,6 +191,9 @@ export function getReplaySnapshot(page: Page): Promise<{
187191
_isPaused: replay.isPaused(),
188192
_isEnabled: replay.isEnabled(),
189193
_context: replay.getContext(),
194+
_options: replay.getOptions(),
195+
// We cannot pass the function through as this is serialized
196+
_hasCanvas: typeof replay.getOptions()._experiments.canvas?.manager === 'function',
190197
session: replay.session,
191198
recordingMode: replay.recordingMode,
192199
};

packages/browser/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const INTEGRATIONS = {
2020

2121
export { INTEGRATIONS as Integrations };
2222

23-
export { Replay } from '@sentry/replay';
23+
export { Replay, ReplayCanvas } from '@sentry/replay';
2424
export type {
2525
ReplayEventType,
2626
ReplayEventWithTime,

packages/replay/rollup.bundle.config.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,20 @@ import { makeBaseBundleConfig, makeBundleConfigVariants } from '../../rollup/ind
22

33
const baseBundleConfig = makeBaseBundleConfig({
44
bundleType: 'addon',
5-
entrypoints: ['src/index.ts'],
5+
entrypoints: ['src/integration.ts'],
66
jsVersion: 'es6',
77
licenseTitle: '@sentry/replay',
88
outputFileBase: () => 'bundles/replay',
99
});
1010

11-
const builds = makeBundleConfigVariants(baseBundleConfig);
11+
const baseCanvasBundleConfig = makeBaseBundleConfig({
12+
bundleType: 'addon',
13+
entrypoints: ['src/canvas.ts'],
14+
jsVersion: 'es6',
15+
licenseTitle: '@sentry/replaycanvas',
16+
outputFileBase: () => 'bundles/replaycanvas',
17+
});
18+
19+
const builds = [...makeBundleConfigVariants(baseBundleConfig), ...makeBundleConfigVariants(baseCanvasBundleConfig)];
1220

1321
export default builds;

packages/replay/src/canvas.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { getCanvasManager } from '@sentry-internal/rrweb';
2+
import type { Integration } from '@sentry/types';
3+
import type { ReplayConfiguration } from './types';
4+
5+
interface ReplayCanvasOptions {
6+
fps: number;
7+
quality: number;
8+
}
9+
10+
/** An integration to add canvas recording to replay. */
11+
export class ReplayCanvas implements Integration {
12+
/**
13+
* @inheritDoc
14+
*/
15+
public static id: string = 'ReplayCanvas';
16+
17+
/**
18+
* @inheritDoc
19+
*/
20+
public name: string;
21+
22+
private _canvasOptions: ReplayCanvasOptions;
23+
24+
public constructor(options?: Partial<ReplayCanvasOptions>) {
25+
this.name = ReplayCanvas.id;
26+
this._canvasOptions = {
27+
fps: 4,
28+
quality: 0.6,
29+
...options,
30+
};
31+
}
32+
33+
/** @inheritdoc */
34+
public setupOnce(): void {
35+
// noop
36+
}
37+
38+
/**
39+
* Get the options that should be merged into replay options.
40+
* This is what is actually called by the Replay integration to setup canvas.
41+
*/
42+
public getOptions(): Partial<ReplayConfiguration> {
43+
return {
44+
_experiments: {
45+
canvas: {
46+
...this._canvasOptions,
47+
manager: getCanvasManager,
48+
},
49+
},
50+
};
51+
}
52+
}

packages/replay/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { Replay } from './integration';
2+
export { ReplayCanvas } from './canvas';
23

34
export type {
45
ReplayEventType,

packages/replay/src/integration.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`,
318318

319319
return this._replay.getSessionId();
320320
}
321+
321322
/**
322323
* Initializes replay.
323324
*/
@@ -326,6 +327,12 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`,
326327
return;
327328
}
328329

330+
// We have to run this in _initialize, because this runs in setTimeout
331+
// So when this runs all integrations have been added
332+
// Before this, we cannot access integrations on the client,
333+
// so we need to mutate the options here
334+
this._maybeLoadFromReplayCanvasIntegration();
335+
329336
this._replay.initializeSampling();
330337
}
331338

@@ -339,6 +346,40 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`,
339346
recordingOptions: this._recordingOptions,
340347
});
341348
}
349+
350+
/** Get canvas options from ReplayCanvas integration, if it is also added. */
351+
private _maybeLoadFromReplayCanvasIntegration(): void {
352+
// If already defined, skip this...
353+
if (this._initialOptions._experiments.canvas) {
354+
return;
355+
}
356+
357+
// To save bundle size, we skip checking for stuff here
358+
// and instead just try-catch everything - as generally this should all be defined
359+
/* eslint-disable @typescript-eslint/no-non-null-assertion */
360+
try {
361+
const client = getClient()!;
362+
const canvasIntegration = client.getIntegrationById!('ReplayCanvas') as Integration & {
363+
getOptions(): Partial<ReplayConfiguration>;
364+
};
365+
if (!canvasIntegration) {
366+
return;
367+
}
368+
const additionalOptions = canvasIntegration.getOptions();
369+
370+
const mergedExperimentsOptions = {
371+
...this._initialOptions._experiments,
372+
...additionalOptions._experiments,
373+
};
374+
375+
this._initialOptions._experiments = mergedExperimentsOptions;
376+
377+
this._replay!.getOptions()._experiments = mergedExperimentsOptions;
378+
} catch {
379+
// ignore errors here
380+
}
381+
/* eslint-enable @typescript-eslint/no-non-null-assertion */
382+
}
342383
}
343384

344385
/** Parse Replay-related options from SDK options */

0 commit comments

Comments
 (0)