Skip to content

Commit 38b1a4e

Browse files
mydeabillyvg
authored andcommitted
feat(replay): Add ReplayCanvas integration
Adding this integration in addition to `Replay` will set up canvas recording.
1 parent ecb4f7f commit 38b1a4e

File tree

15 files changed

+271
-4
lines changed

15 files changed

+271
-4
lines changed

.size-limit.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ module.exports = [
1414
gzip: true,
1515
limit: '75 KB',
1616
},
17+
{
18+
name: '@sentry/browser (incl. Tracing, Replay with Canvas) - Webpack (gzipped)',
19+
path: 'packages/browser/build/npm/esm/index.js',
20+
import: '{ init, Replay, BrowserTracing, ReplayCanvas }',
21+
gzip: true,
22+
limit: '90 KB',
23+
},
1724
{
1825
name: '@sentry/browser (incl. Tracing, Replay) - Webpack with treeshaking flags (gzipped)',
1926
path: 'packages/browser/build/npm/esm/index.js',

dev-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';
@@ -171,6 +173,8 @@ export function getReplaySnapshot(page: Page): Promise<{
171173
_isPaused: boolean;
172174
_isEnabled: boolean;
173175
_context: InternalEventContext;
176+
_options: ReplayPluginOptions;
177+
_hasCanvas: boolean;
174178
session: Session | undefined;
175179
recordingMode: ReplayRecordingMode;
176180
}> {
@@ -182,6 +186,9 @@ export function getReplaySnapshot(page: Page): Promise<{
182186
_isPaused: replay.isPaused(),
183187
_isEnabled: replay.isEnabled(),
184188
_context: replay.getContext(),
189+
_options: replay.getOptions(),
190+
// We cannot pass the function through as this is serialized
191+
_hasCanvas: typeof replay.getOptions()._experiments.canvas?.manager === 'function',
185192
session: replay.session,
186193
recordingMode: replay.recordingMode,
187194
};
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: 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/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.mjs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,20 @@ import { makeBaseBundleConfig, makeBundleConfigVariants } from '@sentry-internal
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: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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() {
25+
this.name = ReplayCanvas.id;
26+
// TODO FN: Allow to configure this
27+
// But since we haven't finalized how to configure this, this is predefined for now
28+
// to avoid breaking changes
29+
this._canvasOptions = {
30+
fps: 4,
31+
quality: 0.6,
32+
};
33+
}
34+
35+
/** @inheritdoc */
36+
public setupOnce(): void {
37+
// noop
38+
}
39+
40+
/**
41+
* Get the options that should be merged into replay options.
42+
* This is what is actually called by the Replay integration to setup canvas.
43+
*/
44+
public getOptions(): Partial<ReplayConfiguration> {
45+
return {
46+
_experiments: {
47+
canvas: {
48+
...this._canvasOptions,
49+
manager: getCanvasManager,
50+
},
51+
},
52+
};
53+
}
54+
}

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 */

packages/types/src/client.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,12 @@ export interface Client<O extends ClientOptions = ClientOptions> {
138138
*/
139139
getEventProcessors?(): EventProcessor[];
140140

141-
/** Returns the client's instance of the given integration class, it any. */
141+
/** Returns the client's instance of the given integration class, if it exists. */
142142
getIntegration<T extends Integration>(integration: IntegrationClass<T>): T | null;
143143

144+
/** Returns the client's instance of the given integration name, if it exists. */
145+
getIntegrationById?(integrationId: string): Integration | undefined;
146+
144147
/**
145148
* Add an integration to the client.
146149
* This can be used to e.g. lazy load integrations.

0 commit comments

Comments
 (0)