diff --git a/examples/sdk/browser/package.json b/examples/sdk/browser/package.json index 316efadd..f6e94e59 100644 --- a/examples/sdk/browser/package.json +++ b/examples/sdk/browser/package.json @@ -41,6 +41,7 @@ }, "dependencies": { "@reduxjs/toolkit": "^1.9.5", - "@backtrace/browser": "^0.1.0" + "@backtrace/browser": "^0.2.0", + "@backtrace/session-replay": "^0.0.1" } } diff --git a/examples/sdk/browser/src/index.ts b/examples/sdk/browser/src/index.ts index 781c6234..58f6767a 100644 --- a/examples/sdk/browser/src/index.ts +++ b/examples/sdk/browser/src/index.ts @@ -1,8 +1,9 @@ import { BacktraceClient, BacktraceStringAttachment, createBacktraceReduxMiddleware } from '@backtrace/browser'; +import { BacktraceSessionReplayModule } from '@backtrace/session-replay'; import { configureStore, createSlice } from '@reduxjs/toolkit'; import { SUBMISSION_URL } from './consts'; -const client = BacktraceClient.initialize({ +const client = BacktraceClient.builder({ url: SUBMISSION_URL, name: '@backtrace/browser-example', version: '0.0.1', @@ -13,7 +14,13 @@ const client = BacktraceClient.initialize({ prop2: 123, }, }, -}); +}) + .useModule( + new BacktraceSessionReplayModule({ + maxEventCount: 100, + }), + ) + .build(); interface DemoState { count: number; diff --git a/package-lock.json b/package-lock.json index cb880bd7..be8a4346 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,8 @@ "tools/rollup-plugin", "tools/webpack-plugin", "tools/vite-plugin", - "packages/electron" + "packages/electron", + "packages/session-replay" ], "devDependencies": { "@types/node": "^14.18.51", @@ -2544,6 +2545,10 @@ "resolved": "packages/sdk-core", "link": true }, + "node_modules/@backtrace/session-replay": { + "resolved": "packages/session-replay", + "link": true + }, "node_modules/@backtrace/sourcemap-tools": { "resolved": "tools/sourcemap-tools", "link": true @@ -4473,6 +4478,14 @@ } } }, + "node_modules/@rrweb/types": { + "version": "2.0.0-alpha.11", + "resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.0.0-alpha.11.tgz", + "integrity": "sha512-8ccocIkT5J/bfNRQY85qR/g6p5YQFpgFO2cMt4+Ex7w31Lq0yqZBRaoYEsawQKpLrn5KOHkdn2UTUrna7WMQuA==", + "dependencies": { + "rrweb-snapshot": "^2.0.0-alpha.11" + } + }, "node_modules/@sideway/address": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", @@ -4708,6 +4721,11 @@ "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", "dev": true }, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz", + "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==" + }, "node_modules/@types/decompress": { "version": "4.2.7", "resolved": "https://registry.npmjs.org/@types/decompress/-/decompress-4.2.7.tgz", @@ -5576,6 +5594,11 @@ } } }, + "node_modules/@xstate/fsm": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz", + "integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==" + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -6455,6 +6478,14 @@ "node": ">=0.10.0" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -10061,6 +10092,11 @@ "pend": "~1.2.0" } }, + "node_modules/fflate": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", + "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==" + }, "node_modules/figgy-pudding": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz", @@ -14694,6 +14730,11 @@ "node": ">=4.0.0" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" + }, "node_modules/mixin-deep": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", @@ -16986,6 +17027,34 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrdom": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/rrdom/-/rrdom-0.1.7.tgz", + "integrity": "sha512-ZLd8f14z9pUy2Hk9y636cNv5Y2BMnNEY99wxzW9tD2BLDfe1xFxtLjB4q/xCBYo6HRe0wofzKzjm4JojmpBfFw==", + "dependencies": { + "rrweb-snapshot": "^2.0.0-alpha.4" + } + }, + "node_modules/rrweb": { + "version": "2.0.0-alpha.4", + "resolved": "https://registry.npmjs.org/rrweb/-/rrweb-2.0.0-alpha.4.tgz", + "integrity": "sha512-wEHUILbxDPcNwkM3m4qgPgXAiBJyqCbbOHyVoNEVBJzHszWEFYyTbrZqUdeb1EfmTRC2PsumCIkVcomJ/xcOzA==", + "dependencies": { + "@rrweb/types": "^2.0.0-alpha.4", + "@types/css-font-loading-module": "0.0.7", + "@xstate/fsm": "^1.4.0", + "base64-arraybuffer": "^1.0.1", + "fflate": "^0.4.4", + "mitt": "^3.0.0", + "rrdom": "^0.1.7", + "rrweb-snapshot": "^2.0.0-alpha.4" + } + }, + "node_modules/rrweb-snapshot": { + "version": "2.0.0-alpha.11", + "resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.11.tgz", + "integrity": "sha512-N0dzeJA2VhrlSOadkKwCVmV/DuNOwBH+Lhx89hAf9PQK4lCS8AP4AaylhqUdZOYHqwVjqsYel/uZ4hN79vuLhw==" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -21121,6 +21190,16 @@ "typescript": "^5.0.4" } }, + "packages/session-replay": { + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "rrweb": "^2.0.0-alpha.4" + }, + "peerDependencies": { + "@backtrace/sdk-core": "^0.2.0" + } + }, "tools/cli": { "name": "@backtrace/javascript-cli", "version": "0.3.2", diff --git a/package.json b/package.json index 472bbb89..9cef27e0 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "tools/rollup-plugin", "tools/webpack-plugin", "tools/vite-plugin", - "packages/electron" + "packages/electron", + "packages/session-replay" ], "repository": { "type": "git", diff --git a/packages/sdk-core/src/builder/BacktraceCoreClientBuilder.ts b/packages/sdk-core/src/builder/BacktraceCoreClientBuilder.ts index c68dd942..0a156edc 100644 --- a/packages/sdk-core/src/builder/BacktraceCoreClientBuilder.ts +++ b/packages/sdk-core/src/builder/BacktraceCoreClientBuilder.ts @@ -1,5 +1,6 @@ import { BacktraceReportSubmission } from '../model/http/BacktraceReportSubmission'; import { BacktraceRequestHandler } from '../model/http/BacktraceRequestHandler'; +import { BacktraceModule } from '../modules/BacktraceModule'; import { BacktraceAttributeProvider } from '../modules/attribute/BacktraceAttributeProvider'; import { BreadcrumbsEventSubscriber, BreadcrumbsStorage } from '../modules/breadcrumbs'; import { BacktraceStackTraceConverter } from '../modules/converter'; @@ -81,4 +82,13 @@ export abstract class BacktraceCoreClientBuilder", + "license": "MIT", + "bugs": { + "url": "https://github.com/backtrace-labs/backtrace-javascript/issues" + }, + "files": [ + "/lib" + ], + "homepage": "https://github.com/backtrace-labs/backtrace-javascript#readme", + "peerDependencies": { + "@backtrace/sdk-core": "^0.2.0" + }, + "dependencies": { + "rrweb": "^2.0.0-alpha.4" + } +} diff --git a/packages/session-replay/src/BacktraceSessionRecorder.ts b/packages/session-replay/src/BacktraceSessionRecorder.ts new file mode 100644 index 00000000..e9aa0a08 --- /dev/null +++ b/packages/session-replay/src/BacktraceSessionRecorder.ts @@ -0,0 +1,72 @@ +import { BacktraceAttachment } from '@backtrace/sdk-core'; +import { eventWithTime } from '@rrweb/types'; +import { record } from 'rrweb'; +import { BacktraceSessionRecorderOptions } from './options'; + +export class BacktraceSessionRecorder implements BacktraceAttachment { + public readonly name = 'bt-session-replay-0'; + public readonly type = 'dynamic'; + + private readonly _maxEventCount?: number; + private readonly _maxTime?: number; + + private _previousEvents?: eventWithTime[] = []; + private _events?: eventWithTime[] = []; + + private _stop?: () => void; + + constructor(private readonly _options: BacktraceSessionRecorderOptions) { + this._events = []; + this._maxEventCount = !_options.disableMaxEventCount ? _options.maxEventCount ?? 100 : undefined; + this._maxTime = !_options.disableMaxTime ? _options.maxTime : undefined; + } + + public start() { + this._stop = record({ + ...this._options.advancedOptions, + sampling: { + mousemove: this._options.sampling?.mousemove, + mouseInteraction: this._options.sampling?.mouseInteraction, + input: this._options.sampling?.input, + media: this._options.sampling?.media, + scroll: this._options.sampling?.scroll, + ...this._options.advancedOptions, + }, + emit: (event, isCheckout) => this.onEmit(event, isCheckout), + checkoutEveryNth: this._maxEventCount && Math.ceil(this._maxEventCount / 2), + checkoutEveryNms: this._maxTime && Math.ceil(this._maxTime / 2), + }); + } + + public stop() { + if (this._stop) { + this._stop(); + } + } + + public get(): string { + const events = [...(this._events ?? []), ...(this._previousEvents ?? [])]; + return JSON.stringify(events); + } + + private onEmit(event: eventWithTime, isCheckout?: boolean) { + if (isCheckout || !this._events) { + this._previousEvents = this._events; + this._events = []; + } + + if (this._options.privacy?.inspect) { + const inspected = this._options.privacy.inspect(event); + if (!inspected) { + return; + } + event = inspected; + } + + this._events.push(event); + + if (this._options.advancedOptions?.emit) { + this._options.advancedOptions.emit(event as never, isCheckout); + } + } +} diff --git a/packages/session-replay/src/BacktraceSessionReplayModule.ts b/packages/session-replay/src/BacktraceSessionReplayModule.ts new file mode 100644 index 00000000..dfd988eb --- /dev/null +++ b/packages/session-replay/src/BacktraceSessionReplayModule.ts @@ -0,0 +1,34 @@ +import { BacktraceModule, BacktraceModuleBindData } from '@backtrace/sdk-core'; +import { BacktraceSessionRecorder } from './BacktraceSessionRecorder'; +import { BacktraceSessionRecorderOptions } from './options'; + +/** + * Adds session recorder module to `BacktraceClient`. + * + * Add using `useModule` on `BacktraceClient` builder. + * @example + const client = BacktraceClient.builder({ + ... + }).useModule(new BacktraceSessionReplayModule({ + // options here + })).build(); + */ +export class BacktraceSessionReplayModule implements BacktraceModule { + private readonly _recorder: BacktraceSessionRecorder; + + constructor(options?: BacktraceSessionRecorderOptions) { + this._recorder = new BacktraceSessionRecorder(options ?? {}); + } + + public bind({ client }: BacktraceModuleBindData): void { + client.addAttachment(this._recorder); + } + + public initialize(): void { + this._recorder.start(); + } + + public dispose(): void { + this._recorder.stop(); + } +} diff --git a/packages/session-replay/src/index.ts b/packages/session-replay/src/index.ts new file mode 100644 index 00000000..70806baf --- /dev/null +++ b/packages/session-replay/src/index.ts @@ -0,0 +1,2 @@ +export * from './BacktraceSessionRecorder'; +export * from './BacktraceSessionReplayModule'; diff --git a/packages/session-replay/src/options.ts b/packages/session-replay/src/options.ts new file mode 100644 index 00000000..586a1dc9 --- /dev/null +++ b/packages/session-replay/src/options.ts @@ -0,0 +1,226 @@ +import { MouseInteractions, eventWithTime } from '@rrweb/types'; +import { MaskInputFn, MaskInputOptions, MaskTextFn } from 'rrweb-snapshot'; +import { recordOptions } from 'rrweb/typings/types'; + +export interface BacktraceSessionRecorderSamplingOptions { + /** + * Controls whether mouse movement is recorded. + * @default true + */ + readonly mousemove?: boolean | number; + + /** + * Controls specific mouse interactions. + * + * Can be one of: + * * `true` - all mouse interactions will be recorded + * * `false` - no mouse interactions will be recorded + * * `{[string]: boolean}` - control specific mouse interactions: + * * `MouseUp` + * * `MouseDown` + * * `Click` + * * `ContextMenu` + * * `DblClick` + * * `Focus` + * * `Blur` + * * `TouchStart` + * * `TouchMove_Departed` + * * `TouchEnd` + * * `TouchCancel` + * @default true + * @example + * // Record only MouseUp and ContextMenu interactions: + * mouseInteraction = { + * DblClick: false, + * Blur: false, + * Click: false, + * ContextMenu: true, + * Focus: false, + * MouseDown: false, + * MouseUp: true, + * TouchCancel: false, + * TouchEnd: false, + * TouchMove_Departed: false, + * TouchStart: false, + * }; + */ + readonly mouseInteraction?: + | boolean + | Partial> + | Partial>; + + /** + * Interval of scrolling events in milliseconds (i.e. will not capture more than one event every set time). + * @default undefined + */ + readonly scroll?: number; + + /** + * Interval of media events in milliseconds (i.e. will not capture more than one event every set time). + * @default undefined + */ + readonly media?: number; + + /** + * Capture either `all` or `last` input events. + * + * When set to `last`, only final input will be captured. + * @default "all" + */ + readonly input?: 'all' | 'last'; +} + +export interface BacktraceSessionReplayPrivacyOptions { + /** + * Use a `string` or `RegExp` to configure which elements should be blocked. + * @default "rr-block" + */ + readonly blockClass?: string | RegExp; + + /** + * Use a `string` to configure which selector should be blocked. + * @default undefined + */ + readonly blockSelector?: string; + + /** + * Use a `string` or `RegExp` to configure which elements should be ignored. + * @default "rr-ignore" + */ + readonly ignoreClass?: string; + + /** + * Use a `string` to configure which selector should be ignored. + * @default undefined + */ + readonly ignoreSelector?: string; + + /** + * Array of CSS attributes that should be ignored. + */ + readonly ignoreCSSAttributes?: string; + + /** + * Use a `string` or `RegExp` to configure which elements should be masked. + * @default "rr-mask" + */ + readonly maskTextClass?: string; + + /** + * Use a `string` to configure which selector should be masked. + * @default undefined + */ + readonly maskTextSelector?: string; + + /** + * If `true`, will mask all inputs. + * @default false + */ + readonly maskAllInputs?: boolean; + + /** + * Mask specific kinds of input. + * + * Can be an object with the following keys: + * * `color` + * * `date` + * * `'datetime-local'` + * * `email` + * * `month` + * * `number` + * * `range` + * * `search` + * * `tel` + * * `text` + * * `time` + * * `url` + * * `week` + * * `textarea` + * * `select` + * * `password` + * @default { password: true } + */ + readonly maskInputOptions?: MaskInputOptions; + + /** + * Callback to customize input masking. + * @param text input text to mask + * @param element input HTML element + * @returns masked text + * @default undefined + * @example + * // replace text with letter A repeated for the text length if element has class "mask-a" + * maskInputFn = (text, element) => + * element.classList.contains('mask-a') + * ? 'A'.repeat(text.length) + * : text; + */ + readonly maskInputFn?: MaskInputFn; + + /** + * Callback to customize text masking. + * @param text text to mask + * @returns masked text + * @default undefined + * @example + * // replace text with letter A repeated for the text length + * maskTextFn = (text) => 'A'.repeat(text.length); + */ + readonly maskTextFn?: MaskTextFn; + + /** + * Callback to inspect the added event. You must return an event for it to be included. + * + * Return `undefined` to skip this event. + * @param event Event to be added to the report. + * @returns modified event or `undefined` + */ + readonly inspect?: (event: eventWithTime) => eventWithTime | undefined; +} + +export interface BacktraceSessionRecorderOptions { + /** + * Maximum recorded event count to be sent with the report. + * + * Set `disableMaxEventCount` to `true` to disable the limit. + * @default 100 + */ + readonly maxEventCount?: number; + + /** + * Disables `maxEventCount` limit. + * + * @default false + */ + readonly disableMaxEventCount?: boolean; + + /** + * Maximum timeframe for recorded events to be sent with the report. + * + * Set `disableMaxTime` to `true` to disable the limit. + * @default undefined + */ + readonly maxTime?: number; + + /** + * Disables `maxEventCount` limit. + * + * @default false + */ + readonly disableMaxTime?: boolean; + + /** + * Sampling options. Use those to reduce event count or size. + */ + readonly sampling?: BacktraceSessionRecorderSamplingOptions; + + /** + * Privacy options. Use those to remove confidendial data. + */ + readonly privacy?: BacktraceSessionReplayPrivacyOptions; + + /** + * Options passed to `rrweb.record` function. Refer to `rrweb` documentation for more information. + */ + readonly advancedOptions?: recordOptions; +} diff --git a/packages/session-replay/tsconfig.json b/packages/session-replay/tsconfig.json new file mode 100644 index 00000000..93e86d7f --- /dev/null +++ b/packages/session-replay/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "composite": true + }, + "exclude": ["node_modules", "tests", "lib"], + "references": [ + { + "path": "../sdk-core/tsconfig.json" + } + ] +} diff --git a/packages/session-replay/webpack.config.js b/packages/session-replay/webpack.config.js new file mode 100644 index 00000000..a47cc23c --- /dev/null +++ b/packages/session-replay/webpack.config.js @@ -0,0 +1,28 @@ +const path = require('path'); +const { webpackTypescriptConfig, minifiedAndUnminified } = require('../../build/common'); +const agentDefinitionPlugin = require('../../build/agentDefinitionPlugin'); + +/** @type {import('webpack').Configuration} */ +const common = { + ...webpackTypescriptConfig, + target: 'web', + mode: process.env.NODE_ENV ?? 'production', + devtool: 'source-map', + entry: './src/index.ts', + plugins: [agentDefinitionPlugin(path.join(__dirname, 'package.json'))], +}; + +/** @type {Array} */ +module.exports = [ + ...minifiedAndUnminified({ + ...common, + output: { + filename: 'index.js', + path: path.join(__dirname, 'lib'), + library: { + name: 'Backtrace', + type: 'umd', + }, + }, + }), +];