Skip to content

Commit 5d4fe6d

Browse files
authored
session-replay: PII defaults (#257)
* session-replay: pass privacy options and set defaults * browser example: add input to demonstrate session replay PII * session-replay: update default block/mask/ignore classes to bt-* * session-replay: add additional options to text masking * browser example: add example of unmasked text --------- Co-authored-by: Sebastian Alex <[email protected]>
1 parent 6973281 commit 5d4fe6d

File tree

8 files changed

+520
-15
lines changed

8 files changed

+520
-15
lines changed

examples/sdk/browser/index.html

+2
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
<h1 class="card-header" style="text-align: center">Welcome to the Backtrace demo</h1>
2525
<p style="text-align: center">Please pick one of the available options:</p>
2626
<br />
27+
<p style="text-align: center" class="bt-unmask">This text has class rr-unmask, and will be unmasked on Session Replay.</p>
28+
<input class="center" type="text" placeholder="Test Session Replay Input!">
2729
<div class="action-container center">
2830
<div class="action-button center">
2931
<a class="text" id="send-error" target="_blank"> Send an error</a>

package-lock.json

+5-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/** @type {import('ts-jest').JestConfigWithTsJest} */
2+
module.exports = {
3+
preset: 'ts-jest',
4+
testEnvironment: 'jsdom',
5+
};

packages/session-replay/package.json

+5-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"clean": "tsc -b --clean && rimraf \"lib\"",
1010
"format": "prettier --write '**/*.ts'",
1111
"lint": "eslint . --ext .ts",
12-
"watch": "tsc -w"
12+
"watch": "tsc -w",
13+
"test": "cross-env NODE_ENV=test jest"
1314
},
1415
"repository": {
1516
"type": "git",
@@ -29,5 +30,8 @@
2930
},
3031
"dependencies": {
3132
"rrweb": "^2.0.0-alpha.15"
33+
},
34+
"devDependencies": {
35+
"jest-environment-jsdom": "^29.7.0"
3236
}
3337
}

packages/session-replay/src/BacktraceSessionRecorder.ts

+21-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { BacktraceAttachment, OverwritingArray } from '@backtrace/sdk-core';
22
import { eventWithTime } from '@rrweb/types';
33
import { record } from 'rrweb';
44
import { BacktraceSessionRecorderOptions } from './options';
5+
import { maskTextFn } from './privacy/maskTextFn';
56

67
export class BacktraceSessionRecorder implements BacktraceAttachment {
78
public readonly name = 'bt-session-replay-0';
@@ -22,17 +23,35 @@ export class BacktraceSessionRecorder implements BacktraceAttachment {
2223

2324
public start() {
2425
this._stop = record({
26+
blockClass: this._options.privacy?.blockClass ?? 'bt-block',
27+
blockSelector: this._options.privacy?.blockSelector,
28+
ignoreClass: this._options.privacy?.ignoreClass ?? 'bt-ignore',
29+
ignoreSelector: this._options.privacy?.ignoreSelector,
30+
ignoreCSSAttributes: new Set(this._options.privacy?.ignoreCSSAttributes),
31+
maskTextSelector: '*', // Pass all text to maskTextFn
32+
maskAllInputs: this._options.privacy?.maskAllInputs ?? true,
33+
maskInputFn: this._options.privacy?.maskInputFn,
34+
maskTextFn: maskTextFn({
35+
maskAllText: this._options.privacy?.maskAllText ?? true,
36+
maskTextClass: this._options.privacy?.maskTextClass ?? 'bt-mask',
37+
unmaskTextClass: this._options.privacy?.unmaskTextClass ?? 'bt-unmask',
38+
maskTextSelector: this._options.privacy?.maskTextSelector,
39+
unmaskTextSelector: this._options.privacy?.unmaskTextSelector,
40+
maskTextFn: this._options.privacy?.maskTextFn,
41+
}),
2542
...this._options.advancedOptions,
2643
sampling: {
2744
mousemove: this._options.sampling?.mousemove,
2845
mouseInteraction: this._options.sampling?.mouseInteraction,
2946
input: this._options.sampling?.input,
3047
media: this._options.sampling?.media,
3148
scroll: this._options.sampling?.scroll,
32-
...this._options.advancedOptions,
49+
...this._options.advancedOptions?.sampling,
3350
},
3451
emit: (event, isCheckout) => this.onEmit(event, isCheckout),
35-
checkoutEveryNth: this._maxEventCount && Math.ceil(this._maxEventCount / 2),
52+
checkoutEveryNth:
53+
this._options.advancedOptions?.checkoutEveryNth ??
54+
(this._maxEventCount && Math.ceil(this._maxEventCount / 2)),
3655
});
3756
}
3857

packages/session-replay/src/options.ts

+29-11
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ export interface BacktraceSessionRecorderSamplingOptions {
7272

7373
export interface BacktraceSessionReplayPrivacyOptions {
7474
/**
75-
* Use a `string` or `RegExp` to configure which elements should be blocked.
76-
* @default "rr-block"
75+
* Blocks elements with this class.
76+
* @default "bt-block"
7777
*/
7878
readonly blockClass?: string | RegExp;
7979

@@ -84,8 +84,8 @@ export interface BacktraceSessionReplayPrivacyOptions {
8484
readonly blockSelector?: string;
8585

8686
/**
87-
* Use a `string` or `RegExp` to configure which elements should be ignored.
88-
* @default "rr-ignore"
87+
* Ignores elements with this class.
88+
* @default "bt-ignore"
8989
*/
9090
readonly ignoreClass?: string;
9191

@@ -98,33 +98,51 @@ export interface BacktraceSessionReplayPrivacyOptions {
9898
/**
9999
* Array of CSS attributes that should be ignored.
100100
*/
101-
readonly ignoreCSSAttributes?: string;
101+
readonly ignoreCSSAttributes?: Set<string>;
102102

103103
/**
104-
* Use a `string` or `RegExp` to configure which elements should be masked.
105-
* @default "rr-mask"
104+
* Masks elements with this class.
105+
* @default "bt-mask"
106106
*/
107107
readonly maskTextClass?: string;
108108

109109
/**
110-
* Use a `string` to configure which selector should be masked.
110+
* Unmasks elements with this class.
111+
* @default "bt-unmask"
112+
*/
113+
readonly unmaskTextClass?: string | RegExp;
114+
115+
/**
116+
* Masks elements matching this selector.
111117
* @default undefined
112118
*/
113119
readonly maskTextSelector?: string;
114120

121+
/**
122+
* Unmasks elements matching this selector.
123+
* @default undefined
124+
*/
125+
readonly unmaskTextSelector?: string;
126+
115127
/**
116128
* If `true`, will mask all inputs.
117-
* @default false
129+
* @default true
118130
*/
119131
readonly maskAllInputs?: boolean;
120132

121133
/**
122-
* Mask specific kinds of input.
134+
* If `true`, will mask all text.
135+
* @default true
136+
*/
137+
readonly maskAllText?: boolean;
138+
139+
/**
140+
* Mask specific kinds of inputs.
123141
*
124142
* Can be an object with the following keys:
125143
* * `color`
126144
* * `date`
127-
* * `'datetime-local'`
145+
* * `datetime-local`
128146
* * `email`
129147
* * `month`
130148
* * `number`
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { MaskTextFn } from 'rrweb-snapshot';
2+
3+
export interface MaskTextFnOptions {
4+
readonly maskAllText: boolean;
5+
readonly maskTextSelector?: string;
6+
readonly unmaskTextSelector?: string;
7+
readonly maskTextClass?: string | RegExp;
8+
readonly unmaskTextClass?: string | RegExp;
9+
readonly maskTextFn?: MaskTextFn;
10+
}
11+
12+
function maskCharacters(text: string) {
13+
return text.replace(/[\S]/g, '*');
14+
}
15+
16+
function testSelector(element: HTMLElement, selector?: string) {
17+
if (!selector) {
18+
return false;
19+
}
20+
21+
return element.matches(selector);
22+
}
23+
24+
function testClass(element: HTMLElement, expr?: string | RegExp) {
25+
if (!expr) {
26+
return false;
27+
}
28+
29+
const test = typeof expr === 'string' ? (v: string) => v === expr : (v: string) => expr.test(v);
30+
for (const c of element.classList) {
31+
if (test(c)) {
32+
return true;
33+
}
34+
}
35+
return false;
36+
}
37+
38+
/**
39+
* Tests if element should be masked or not.
40+
*
41+
* Prefers masking elements, i.e. to be unmasked, the element cannot be matched by mask
42+
* and has to be matched by unmask.
43+
*/
44+
function testElementPreferMask(element: HTMLElement, options: MaskTextFnOptions) {
45+
if (testSelector(element, options.maskTextSelector) || testClass(element, options.maskTextClass)) {
46+
return true;
47+
}
48+
49+
if (testSelector(element, options.unmaskTextSelector) || testClass(element, options.unmaskTextClass)) {
50+
return false;
51+
}
52+
53+
return true;
54+
}
55+
56+
/**
57+
* Tests if element should be masked or not.
58+
*
59+
* Prefers unmasking elements, i.e. to be masked, the element cannot be matched by unmask
60+
* and has to be matched by mask.
61+
*/
62+
function testElementPreferUnmask(element: HTMLElement, options: MaskTextFnOptions) {
63+
if (testSelector(element, options.unmaskTextSelector) || testClass(element, options.unmaskTextClass)) {
64+
return false;
65+
}
66+
67+
if (testSelector(element, options.maskTextSelector) || testClass(element, options.maskTextClass)) {
68+
return true;
69+
}
70+
71+
return false;
72+
}
73+
74+
export function maskTextFn(options: MaskTextFnOptions): MaskTextFn {
75+
const maskText = options.maskTextFn ? options.maskTextFn : maskCharacters;
76+
77+
return function maskTextFn(text, element) {
78+
if (!element) {
79+
return options.maskAllText ? maskText(text, element) : text;
80+
}
81+
82+
const shouldBeMasked = options.maskAllText
83+
? testElementPreferMask(element, options)
84+
: testElementPreferUnmask(element, options);
85+
86+
return shouldBeMasked ? maskText(text, element) : text;
87+
};
88+
}

0 commit comments

Comments
 (0)