diff --git a/packages/integration-tests/utils/helpers.ts b/packages/integration-tests/utils/helpers.ts index afcd5c74683a..443e3e0e57af 100644 --- a/packages/integration-tests/utils/helpers.ts +++ b/packages/integration-tests/utils/helpers.ts @@ -3,7 +3,7 @@ import type { EnvelopeItemType, Event, EventEnvelopeHeaders } from '@sentry/type const envelopeUrlRegex = /\.sentry\.io\/api\/\d+\/envelope\//; -export const envelopeRequestParser = (request: Request | null): Event => { +export const envelopeParser = (request: Request | null): unknown[] => { // https://develop.sentry.dev/sdk/envelopes/ const envelope = request?.postData() || ''; @@ -14,7 +14,11 @@ export const envelopeRequestParser = (request: Request | null): Event => { } catch (error) { return line; } - })[2]; + }); +}; + +export const envelopeRequestParser = (request: Request | null): Event => { + return envelopeParser(request)[2] as Event; }; export const envelopeHeaderRequestParser = (request: Request | null): EventEnvelopeHeaders => { diff --git a/packages/replay/README.md b/packages/replay/README.md index 92babf3fe819..c65700a1389c 100644 --- a/packages/replay/README.md +++ b/packages/replay/README.md @@ -83,7 +83,7 @@ Sentry.setUser({ email: "jane.doe@example.com" }); ### Stopping & re-starting replays -You can manually stop/re-start Replay capture via `.stop()` & `.start()`: +Replay recording only starts when it is included in the `integrations` array when calling `Sentry.init` or calling `addIntegration` from the a Sentry client instance. To stop recording you can call the `stop()`. ```js const replay = new Replay(); @@ -91,9 +91,12 @@ Sentry.init({ integrations: [replay] }); -// sometime later -replay.stop(); -replay.start(); +const client = getClient(); + +// Add replay integration, will start recoring +client.addIntegration(replay); + +replay.stop(); // Stop recording ``` ## Loading Replay as a CDN Bundle @@ -185,19 +188,29 @@ The following options can be configured as options to the integration, in `new R The following options can be configured as options to the integration, in `new Replay({})`: -| key | type | default | description | -| ---------------- | ------------------------ | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| maskAllText | boolean | `true` | Mask _all_ text content. Will pass text content through `maskTextFn` before sending to server. | -| blockAllMedia | boolean | `true` | Block _all_ media elements (`img, svg, video, object, picture, embed, map, audio`) -| maskTextFn | (text: string) => string | `(text) => '*'.repeat(text.length)` | Function to customize how text content is masked before sending to server. By default, masks text with `*`. | -| maskAllInputs | boolean | `true` | Mask values of `` elements. Passes input values through `maskInputFn` before sending to server. | -| maskInputOptions | Record | `{ password: true }` | Customize which inputs `type` to mask.
Available `` types: `color, date, datetime-local, email, month, number, range, search, tel, text, time, url, week, textarea, select, password`. | -| maskInputFn | (text: string) => string | `(text) => '*'.repeat(text.length)` | Function to customize how form input values are masked before sending to server. By default, masks values with `*`. | -| blockClass | string \| RegExp | `'sentry-block'` | Redact all elements that match the class name. See [privacy](#blocking) section for an example. | -| blockSelector | string | `'[data-sentry-block]'` | Redact all elements that match the DOM selector. See [privacy](#blocking) section for an example. | -| ignoreClass | string \| RegExp | `'sentry-ignore'` | Ignores all events on the matching input field. See [privacy](#ignoring) section for an example. | -| maskTextClass | string \| RegExp | `'sentry-mask'` | Mask all elements that match the class name. See [privacy](#masking) section for an example. | -| maskTextSelector | string | `undefined` | Mask all elements that match the given DOM selector. See [privacy](#masking) section for an example. | +| key | type | default | description | +| ---------------- | ------------------------ | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| maskAllText | boolean | `true` | Mask _all_ text content. Will pass text content through `maskTextFn` before sending to server. | +| maskAllInputs | boolean | `true` | Mask values of `` elements. Passes input values through `maskInputFn` before sending to server. | +| blockAllMedia | boolean | `true` | Block _all_ media elements (`img, svg, video, object, picture, embed, map, audio`) +| maskTextFn | (text: string) => string | `(text) => '*'.repeat(text.length)` | Function to customize how text content is masked before sending to server. By default, masks text with `*`. | +| block | Array | `.sentry-block, [data-sentry-block]` | Redact any elements that match the DOM selectors. See [privacy](#blocking) section for an example. | +| unblock | Array | `.sentry-unblock, [data-sentry-unblock]`| Do not redact any elements that match the DOM selectors. Useful when using `blockAllMedia`. See [privacy](#blocking) section for an example. | +| mask | Array | `.sentry-mask, [data-sentry-mask]` | Mask all elements that match the given DOM selectors. See [privacy](#masking) section for an example. | +| unmask | Array | `.sentry-unmask, [data-sentry-unmask]` | Unmask all elements that match the given DOM selectors. Useful when using `maskAllText`. See [privacy](#masking) section for an example. | +| ignore | Array | `.sentry-ignore, [data-sentry-ignore]` | Ignores all events on the matching input fields. See [privacy](#ignoring) section for an example. | + +#### Deprecated options +In order to streamline our privacy options, the following have been deprecated in favor for the respective options above. + +| deprecated key | replaced by | description | +| ---------------- | ----------- | ----------- | +| maskInputOptions | mask | Use CSS selectors in `mask` in order to mask all inputs of a certain type. For example, `input[type="address"]` | +| blockSelector | block | The selector(s) can be moved directly in the `block` array. | +| blockClass | block | Convert the class name to a CSS selector and add to `block` array. For example, `first-name` becomes `.first-name`. Regexes can be moved as-is. | +| maskClass | mask | Convert the class name to a CSS selector and add to `mask` array. For example, `first-name` becomes `.first-name`. Regexes can be moved as-is. | +| maskSelector | mask | The selector(s) can be moved directly in the `mask` array. | +| ignoreClass | ignore | Convert the class name to a CSS selector and add to `ignore` array. For example, `first-name` becomes `.first-name`. Regexes can be moved as-is. | ## Privacy There are several ways to deal with PII. By default, the integration will mask all text content with `*` and block all media elements (`img, svg, video, object, picture, embed, map, audio`). This can be disabled by setting `maskAllText` to `false`. It is also possible to add the following CSS classes to specific DOM elements to prevent recording its contents: `sentry-block`, `sentry-ignore`, and `sentry-mask`. The following sections will show examples of how content is handled by the differing methods. diff --git a/packages/replay/src/integration.ts b/packages/replay/src/integration.ts index daf676c82efd..a7753ac44cb5 100644 --- a/packages/replay/src/integration.ts +++ b/packages/replay/src/integration.ts @@ -4,6 +4,7 @@ import type { BrowserClientReplayOptions, Integration } from '@sentry/types'; import { DEFAULT_FLUSH_MAX_DELAY, DEFAULT_FLUSH_MIN_DELAY, MASK_ALL_TEXT_SELECTOR } from './constants'; import { ReplayContainer } from './replay'; import type { RecordingOptions, ReplayConfiguration, ReplayPluginOptions } from './types'; +import { getPrivacyOptions } from './util/getPrivacyOptions'; import { isBrowser } from './util/isBrowser'; const MEDIA_SELECTORS = 'img,image,svg,path,rect,area,video,object,picture,embed,map,audio'; @@ -38,27 +39,57 @@ export class Replay implements Integration { flushMaxDelay = DEFAULT_FLUSH_MAX_DELAY, stickySession = true, useCompression = true, + _experiments = {}, sessionSampleRate, errorSampleRate, maskAllText, - maskTextSelector, maskAllInputs = true, blockAllMedia = true, - _experiments = {}, - blockClass = 'sentry-block', - ignoreClass = 'sentry-ignore', - maskTextClass = 'sentry-mask', - blockSelector = '[data-sentry-block]', - ..._recordingOptions + + mask = [], + unmask = [], + block = [], + unblock = [], + ignore = [], + maskFn, + + // eslint-disable-next-line deprecation/deprecation + blockClass, + // eslint-disable-next-line deprecation/deprecation + blockSelector, + // eslint-disable-next-line deprecation/deprecation + maskTextClass, + // eslint-disable-next-line deprecation/deprecation + maskTextSelector, + // eslint-disable-next-line deprecation/deprecation + ignoreClass, }: ReplayConfiguration = {}) { this._recordingOptions = { maskAllInputs, - blockClass, - ignoreClass, - maskTextClass, - maskTextSelector, - blockSelector, - ..._recordingOptions, + maskTextFn: maskFn, + maskInputFn: maskFn, + + ...getPrivacyOptions({ + mask, + unmask, + block, + unblock, + ignore, + blockClass, + blockSelector, + maskTextClass, + maskTextSelector, + ignoreClass, + }), + + // Our defaults + slimDOMOptions: 'all', + inlineStylesheet: true, + // Disable inline images as it will increase segment/replay size + inlineImages: false, + // collect fonts, but be aware that `sentry.io` needs to be an allowed + // origin for playback + collectFonts: true, }; this._options = { diff --git a/packages/replay/src/types.ts b/packages/replay/src/types.ts index adb676617136..a16b3547e0ec 100644 --- a/packages/replay/src/types.ts +++ b/packages/replay/src/types.ts @@ -113,10 +113,70 @@ export interface ReplayPluginOptions extends SessionOptions { }>; } +export interface ReplayIntegrationPrivacyOptions { + /** + * Mask text content for elements that match the CSS selectors in the list. + */ + mask?: string[]; + + /** + * Unmask text content for elements that match the CSS selectors in the list. + */ + unmask?: string[]; + + /** + * Block elements that match the CSS selectors in the list. Blocking replaces + * the element with an empty placeholder with the same dimensions. + */ + block?: string[]; + + /** + * Unblock elements that match the CSS selectors in the list. This is useful when using `blockAllMedia`. + */ + unblock?: string[]; + + /** + * Ignore input events for elements that match the CSS selectors in the list. + */ + ignore?: string[]; + + /** + * A callback function to customize how your text is masked. + */ + maskFn?: Pick; +} + // These are optional for ReplayPluginOptions because the plugin sets default values type OptionalReplayPluginOptions = Partial; -export interface ReplayConfiguration extends OptionalReplayPluginOptions, RecordingOptions {} +export interface DeprecatedPrivacyOptions { + /** + * @deprecated Use `block` which accepts an array of CSS selectors + */ + blockSelector?: RecordingOptions['blockSelector']; + /** + * @deprecated Use `block` which accepts an array of CSS selectors + */ + blockClass?: RecordingOptions['blockClass']; + /** + * @deprecated Use `mask` which accepts an array of CSS selectors + */ + maskTextClass?: RecordingOptions['maskTextClass']; + /** + * @deprecated Use `mask` which accepts an array of CSS selectors + */ + maskTextSelector?: RecordingOptions['maskTextSelector']; + /** + * @deprecated Use `ignore` which accepts an array of CSS selectors + */ + ignoreClass?: RecordingOptions['ignoreClass']; +} + +export interface ReplayConfiguration + extends ReplayIntegrationPrivacyOptions, + OptionalReplayPluginOptions, + DeprecatedPrivacyOptions, + Pick {} interface CommonEventContext { /** diff --git a/packages/replay/src/types/rrweb.ts b/packages/replay/src/types/rrweb.ts index 1c6ee198eb33..780c7279765a 100644 --- a/packages/replay/src/types/rrweb.ts +++ b/packages/replay/src/types/rrweb.ts @@ -34,5 +34,6 @@ export type recordOptions = { blockClass?: blockClass; ignoreClass?: string; maskTextClass?: maskTextClass; + maskTextSelector?: string; blockSelector?: string; } & Record; diff --git a/packages/replay/src/util/getPrivacyOptions.ts b/packages/replay/src/util/getPrivacyOptions.ts new file mode 100644 index 000000000000..0e5c18ff6345 --- /dev/null +++ b/packages/replay/src/util/getPrivacyOptions.ts @@ -0,0 +1,95 @@ +import type { DeprecatedPrivacyOptions, ReplayIntegrationPrivacyOptions } from '../types'; + +type GetPrivacyOptions = Required> & DeprecatedPrivacyOptions; +interface GetPrivacyReturn { + maskTextSelector: string; + unmaskTextSelector: string; + maskInputSelector: string; + unmaskInputSelector: string; + blockSelector: string; + unblockSelector: string; + ignoreSelector: string; + + blockClass?: RegExp; + maskTextClass?: RegExp; +} + +function getOption( + selectors: string[], + defaultSelectors: string[], + deprecatedClassOption?: string | RegExp, + deprecatedSelectorOption?: string, +): string { + const deprecatedSelectors = typeof deprecatedSelectorOption === 'string' ? deprecatedSelectorOption.split(',') : []; + + const allSelectors = [ + ...selectors, + // @deprecated + ...deprecatedSelectors, + + // sentry defaults + ...defaultSelectors, + ]; + + // @deprecated + if (typeof deprecatedClassOption !== 'undefined') { + // NOTE: No support for RegExp + if (typeof deprecatedClassOption === 'string') { + allSelectors.push(`.${deprecatedClassOption}`); + } + + // eslint-disable-next-line no-console + console.warn( + '[Replay] You are using a deprecated configuration item for privacy. Read the documentation on how to use the new privacy configuration.', + ); + } + + return allSelectors.join(','); +} + +/** + * Returns privacy related configuration for use in rrweb + */ +export function getPrivacyOptions({ + mask, + unmask, + block, + unblock, + ignore, + + // eslint-disable-next-line deprecation/deprecation + blockClass, + // eslint-disable-next-line deprecation/deprecation + blockSelector, + // eslint-disable-next-line deprecation/deprecation + maskTextClass, + // eslint-disable-next-line deprecation/deprecation + maskTextSelector, + // eslint-disable-next-line deprecation/deprecation + ignoreClass, +}: GetPrivacyOptions): GetPrivacyReturn { + const maskSelector = getOption(mask, ['.sentry-mask', '[data-sentry-mask]'], maskTextClass, maskTextSelector); + const unmaskSelector = getOption(unmask, ['.sentry-unmask', '[data-sentry-unmask]']); + + const options: GetPrivacyReturn = { + // We are making the decision to make text and input selectors the same + maskTextSelector: maskSelector, + unmaskTextSelector: unmaskSelector, + maskInputSelector: maskSelector, + unmaskInputSelector: unmaskSelector, + + blockSelector: getOption(block, ['.sentry-block', '[data-sentry-block]'], blockClass, blockSelector), + unblockSelector: getOption(unblock, ['.sentry-unblock', '[data-sentry-unblock]']), + ignoreSelector: getOption(ignore, ['.sentry-ignore', '[data-sentry-ignore]'], ignoreClass), + }; + + if (blockClass instanceof RegExp) { + options.blockClass = blockClass; + } + + if (maskTextClass instanceof RegExp) { + options.maskTextClass = maskTextClass; + } + + return options; +} diff --git a/packages/replay/test/integration/integrationSettings.test.ts b/packages/replay/test/integration/integrationSettings.test.ts index 6c63839b1c06..9e465641dbae 100644 --- a/packages/replay/test/integration/integrationSettings.test.ts +++ b/packages/replay/test/integration/integrationSettings.test.ts @@ -10,14 +10,14 @@ describe('Integration | integrationSettings', () => { it('sets the correct configuration when `blockAllMedia` is disabled', async () => { const { replay } = await mockSdk({ replayOptions: { blockAllMedia: false } }); - expect(replay['_recordingOptions'].blockSelector).toBe('[data-sentry-block]'); + expect(replay['_recordingOptions'].blockSelector).toBe('.sentry-block,[data-sentry-block]'); }); it('sets the correct configuration when `blockSelector` is empty and `blockAllMedia` is enabled', async () => { const { replay } = await mockSdk({ replayOptions: { blockSelector: '' } }); expect(replay['_recordingOptions'].blockSelector).toMatchInlineSnapshot( - '"img,image,svg,path,rect,area,video,object,picture,embed,map,audio"', + '",.sentry-block,[data-sentry-block],img,image,svg,path,rect,area,video,object,picture,embed,map,audio"', ); }); @@ -27,7 +27,7 @@ describe('Integration | integrationSettings', () => { }); expect(replay['_recordingOptions'].blockSelector).toMatchInlineSnapshot( - '"[data-test-blockSelector],img,image,svg,path,rect,area,video,object,picture,embed,map,audio"', + '"[data-test-blockSelector],.sentry-block,[data-sentry-block],img,image,svg,path,rect,area,video,object,picture,embed,map,audio"', ); }); }); @@ -181,13 +181,13 @@ describe('Integration | integrationSettings', () => { it('works with false', async () => { const { replay } = await mockSdk({ replayOptions: { maskAllText: false } }); - expect(replay['_recordingOptions'].maskTextSelector).toBe(undefined); + expect(replay['_recordingOptions'].maskTextSelector).toBe('.sentry-mask,[data-sentry-mask]'); }); it('maskTextSelector takes precedence over maskAllText when not specifiying maskAllText:true', async () => { const { replay } = await mockSdk({ replayOptions: { maskTextSelector: '[custom]' } }); - expect(replay['_recordingOptions'].maskTextSelector).toBe('[custom]'); + expect(replay['_recordingOptions'].maskTextSelector).toBe('[custom],.sentry-mask,[data-sentry-mask]'); }); it('maskAllText takes precedence over maskTextSelector when specifiying maskAllText:true', async () => { diff --git a/packages/replay/test/integration/rrweb.test.ts b/packages/replay/test/integration/rrweb.test.ts index 15c8cdba432b..4d74547f18be 100644 --- a/packages/replay/test/integration/rrweb.test.ts +++ b/packages/replay/test/integration/rrweb.test.ts @@ -1,4 +1,3 @@ -import { MASK_ALL_TEXT_SELECTOR } from '../../src/constants'; import { resetSdkMock } from '../mocks/resetSdkMock'; import { useFakeTimers } from '../utils/use-fake-timers'; @@ -12,19 +11,27 @@ describe('Integration | rrweb', () => { it('calls rrweb.record with custom options', async () => { const { mockRecord } = await resetSdkMock({ replayOptions: { - ignoreClass: 'sentry-test-ignore', + ignore: ['.sentry-test-ignore'], stickySession: false, }, }); expect(mockRecord.mock.calls[0][0]).toMatchInlineSnapshot(` Object { - "blockClass": "sentry-block", - "blockSelector": "[data-sentry-block],img,image,svg,path,rect,area,video,object,picture,embed,map,audio", + "blockSelector": ".sentry-block,[data-sentry-block],img,image,svg,path,rect,area,video,object,picture,embed,map,audio", + "collectFonts": true, "emit": [Function], - "ignoreClass": "sentry-test-ignore", + "ignoreSelector": ".sentry-test-ignore,.sentry-ignore,[data-sentry-ignore]", + "inlineImages": false, + "inlineStylesheet": true, "maskAllInputs": true, - "maskTextClass": "sentry-mask", - "maskTextSelector": "${MASK_ALL_TEXT_SELECTOR}", + "maskInputFn": undefined, + "maskInputSelector": ".sentry-mask,[data-sentry-mask]", + "maskTextFn": undefined, + "maskTextSelector": "body *:not(style), body *:not(script)", + "slimDOMOptions": "all", + "unblockSelector": ".sentry-unblock,[data-sentry-unblock]", + "unmaskInputSelector": ".sentry-unmask,[data-sentry-unmask]", + "unmaskTextSelector": ".sentry-unmask,[data-sentry-unmask]", } `); }); diff --git a/packages/replay/test/unit/util/getPrivacyOptions.test.ts b/packages/replay/test/unit/util/getPrivacyOptions.test.ts new file mode 100644 index 000000000000..689f9b573f0f --- /dev/null +++ b/packages/replay/test/unit/util/getPrivacyOptions.test.ts @@ -0,0 +1,87 @@ +import { getPrivacyOptions } from '../../../src/util/getPrivacyOptions'; + +describe('Unit | util | getPrivacyOptions', () => { + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('has correct default options', () => { + expect( + getPrivacyOptions({ + mask: ['.custom-mask'], + unmask: ['.custom-unmask'], + block: ['.custom-block'], + unblock: ['.custom-unblock'], + ignore: ['.custom-ignore'], + }), + ).toMatchInlineSnapshot(` + Object { + "blockSelector": ".custom-block,.sentry-block,[data-sentry-block]", + "ignoreSelector": ".custom-ignore,.sentry-ignore,[data-sentry-ignore]", + "maskInputSelector": ".custom-mask,.sentry-mask,[data-sentry-mask]", + "maskTextSelector": ".custom-mask,.sentry-mask,[data-sentry-mask]", + "unblockSelector": ".custom-unblock,.sentry-unblock,[data-sentry-unblock]", + "unmaskInputSelector": ".custom-unmask,.sentry-unmask,[data-sentry-unmask]", + "unmaskTextSelector": ".custom-unmask,.sentry-unmask,[data-sentry-unmask]", + } + `); + }); + + it('supports deprecated options', () => { + expect( + getPrivacyOptions({ + mask: ['.custom-mask'], + unmask: ['.custom-unmask'], + block: ['.custom-block'], + unblock: ['.custom-unblock'], + ignore: ['.custom-ignore'], + + blockClass: 'deprecated-block-class', + blockSelector: '.deprecated-block-selector', + maskTextClass: 'deprecated-mask-class', + maskTextSelector: '.deprecated-mask-selector', + ignoreClass: 'deprecated-ignore-class', + }), + ).toMatchInlineSnapshot(` + Object { + "blockSelector": ".custom-block,.deprecated-block-selector,.sentry-block,[data-sentry-block],.deprecated-block-class", + "ignoreSelector": ".custom-ignore,.sentry-ignore,[data-sentry-ignore],.deprecated-ignore-class", + "maskInputSelector": ".custom-mask,.deprecated-mask-selector,.sentry-mask,[data-sentry-mask],.deprecated-mask-class", + "maskTextSelector": ".custom-mask,.deprecated-mask-selector,.sentry-mask,[data-sentry-mask],.deprecated-mask-class", + "unblockSelector": ".custom-unblock,.sentry-unblock,[data-sentry-unblock]", + "unmaskInputSelector": ".custom-unmask,.sentry-unmask,[data-sentry-unmask]", + "unmaskTextSelector": ".custom-unmask,.sentry-unmask,[data-sentry-unmask]", + } + `); + }); + + it('supports deprecated regexp class name', () => { + expect( + getPrivacyOptions({ + mask: ['.custom-mask'], + unmask: ['.custom-unmask'], + block: ['.custom-block'], + unblock: ['.custom-unblock'], + ignore: ['.custom-ignore'], + + blockClass: /deprecated-block-*/, + maskTextClass: /deprecated-mask-*/, + }), + ).toMatchInlineSnapshot(` + Object { + "blockClass": /deprecated-block-\\*/, + "blockSelector": ".custom-block,.sentry-block,[data-sentry-block]", + "ignoreSelector": ".custom-ignore,.sentry-ignore,[data-sentry-ignore]", + "maskInputSelector": ".custom-mask,.sentry-mask,[data-sentry-mask]", + "maskTextClass": /deprecated-mask-\\*/, + "maskTextSelector": ".custom-mask,.sentry-mask,[data-sentry-mask]", + "unblockSelector": ".custom-unblock,.sentry-unblock,[data-sentry-unblock]", + "unmaskInputSelector": ".custom-unmask,.sentry-unmask,[data-sentry-unmask]", + "unmaskTextSelector": ".custom-unmask,.sentry-unmask,[data-sentry-unmask]", + } + `); + }); +});