@spotify-confidence/csr-recorder 0.17.21 → 0.17.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +17 -0
- package/dist/index.cjs +82 -10
- package/dist/index.d.cts +24 -5
- package/dist/index.d.ts +24 -5
- package/dist/index.js +82 -10
- package/package.json +1 -1
- package/src/capture-sanitizer.ts +67 -0
- package/src/engine/console-capture-plugin.ts +45 -0
- package/src/engine/rrweb-engine.test.ts +132 -0
- package/src/engine/rrweb-engine.ts +6 -15
- package/src/index.ts +3 -0
- package/src/recorder.test.ts +196 -0
- package/src/recorder.ts +24 -1
- package/src/types.ts +23 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.17.22](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.21...csr-recorder-v0.17.22) (2026-09-18)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ✨ New Features
|
|
7
|
+
|
|
8
|
+
* add capture sanitization controls ([#468](https://github.com/spotify/confidence-sdk-js/issues/468)) ([971a9d9](https://github.com/spotify/confidence-sdk-js/commit/971a9d9d6ef6db8e82126e9caf91f7403512d3e2))
|
|
9
|
+
|
|
3
10
|
## [0.17.21](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.20...csr-recorder-v0.17.21) (2026-09-17)
|
|
4
11
|
|
|
5
12
|
|
package/README.md
CHANGED
|
@@ -27,6 +27,23 @@ const stop = record(
|
|
|
27
27
|
stop();
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
Raw URLs and console output can contain sensitive data from first-party or third-party code. Both capture options record
|
|
31
|
+
raw values when set to `true`. Select a sanitization policy before you enable these channels in production.
|
|
32
|
+
|
|
33
|
+
Set `sanitize: true` to remove query strings and fragments from network URLs and from URLs inside console payloads and
|
|
34
|
+
traces:
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
const stop = record(event => {}, {
|
|
38
|
+
captureNetworkRequests: { sanitize: true },
|
|
39
|
+
captureConsoleLogs: { levels: ['warn', 'error'], sanitize: true },
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Set `sanitize` to `(value: string) => string` for a custom policy. See
|
|
44
|
+
[`@spotify-confidence/session-recording`](../session-recording/README.md#sanitizing-console-and-network-capture) for the
|
|
45
|
+
full description, including the fail-closed behavior.
|
|
46
|
+
|
|
30
47
|
## Route parameterization
|
|
31
48
|
|
|
32
49
|
Routes containing dynamic segments (such as IDs in the URL) are automatically normalized into patterns — for example, `/users/123/profile` becomes `/users/:id/profile`. This ensures that per-page metrics are grouped by route rather than by individual page visit, keeping dashboards meaningful and query performance fast.
|
package/dist/index.cjs
CHANGED
|
@@ -64,6 +64,48 @@ function extractGraphQLRequestMetadata(url, body) {
|
|
|
64
64
|
return operationName ? { operationName } : void 0;
|
|
65
65
|
}
|
|
66
66
|
//#endregion
|
|
67
|
+
//#region src/capture-sanitizer.ts
|
|
68
|
+
const URL_IN_TEXT = /(?:(?:https?|wss?):)?\/\/[^\s<>"')\]}]+/giu;
|
|
69
|
+
const RELATIVE_URL_IN_TEXT = /(^|[\s("'=])(\/(?!\/)[^\s<>"')\]}]*[?#][^\s<>"')\]}]*)/giu;
|
|
70
|
+
/** Remove query strings and fragments from every URL found inside free text. */
|
|
71
|
+
function stripUrlsInText(value) {
|
|
72
|
+
return value.replace(URL_IN_TEXT, _spotify_confidence_csr_common.stripUrlQueryAndHash).replace(RELATIVE_URL_IN_TEXT, (_match, prefix, url) => prefix + (0, _spotify_confidence_csr_common.stripUrlQueryAndHash)(url));
|
|
73
|
+
}
|
|
74
|
+
const BUILT_IN = {
|
|
75
|
+
network: _spotify_confidence_csr_common.stripUrlQueryAndHash,
|
|
76
|
+
console: stripUrlsInText
|
|
77
|
+
};
|
|
78
|
+
/** The sanitizer for one capture channel, or `undefined` when that channel stays raw. */
|
|
79
|
+
function getValueSanitizer(sanitizer, source) {
|
|
80
|
+
if (!sanitizer) return void 0;
|
|
81
|
+
const sanitize = sanitizer === true ? BUILT_IN[source] : sanitizer;
|
|
82
|
+
return (value) => {
|
|
83
|
+
const sanitized = sanitize(value);
|
|
84
|
+
if (typeof sanitized !== "string") throw new TypeError("Sanitizer must return a string");
|
|
85
|
+
return sanitized;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** Log at most once. Diagnostics must never reach the host application. */
|
|
89
|
+
function createOnceLogger(debugLogger) {
|
|
90
|
+
let hasLogged = false;
|
|
91
|
+
return (message) => {
|
|
92
|
+
if (hasLogged) return;
|
|
93
|
+
hasLogged = true;
|
|
94
|
+
try {
|
|
95
|
+
debugLogger?.(message);
|
|
96
|
+
} catch (_error) {}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** Build a captured event. Returns `undefined` when sanitizing threw, so the caller drops it. */
|
|
100
|
+
function dropOnFailure(build, warn, source) {
|
|
101
|
+
try {
|
|
102
|
+
return build();
|
|
103
|
+
} catch (_error) {
|
|
104
|
+
warn(`SECURITY: ${source} sanitizer failed; captured event dropped`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
67
109
|
//#region src/recorder.ts
|
|
68
110
|
var Recorder = class Recorder {
|
|
69
111
|
engine;
|
|
@@ -78,6 +120,8 @@ var Recorder = class Recorder {
|
|
|
78
120
|
originalReplaceState = null;
|
|
79
121
|
popstateHandler = null;
|
|
80
122
|
parameterizeRoute;
|
|
123
|
+
sanitizeNetworkUrl;
|
|
124
|
+
warnNetworkDrop = createOnceLogger();
|
|
81
125
|
constructor(options) {
|
|
82
126
|
this.engine = options.engine;
|
|
83
127
|
this.onEvent = options.onEvent;
|
|
@@ -89,6 +133,8 @@ var Recorder = class Recorder {
|
|
|
89
133
|
if (this.state === "recording") return;
|
|
90
134
|
this.state = "recording";
|
|
91
135
|
this.parameterizeRoute = config?.parameterizeRoute ?? defaultParameterizeRoute;
|
|
136
|
+
this.sanitizeNetworkUrl = getValueSanitizer(typeof config?.captureNetworkRequests === "object" ? config.captureNetworkRequests.sanitize : void 0, "network");
|
|
137
|
+
this.warnNetworkDrop = createOnceLogger(config?.debugLogger);
|
|
92
138
|
this.engine.start(config ?? {}, (event) => {
|
|
93
139
|
if (event.type === _spotify_confidence_csr_common.RecordingEventType.Meta) {
|
|
94
140
|
const data = event.data;
|
|
@@ -125,7 +171,13 @@ var Recorder = class Recorder {
|
|
|
125
171
|
if (config?.captureNetworkRequests) this.patchNetwork();
|
|
126
172
|
if (config?.captureRouteChanges !== false) this.patchRouting();
|
|
127
173
|
}
|
|
128
|
-
emitNetworkRequest(
|
|
174
|
+
emitNetworkRequest(raw) {
|
|
175
|
+
const sanitizeUrl = this.sanitizeNetworkUrl;
|
|
176
|
+
const payload = sanitizeUrl ? dropOnFailure(() => ({
|
|
177
|
+
...raw,
|
|
178
|
+
url: sanitizeUrl(raw.url)
|
|
179
|
+
}), this.warnNetworkDrop, "network") : raw;
|
|
180
|
+
if (!payload) return;
|
|
129
181
|
const data = {
|
|
130
182
|
plugin: _spotify_confidence_csr_common.RecordingPluginName.NetworkRequest,
|
|
131
183
|
payload
|
|
@@ -11118,7 +11170,7 @@ const getRecordConsolePlugin = (options) => ({
|
|
|
11118
11170
|
options
|
|
11119
11171
|
});
|
|
11120
11172
|
//#endregion
|
|
11121
|
-
//#region src/engine/
|
|
11173
|
+
//#region src/engine/console-capture-plugin.ts
|
|
11122
11174
|
const ALL_CONSOLE_LEVELS = [
|
|
11123
11175
|
"log",
|
|
11124
11176
|
"warn",
|
|
@@ -11126,6 +11178,31 @@ const ALL_CONSOLE_LEVELS = [
|
|
|
11126
11178
|
"debug",
|
|
11127
11179
|
"info"
|
|
11128
11180
|
];
|
|
11181
|
+
function getConsoleCapturePlugin(captureConsoleLogs, debugLogger) {
|
|
11182
|
+
if (!captureConsoleLogs) return void 0;
|
|
11183
|
+
const { levels = ALL_CONSOLE_LEVELS, sanitize } = captureConsoleLogs === true ? {} : captureConsoleLogs;
|
|
11184
|
+
if (levels.length === 0) return void 0;
|
|
11185
|
+
const plugin = getRecordConsolePlugin({ level: levels });
|
|
11186
|
+
const sanitizeValue = getValueSanitizer(sanitize, "console");
|
|
11187
|
+
if (!sanitizeValue) return plugin;
|
|
11188
|
+
const observeConsole = plugin.observer;
|
|
11189
|
+
if (!observeConsole) return void 0;
|
|
11190
|
+
const warn = createOnceLogger(debugLogger);
|
|
11191
|
+
return {
|
|
11192
|
+
...plugin,
|
|
11193
|
+
observer: (callback, win, observerOptions) => observeConsole((data) => {
|
|
11194
|
+
const log = data;
|
|
11195
|
+
const sanitized = dropOnFailure(() => ({
|
|
11196
|
+
...log,
|
|
11197
|
+
payload: log.payload.map(sanitizeValue),
|
|
11198
|
+
trace: log.trace.map(sanitizeValue)
|
|
11199
|
+
}), warn, "console");
|
|
11200
|
+
if (sanitized) callback(sanitized);
|
|
11201
|
+
}, win, observerOptions)
|
|
11202
|
+
};
|
|
11203
|
+
}
|
|
11204
|
+
//#endregion
|
|
11205
|
+
//#region src/engine/rrweb-engine.ts
|
|
11129
11206
|
const BLOCKED_ELEMENT_ATTRIBUTE = "data-csr-blocked-element";
|
|
11130
11207
|
function labelBlockedElement(node, tracker, parentId) {
|
|
11131
11208
|
if (node.id !== void 0 && parentId !== void 0) {
|
|
@@ -11274,15 +11351,10 @@ var RrwebEngine = class {
|
|
|
11274
11351
|
clipboardActionsPlugin(),
|
|
11275
11352
|
blockedElementLabelsPlugin()
|
|
11276
11353
|
];
|
|
11277
|
-
const
|
|
11278
|
-
if (
|
|
11279
|
-
const levels = captureConsoleLogs === true ? ALL_CONSOLE_LEVELS : captureConsoleLogs.levels;
|
|
11280
|
-
if (levels.length > 0) plugins.push(getRecordConsolePlugin({ level: levels }));
|
|
11281
|
-
}
|
|
11354
|
+
const consoleCapturePlugin = getConsoleCapturePlugin(config.captureConsoleLogs, config.debugLogger);
|
|
11355
|
+
if (consoleCapturePlugin) plugins.push(consoleCapturePlugin);
|
|
11282
11356
|
this.stopFn = record$1({
|
|
11283
|
-
emit:
|
|
11284
|
-
onEvent(event);
|
|
11285
|
-
},
|
|
11357
|
+
emit: onEvent,
|
|
11286
11358
|
maskAllInputs: config.maskInputs ?? true,
|
|
11287
11359
|
...maskSelectors.length ? { maskTextSelector: maskSelectors.join(",") } : {},
|
|
11288
11360
|
...blockSelectors.length ? { blockSelector: blockSelectors.join(",") } : {},
|
package/dist/index.d.cts
CHANGED
|
@@ -11,6 +11,10 @@ interface RecordingEngine {
|
|
|
11
11
|
stop(): void;
|
|
12
12
|
}
|
|
13
13
|
//#endregion
|
|
14
|
+
//#region src/capture-sanitizer.d.ts
|
|
15
|
+
/** Capture option: `true` for the built-in policy, or a customer-defined function. */
|
|
16
|
+
type CaptureSanitizer = boolean | ((value: string) => string);
|
|
17
|
+
//#endregion
|
|
14
18
|
//#region src/types.d.ts
|
|
15
19
|
interface RecorderOptions {
|
|
16
20
|
/** Engine used to capture DOM events (defaults to rrweb). */
|
|
@@ -20,6 +24,13 @@ interface RecorderOptions {
|
|
|
20
24
|
}
|
|
21
25
|
declare const DEFAULT_MASK_SELECTORS: string[];
|
|
22
26
|
declare const DEFAULT_BLOCK_SELECTORS: string[];
|
|
27
|
+
interface ConsoleCaptureOptions {
|
|
28
|
+
levels?: import("@spotify-confidence/csr-common").ConsoleLogLevel[];
|
|
29
|
+
sanitize?: CaptureSanitizer;
|
|
30
|
+
}
|
|
31
|
+
interface NetworkCaptureOptions {
|
|
32
|
+
sanitize?: CaptureSanitizer;
|
|
33
|
+
}
|
|
23
34
|
interface RecordingConfig {
|
|
24
35
|
/**
|
|
25
36
|
* CSS selectors for text content that should be masked. Joined with `,` and
|
|
@@ -50,10 +61,10 @@ interface RecordingConfig {
|
|
|
50
61
|
*
|
|
51
62
|
* - `true` — capture all levels (log, warn, error, debug, info).
|
|
52
63
|
* - `{ levels: [...] }` — capture only the listed levels.
|
|
64
|
+
* - Add `sanitize: true` to remove query strings and fragments from URLs,
|
|
65
|
+
* or provide a function to sanitize each captured payload and trace.
|
|
53
66
|
*/
|
|
54
|
-
captureConsoleLogs?: boolean |
|
|
55
|
-
levels: import("@spotify-confidence/csr-common").ConsoleLogLevel[];
|
|
56
|
-
};
|
|
67
|
+
captureConsoleLogs?: boolean | ConsoleCaptureOptions;
|
|
57
68
|
/**
|
|
58
69
|
* Capture network requests (fetch and XMLHttpRequest) during the recording.
|
|
59
70
|
* Defaults to `false` because request URLs and metadata can contain PII,
|
|
@@ -61,8 +72,14 @@ interface RecordingConfig {
|
|
|
61
72
|
*
|
|
62
73
|
* Only metadata is captured (method, URL, status, duration, sizes) — no
|
|
63
74
|
* headers or bodies are recorded.
|
|
75
|
+
*
|
|
76
|
+
* - `true` — capture raw URLs, preserving existing behavior.
|
|
77
|
+
* - `{ sanitize: true }` — remove query strings and fragments.
|
|
78
|
+
* - `{ sanitize: value => ... }` — apply a custom sanitizer.
|
|
64
79
|
*/
|
|
65
|
-
captureNetworkRequests?: boolean;
|
|
80
|
+
captureNetworkRequests?: boolean | NetworkCaptureOptions;
|
|
81
|
+
/** Optional diagnostic logger used for fail-closed sanitizer warnings. */
|
|
82
|
+
debugLogger?: (message: string) => void;
|
|
66
83
|
/**
|
|
67
84
|
* Capture client-side route changes during the recording. Defaults to
|
|
68
85
|
* `true`. Only the pathname is recorded; origin, query strings, and
|
|
@@ -111,6 +128,8 @@ declare class Recorder {
|
|
|
111
128
|
private originalReplaceState;
|
|
112
129
|
private popstateHandler;
|
|
113
130
|
private parameterizeRoute;
|
|
131
|
+
private sanitizeNetworkUrl;
|
|
132
|
+
private warnNetworkDrop;
|
|
114
133
|
constructor(options: RecorderOptions);
|
|
115
134
|
get currentState(): RecorderState;
|
|
116
135
|
start(config?: RecordingConfig): void;
|
|
@@ -150,4 +169,4 @@ declare function record(onEvent: (event: RecordingEvent) => void, config?: Recor
|
|
|
150
169
|
//#region src/route-parameterizer.d.ts
|
|
151
170
|
declare function defaultParameterizeRoute(route: string): string;
|
|
152
171
|
//#endregion
|
|
153
|
-
export { DEFAULT_BLOCK_SELECTORS, DEFAULT_MASK_SELECTORS, Recorder, type RecorderOptions, RecorderState, type RecordingConfig, type RecordingEngine, RrwebEngine, defaultParameterizeRoute, record };
|
|
172
|
+
export { type CaptureSanitizer, type ConsoleCaptureOptions, DEFAULT_BLOCK_SELECTORS, DEFAULT_MASK_SELECTORS, type NetworkCaptureOptions, Recorder, type RecorderOptions, RecorderState, type RecordingConfig, type RecordingEngine, RrwebEngine, defaultParameterizeRoute, record };
|
package/dist/index.d.ts
CHANGED
|
@@ -11,6 +11,10 @@ interface RecordingEngine {
|
|
|
11
11
|
stop(): void;
|
|
12
12
|
}
|
|
13
13
|
//#endregion
|
|
14
|
+
//#region src/capture-sanitizer.d.ts
|
|
15
|
+
/** Capture option: `true` for the built-in policy, or a customer-defined function. */
|
|
16
|
+
type CaptureSanitizer = boolean | ((value: string) => string);
|
|
17
|
+
//#endregion
|
|
14
18
|
//#region src/types.d.ts
|
|
15
19
|
interface RecorderOptions {
|
|
16
20
|
/** Engine used to capture DOM events (defaults to rrweb). */
|
|
@@ -20,6 +24,13 @@ interface RecorderOptions {
|
|
|
20
24
|
}
|
|
21
25
|
declare const DEFAULT_MASK_SELECTORS: string[];
|
|
22
26
|
declare const DEFAULT_BLOCK_SELECTORS: string[];
|
|
27
|
+
interface ConsoleCaptureOptions {
|
|
28
|
+
levels?: import("@spotify-confidence/csr-common").ConsoleLogLevel[];
|
|
29
|
+
sanitize?: CaptureSanitizer;
|
|
30
|
+
}
|
|
31
|
+
interface NetworkCaptureOptions {
|
|
32
|
+
sanitize?: CaptureSanitizer;
|
|
33
|
+
}
|
|
23
34
|
interface RecordingConfig {
|
|
24
35
|
/**
|
|
25
36
|
* CSS selectors for text content that should be masked. Joined with `,` and
|
|
@@ -50,10 +61,10 @@ interface RecordingConfig {
|
|
|
50
61
|
*
|
|
51
62
|
* - `true` — capture all levels (log, warn, error, debug, info).
|
|
52
63
|
* - `{ levels: [...] }` — capture only the listed levels.
|
|
64
|
+
* - Add `sanitize: true` to remove query strings and fragments from URLs,
|
|
65
|
+
* or provide a function to sanitize each captured payload and trace.
|
|
53
66
|
*/
|
|
54
|
-
captureConsoleLogs?: boolean |
|
|
55
|
-
levels: import("@spotify-confidence/csr-common").ConsoleLogLevel[];
|
|
56
|
-
};
|
|
67
|
+
captureConsoleLogs?: boolean | ConsoleCaptureOptions;
|
|
57
68
|
/**
|
|
58
69
|
* Capture network requests (fetch and XMLHttpRequest) during the recording.
|
|
59
70
|
* Defaults to `false` because request URLs and metadata can contain PII,
|
|
@@ -61,8 +72,14 @@ interface RecordingConfig {
|
|
|
61
72
|
*
|
|
62
73
|
* Only metadata is captured (method, URL, status, duration, sizes) — no
|
|
63
74
|
* headers or bodies are recorded.
|
|
75
|
+
*
|
|
76
|
+
* - `true` — capture raw URLs, preserving existing behavior.
|
|
77
|
+
* - `{ sanitize: true }` — remove query strings and fragments.
|
|
78
|
+
* - `{ sanitize: value => ... }` — apply a custom sanitizer.
|
|
64
79
|
*/
|
|
65
|
-
captureNetworkRequests?: boolean;
|
|
80
|
+
captureNetworkRequests?: boolean | NetworkCaptureOptions;
|
|
81
|
+
/** Optional diagnostic logger used for fail-closed sanitizer warnings. */
|
|
82
|
+
debugLogger?: (message: string) => void;
|
|
66
83
|
/**
|
|
67
84
|
* Capture client-side route changes during the recording. Defaults to
|
|
68
85
|
* `true`. Only the pathname is recorded; origin, query strings, and
|
|
@@ -111,6 +128,8 @@ declare class Recorder {
|
|
|
111
128
|
private originalReplaceState;
|
|
112
129
|
private popstateHandler;
|
|
113
130
|
private parameterizeRoute;
|
|
131
|
+
private sanitizeNetworkUrl;
|
|
132
|
+
private warnNetworkDrop;
|
|
114
133
|
constructor(options: RecorderOptions);
|
|
115
134
|
get currentState(): RecorderState;
|
|
116
135
|
start(config?: RecordingConfig): void;
|
|
@@ -150,4 +169,4 @@ declare function record(onEvent: (event: RecordingEvent) => void, config?: Recor
|
|
|
150
169
|
//#region src/route-parameterizer.d.ts
|
|
151
170
|
declare function defaultParameterizeRoute(route: string): string;
|
|
152
171
|
//#endregion
|
|
153
|
-
export { DEFAULT_BLOCK_SELECTORS, DEFAULT_MASK_SELECTORS, Recorder, type RecorderOptions, RecorderState, type RecordingConfig, type RecordingEngine, RrwebEngine, defaultParameterizeRoute, record };
|
|
172
|
+
export { type CaptureSanitizer, type ConsoleCaptureOptions, DEFAULT_BLOCK_SELECTORS, DEFAULT_MASK_SELECTORS, type NetworkCaptureOptions, Recorder, type RecorderOptions, RecorderState, type RecordingConfig, type RecordingEngine, RrwebEngine, defaultParameterizeRoute, record };
|
package/dist/index.js
CHANGED
|
@@ -63,6 +63,48 @@ function extractGraphQLRequestMetadata(url, body) {
|
|
|
63
63
|
return operationName ? { operationName } : void 0;
|
|
64
64
|
}
|
|
65
65
|
//#endregion
|
|
66
|
+
//#region src/capture-sanitizer.ts
|
|
67
|
+
const URL_IN_TEXT = /(?:(?:https?|wss?):)?\/\/[^\s<>"')\]}]+/giu;
|
|
68
|
+
const RELATIVE_URL_IN_TEXT = /(^|[\s("'=])(\/(?!\/)[^\s<>"')\]}]*[?#][^\s<>"')\]}]*)/giu;
|
|
69
|
+
/** Remove query strings and fragments from every URL found inside free text. */
|
|
70
|
+
function stripUrlsInText(value) {
|
|
71
|
+
return value.replace(URL_IN_TEXT, stripUrlQueryAndHash).replace(RELATIVE_URL_IN_TEXT, (_match, prefix, url) => prefix + stripUrlQueryAndHash(url));
|
|
72
|
+
}
|
|
73
|
+
const BUILT_IN = {
|
|
74
|
+
network: stripUrlQueryAndHash,
|
|
75
|
+
console: stripUrlsInText
|
|
76
|
+
};
|
|
77
|
+
/** The sanitizer for one capture channel, or `undefined` when that channel stays raw. */
|
|
78
|
+
function getValueSanitizer(sanitizer, source) {
|
|
79
|
+
if (!sanitizer) return void 0;
|
|
80
|
+
const sanitize = sanitizer === true ? BUILT_IN[source] : sanitizer;
|
|
81
|
+
return (value) => {
|
|
82
|
+
const sanitized = sanitize(value);
|
|
83
|
+
if (typeof sanitized !== "string") throw new TypeError("Sanitizer must return a string");
|
|
84
|
+
return sanitized;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Log at most once. Diagnostics must never reach the host application. */
|
|
88
|
+
function createOnceLogger(debugLogger) {
|
|
89
|
+
let hasLogged = false;
|
|
90
|
+
return (message) => {
|
|
91
|
+
if (hasLogged) return;
|
|
92
|
+
hasLogged = true;
|
|
93
|
+
try {
|
|
94
|
+
debugLogger?.(message);
|
|
95
|
+
} catch (_error) {}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** Build a captured event. Returns `undefined` when sanitizing threw, so the caller drops it. */
|
|
99
|
+
function dropOnFailure(build, warn, source) {
|
|
100
|
+
try {
|
|
101
|
+
return build();
|
|
102
|
+
} catch (_error) {
|
|
103
|
+
warn(`SECURITY: ${source} sanitizer failed; captured event dropped`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
66
108
|
//#region src/recorder.ts
|
|
67
109
|
var Recorder = class Recorder {
|
|
68
110
|
engine;
|
|
@@ -77,6 +119,8 @@ var Recorder = class Recorder {
|
|
|
77
119
|
originalReplaceState = null;
|
|
78
120
|
popstateHandler = null;
|
|
79
121
|
parameterizeRoute;
|
|
122
|
+
sanitizeNetworkUrl;
|
|
123
|
+
warnNetworkDrop = createOnceLogger();
|
|
80
124
|
constructor(options) {
|
|
81
125
|
this.engine = options.engine;
|
|
82
126
|
this.onEvent = options.onEvent;
|
|
@@ -88,6 +132,8 @@ var Recorder = class Recorder {
|
|
|
88
132
|
if (this.state === "recording") return;
|
|
89
133
|
this.state = "recording";
|
|
90
134
|
this.parameterizeRoute = config?.parameterizeRoute ?? defaultParameterizeRoute;
|
|
135
|
+
this.sanitizeNetworkUrl = getValueSanitizer(typeof config?.captureNetworkRequests === "object" ? config.captureNetworkRequests.sanitize : void 0, "network");
|
|
136
|
+
this.warnNetworkDrop = createOnceLogger(config?.debugLogger);
|
|
91
137
|
this.engine.start(config ?? {}, (event) => {
|
|
92
138
|
if (event.type === RecordingEventType.Meta) {
|
|
93
139
|
const data = event.data;
|
|
@@ -124,7 +170,13 @@ var Recorder = class Recorder {
|
|
|
124
170
|
if (config?.captureNetworkRequests) this.patchNetwork();
|
|
125
171
|
if (config?.captureRouteChanges !== false) this.patchRouting();
|
|
126
172
|
}
|
|
127
|
-
emitNetworkRequest(
|
|
173
|
+
emitNetworkRequest(raw) {
|
|
174
|
+
const sanitizeUrl = this.sanitizeNetworkUrl;
|
|
175
|
+
const payload = sanitizeUrl ? dropOnFailure(() => ({
|
|
176
|
+
...raw,
|
|
177
|
+
url: sanitizeUrl(raw.url)
|
|
178
|
+
}), this.warnNetworkDrop, "network") : raw;
|
|
179
|
+
if (!payload) return;
|
|
128
180
|
const data = {
|
|
129
181
|
plugin: RecordingPluginName.NetworkRequest,
|
|
130
182
|
payload
|
|
@@ -11117,7 +11169,7 @@ const getRecordConsolePlugin = (options) => ({
|
|
|
11117
11169
|
options
|
|
11118
11170
|
});
|
|
11119
11171
|
//#endregion
|
|
11120
|
-
//#region src/engine/
|
|
11172
|
+
//#region src/engine/console-capture-plugin.ts
|
|
11121
11173
|
const ALL_CONSOLE_LEVELS = [
|
|
11122
11174
|
"log",
|
|
11123
11175
|
"warn",
|
|
@@ -11125,6 +11177,31 @@ const ALL_CONSOLE_LEVELS = [
|
|
|
11125
11177
|
"debug",
|
|
11126
11178
|
"info"
|
|
11127
11179
|
];
|
|
11180
|
+
function getConsoleCapturePlugin(captureConsoleLogs, debugLogger) {
|
|
11181
|
+
if (!captureConsoleLogs) return void 0;
|
|
11182
|
+
const { levels = ALL_CONSOLE_LEVELS, sanitize } = captureConsoleLogs === true ? {} : captureConsoleLogs;
|
|
11183
|
+
if (levels.length === 0) return void 0;
|
|
11184
|
+
const plugin = getRecordConsolePlugin({ level: levels });
|
|
11185
|
+
const sanitizeValue = getValueSanitizer(sanitize, "console");
|
|
11186
|
+
if (!sanitizeValue) return plugin;
|
|
11187
|
+
const observeConsole = plugin.observer;
|
|
11188
|
+
if (!observeConsole) return void 0;
|
|
11189
|
+
const warn = createOnceLogger(debugLogger);
|
|
11190
|
+
return {
|
|
11191
|
+
...plugin,
|
|
11192
|
+
observer: (callback, win, observerOptions) => observeConsole((data) => {
|
|
11193
|
+
const log = data;
|
|
11194
|
+
const sanitized = dropOnFailure(() => ({
|
|
11195
|
+
...log,
|
|
11196
|
+
payload: log.payload.map(sanitizeValue),
|
|
11197
|
+
trace: log.trace.map(sanitizeValue)
|
|
11198
|
+
}), warn, "console");
|
|
11199
|
+
if (sanitized) callback(sanitized);
|
|
11200
|
+
}, win, observerOptions)
|
|
11201
|
+
};
|
|
11202
|
+
}
|
|
11203
|
+
//#endregion
|
|
11204
|
+
//#region src/engine/rrweb-engine.ts
|
|
11128
11205
|
const BLOCKED_ELEMENT_ATTRIBUTE = "data-csr-blocked-element";
|
|
11129
11206
|
function labelBlockedElement(node, tracker, parentId) {
|
|
11130
11207
|
if (node.id !== void 0 && parentId !== void 0) {
|
|
@@ -11273,15 +11350,10 @@ var RrwebEngine = class {
|
|
|
11273
11350
|
clipboardActionsPlugin(),
|
|
11274
11351
|
blockedElementLabelsPlugin()
|
|
11275
11352
|
];
|
|
11276
|
-
const
|
|
11277
|
-
if (
|
|
11278
|
-
const levels = captureConsoleLogs === true ? ALL_CONSOLE_LEVELS : captureConsoleLogs.levels;
|
|
11279
|
-
if (levels.length > 0) plugins.push(getRecordConsolePlugin({ level: levels }));
|
|
11280
|
-
}
|
|
11353
|
+
const consoleCapturePlugin = getConsoleCapturePlugin(config.captureConsoleLogs, config.debugLogger);
|
|
11354
|
+
if (consoleCapturePlugin) plugins.push(consoleCapturePlugin);
|
|
11281
11355
|
this.stopFn = record$1({
|
|
11282
|
-
emit:
|
|
11283
|
-
onEvent(event);
|
|
11284
|
-
},
|
|
11356
|
+
emit: onEvent,
|
|
11285
11357
|
maskAllInputs: config.maskInputs ?? true,
|
|
11286
11358
|
...maskSelectors.length ? { maskTextSelector: maskSelectors.join(",") } : {},
|
|
11287
11359
|
...blockSelectors.length ? { blockSelector: blockSelectors.join(",") } : {},
|
package/package.json
CHANGED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { stripUrlQueryAndHash } from '@spotify-confidence/csr-common';
|
|
2
|
+
|
|
3
|
+
/** Capture option: `true` for the built-in policy, or a customer-defined function. */
|
|
4
|
+
export type CaptureSanitizer = boolean | ((value: string) => string);
|
|
5
|
+
|
|
6
|
+
/** Sanitizes one captured string. Throws when the policy fails, which drops the whole event. */
|
|
7
|
+
export type ValueSanitizer = (value: string) => string;
|
|
8
|
+
|
|
9
|
+
type CaptureSource = 'console' | 'network';
|
|
10
|
+
|
|
11
|
+
const URL_IN_TEXT = /(?:(?:https?|wss?):)?\/\/[^\s<>"')\]}]+/giu;
|
|
12
|
+
const RELATIVE_URL_IN_TEXT = /(^|[\s("'=])(\/(?!\/)[^\s<>"')\]}]*[?#][^\s<>"')\]}]*)/giu;
|
|
13
|
+
|
|
14
|
+
/** Remove query strings and fragments from every URL found inside free text. */
|
|
15
|
+
export function stripUrlsInText(value: string): string {
|
|
16
|
+
return value
|
|
17
|
+
.replace(URL_IN_TEXT, stripUrlQueryAndHash)
|
|
18
|
+
.replace(RELATIVE_URL_IN_TEXT, (_match, prefix: string, url: string) => prefix + stripUrlQueryAndHash(url));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const BUILT_IN: Record<CaptureSource, ValueSanitizer> = {
|
|
22
|
+
network: stripUrlQueryAndHash,
|
|
23
|
+
console: stripUrlsInText,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** The sanitizer for one capture channel, or `undefined` when that channel stays raw. */
|
|
27
|
+
export function getValueSanitizer(
|
|
28
|
+
sanitizer: CaptureSanitizer | undefined,
|
|
29
|
+
source: CaptureSource,
|
|
30
|
+
): ValueSanitizer | undefined {
|
|
31
|
+
if (!sanitizer) return undefined;
|
|
32
|
+
|
|
33
|
+
const sanitize = sanitizer === true ? BUILT_IN[source] : sanitizer;
|
|
34
|
+
return value => {
|
|
35
|
+
const sanitized = sanitize(value);
|
|
36
|
+
if (typeof sanitized !== 'string') throw new TypeError('Sanitizer must return a string');
|
|
37
|
+
return sanitized;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Log at most once. Diagnostics must never reach the host application. */
|
|
42
|
+
export function createOnceLogger(debugLogger?: (message: string) => void): (message: string) => void {
|
|
43
|
+
let hasLogged = false;
|
|
44
|
+
return message => {
|
|
45
|
+
if (hasLogged) return;
|
|
46
|
+
hasLogged = true;
|
|
47
|
+
try {
|
|
48
|
+
debugLogger?.(message);
|
|
49
|
+
} catch (_error) {
|
|
50
|
+
// Diagnostics must never affect the host application.
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Build a captured event. Returns `undefined` when sanitizing threw, so the caller drops it. */
|
|
56
|
+
export function dropOnFailure<T>(
|
|
57
|
+
build: () => T,
|
|
58
|
+
warn: (message: string) => void,
|
|
59
|
+
source: CaptureSource,
|
|
60
|
+
): T | undefined {
|
|
61
|
+
try {
|
|
62
|
+
return build();
|
|
63
|
+
} catch (_error) {
|
|
64
|
+
warn(`SECURITY: ${source} sanitizer failed; captured event dropped`);
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { getRecordConsolePlugin } from '@rrweb/rrweb-plugin-console-record';
|
|
2
|
+
import type { ConsoleLogLevel, ConsoleLogPluginData } from '@spotify-confidence/csr-common';
|
|
3
|
+
import { createOnceLogger, dropOnFailure, getValueSanitizer } from '../capture-sanitizer';
|
|
4
|
+
import type { ConsoleCaptureOptions } from '../types';
|
|
5
|
+
|
|
6
|
+
const ALL_CONSOLE_LEVELS: ConsoleLogLevel[] = ['log', 'warn', 'error', 'debug', 'info'];
|
|
7
|
+
|
|
8
|
+
export function getConsoleCapturePlugin(
|
|
9
|
+
captureConsoleLogs: boolean | ConsoleCaptureOptions | undefined,
|
|
10
|
+
debugLogger?: (message: string) => void,
|
|
11
|
+
): ReturnType<typeof getRecordConsolePlugin> | undefined {
|
|
12
|
+
if (!captureConsoleLogs) return undefined;
|
|
13
|
+
|
|
14
|
+
const options: ConsoleCaptureOptions = captureConsoleLogs === true ? {} : captureConsoleLogs;
|
|
15
|
+
const { levels = ALL_CONSOLE_LEVELS, sanitize } = options;
|
|
16
|
+
if (levels.length === 0) return undefined;
|
|
17
|
+
|
|
18
|
+
const plugin = getRecordConsolePlugin({ level: levels });
|
|
19
|
+
const sanitizeValue = getValueSanitizer(sanitize, 'console');
|
|
20
|
+
if (!sanitizeValue) return plugin;
|
|
21
|
+
|
|
22
|
+
const observeConsole = plugin.observer;
|
|
23
|
+
// Fail closed: with no observer to wrap there is no way to sanitize.
|
|
24
|
+
if (!observeConsole) return undefined;
|
|
25
|
+
|
|
26
|
+
const warn = createOnceLogger(debugLogger);
|
|
27
|
+
return {
|
|
28
|
+
...plugin,
|
|
29
|
+
// Malformed plugin data also fails closed here: mapping a non-array throws.
|
|
30
|
+
observer: (callback, win, observerOptions) =>
|
|
31
|
+
observeConsole(
|
|
32
|
+
data => {
|
|
33
|
+
const log = data as ConsoleLogPluginData['payload'];
|
|
34
|
+
const sanitized = dropOnFailure(
|
|
35
|
+
() => ({ ...log, payload: log.payload.map(sanitizeValue), trace: log.trace.map(sanitizeValue) }),
|
|
36
|
+
warn,
|
|
37
|
+
'console',
|
|
38
|
+
);
|
|
39
|
+
if (sanitized) callback(sanitized);
|
|
40
|
+
},
|
|
41
|
+
win,
|
|
42
|
+
observerOptions,
|
|
43
|
+
),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -6,6 +6,7 @@ import { RrwebEngine } from './rrweb-engine';
|
|
|
6
6
|
|
|
7
7
|
const recordSpy = vi.fn().mockReturnValue(() => {});
|
|
8
8
|
const takeFullSnapshotSpy = vi.fn();
|
|
9
|
+
const consoleObserverSpy = vi.fn();
|
|
9
10
|
|
|
10
11
|
vi.mock('rrweb', async importOriginal => ({
|
|
11
12
|
...(await importOriginal<typeof import('rrweb')>()),
|
|
@@ -13,6 +14,31 @@ vi.mock('rrweb', async importOriginal => ({
|
|
|
13
14
|
takeFullSnapshot: (isCheckout: boolean) => takeFullSnapshotSpy(isCheckout),
|
|
14
15
|
}));
|
|
15
16
|
|
|
17
|
+
vi.mock('@rrweb/rrweb-plugin-console-record', () => ({
|
|
18
|
+
getRecordConsolePlugin: (options: unknown) => ({
|
|
19
|
+
name: 'rrweb/console@1',
|
|
20
|
+
options,
|
|
21
|
+
observer: (callback: (...args: unknown[]) => void) => {
|
|
22
|
+
consoleObserverSpy(callback);
|
|
23
|
+
return () => {};
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
function observeConsolePlugin(
|
|
29
|
+
captureConsoleLogs: NonNullable<Parameters<RrwebEngine['start']>[0]['captureConsoleLogs']>,
|
|
30
|
+
debugLogger?: (message: string) => void,
|
|
31
|
+
) {
|
|
32
|
+
new RrwebEngine().start({ captureConsoleLogs, debugLogger }, () => {});
|
|
33
|
+
const plugin = recordSpy.mock.calls[0][0].plugins.find(
|
|
34
|
+
({ name }: { name: string }) => name === RecordingPluginName.ConsoleLog,
|
|
35
|
+
);
|
|
36
|
+
const callback = vi.fn();
|
|
37
|
+
plugin.observer(callback, window, plugin.options);
|
|
38
|
+
|
|
39
|
+
return { callback, emitConsoleData: consoleObserverSpy.mock.calls[0][0] };
|
|
40
|
+
}
|
|
41
|
+
|
|
16
42
|
function blockedElementLabelsPlugin() {
|
|
17
43
|
new RrwebEngine().start({}, () => {});
|
|
18
44
|
return recordSpy.mock.calls[0][0].plugins.find(
|
|
@@ -63,6 +89,7 @@ describe('RrwebEngine', () => {
|
|
|
63
89
|
beforeEach(() => {
|
|
64
90
|
recordSpy.mockClear();
|
|
65
91
|
takeFullSnapshotSpy.mockClear();
|
|
92
|
+
consoleObserverSpy.mockClear();
|
|
66
93
|
});
|
|
67
94
|
|
|
68
95
|
it('defaults maskAllInputs=true when maskInputs is omitted', () => {
|
|
@@ -256,6 +283,111 @@ describe('RrwebEngine', () => {
|
|
|
256
283
|
expect(recordSpy.mock.calls[0][0].slimDOMOptions).toBe('all');
|
|
257
284
|
});
|
|
258
285
|
|
|
286
|
+
it('strips query strings and fragments from console payloads and traces', () => {
|
|
287
|
+
const { callback, emitConsoleData } = observeConsolePlugin({ levels: ['error'], sanitize: true });
|
|
288
|
+
|
|
289
|
+
emitConsoleData({
|
|
290
|
+
level: 'error',
|
|
291
|
+
payload: [
|
|
292
|
+
'Stripe failed at https://api.stripe.com/intents/pi_1?client_secret=secret#result',
|
|
293
|
+
'socket wss://events.example/stream?subscriber=user-1 and //cdn.example/script.js?token=secret',
|
|
294
|
+
],
|
|
295
|
+
trace: ['at checkout (/checkout?subscriber=user-1#payment:10:2)'],
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
expect(callback).toHaveBeenCalledWith({
|
|
299
|
+
level: 'error',
|
|
300
|
+
payload: [
|
|
301
|
+
'Stripe failed at https://api.stripe.com/intents/pi_1',
|
|
302
|
+
'socket wss://events.example/stream and //cdn.example/script.js',
|
|
303
|
+
],
|
|
304
|
+
trace: ['at checkout (/checkout)'],
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('keeps raw console data when sanitization is not configured', () => {
|
|
309
|
+
const { callback, emitConsoleData } = observeConsolePlugin({ levels: ['error'] });
|
|
310
|
+
const data = {
|
|
311
|
+
level: 'error',
|
|
312
|
+
payload: ['https://api.example.com/payment?client_secret=secret'],
|
|
313
|
+
trace: [],
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
emitConsoleData(data);
|
|
317
|
+
|
|
318
|
+
expect(callback).toHaveBeenCalledWith(data);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('uses a custom sanitizer for every console payload and trace string', () => {
|
|
322
|
+
const { callback, emitConsoleData } = observeConsolePlugin({
|
|
323
|
+
levels: ['error'],
|
|
324
|
+
sanitize: value => value.replaceAll('secret', '[REDACTED]'),
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
emitConsoleData({
|
|
328
|
+
level: 'error',
|
|
329
|
+
payload: ['secret'],
|
|
330
|
+
trace: ['secret trace'],
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
expect(callback).toHaveBeenCalledWith({
|
|
334
|
+
level: 'error',
|
|
335
|
+
payload: ['[REDACTED]'],
|
|
336
|
+
trace: ['[REDACTED] trace'],
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('drops console events and logs when a custom sanitizer throws', () => {
|
|
341
|
+
const debugLogger = vi.fn();
|
|
342
|
+
const { callback, emitConsoleData } = observeConsolePlugin(
|
|
343
|
+
{
|
|
344
|
+
levels: ['error'],
|
|
345
|
+
sanitize: () => {
|
|
346
|
+
throw new Error('sanitizer bug');
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
debugLogger,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
emitConsoleData({
|
|
353
|
+
level: 'error',
|
|
354
|
+
payload: ['client_secret=secret', 'subscriber=user-1'],
|
|
355
|
+
trace: ['secret'],
|
|
356
|
+
});
|
|
357
|
+
emitConsoleData({ level: 'error', payload: ['another secret'], trace: [] });
|
|
358
|
+
|
|
359
|
+
expect(callback).not.toHaveBeenCalled();
|
|
360
|
+
expect(debugLogger).toHaveBeenCalledWith(expect.stringMatching(/SECURITY.*console.*dropped/i));
|
|
361
|
+
expect(debugLogger).toHaveBeenCalledTimes(1);
|
|
362
|
+
expect(debugLogger.mock.calls.join(' ')).not.toContain('client_secret=secret');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('skips only the console events its sanitizer cannot handle and keeps capturing the rest', () => {
|
|
366
|
+
const { callback, emitConsoleData } = observeConsolePlugin({
|
|
367
|
+
levels: ['error'],
|
|
368
|
+
sanitize: value => {
|
|
369
|
+
if (value.includes('unhandled')) throw new Error('sanitizer bug');
|
|
370
|
+
return value.replaceAll('secret', '[REDACTED]');
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
emitConsoleData({ level: 'error', payload: ['unhandled secret'], trace: [] });
|
|
375
|
+
emitConsoleData({ level: 'error', payload: ['next secret'], trace: [] });
|
|
376
|
+
|
|
377
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
378
|
+
expect(callback).toHaveBeenCalledWith({ level: 'error', payload: ['next [REDACTED]'], trace: [] });
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('fails closed on malformed console plugin data instead of asserting its type', () => {
|
|
382
|
+
const debugLogger = vi.fn();
|
|
383
|
+
const { callback, emitConsoleData } = observeConsolePlugin({ sanitize: true }, debugLogger);
|
|
384
|
+
|
|
385
|
+
emitConsoleData({ level: 'error', payload: 'not-an-array', trace: [] });
|
|
386
|
+
|
|
387
|
+
expect(callback).not.toHaveBeenCalled();
|
|
388
|
+
expect(debugLogger).toHaveBeenCalledWith(expect.stringMatching(/SECURITY.*console.*dropped/i));
|
|
389
|
+
});
|
|
390
|
+
|
|
259
391
|
it('records copy, cut, and paste actions without reading clipboard contents', () => {
|
|
260
392
|
new RrwebEngine().start({}, () => {});
|
|
261
393
|
const plugin = recordSpy.mock.calls[0][0].plugins.find(
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import { RecordingPluginName, type
|
|
1
|
+
import { RecordingPluginName, type RecordingEvent } from '@spotify-confidence/csr-common';
|
|
2
2
|
import { RecordingConfig, DEFAULT_MASK_SELECTORS, DEFAULT_BLOCK_SELECTORS } from '../types';
|
|
3
3
|
import { RecordingEngine } from './index';
|
|
4
4
|
import { EventType, IncrementalSource, MouseInteractions, record, takeFullSnapshot, type recordOptions } from 'rrweb';
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
const ALL_CONSOLE_LEVELS: ConsoleLogLevel[] = ['log', 'warn', 'error', 'debug', 'info'];
|
|
5
|
+
import { getConsoleCapturePlugin } from './console-capture-plugin';
|
|
8
6
|
|
|
9
7
|
type RrwebPlugin = NonNullable<recordOptions<RecordingEvent>['plugins']>[number];
|
|
10
8
|
|
|
@@ -198,19 +196,12 @@ export class RrwebEngine implements RecordingEngine {
|
|
|
198
196
|
const blockSelectors = config.blockSelectors ?? DEFAULT_BLOCK_SELECTORS;
|
|
199
197
|
|
|
200
198
|
const plugins: RrwebPlugin[] = [clickModifiersPlugin(), clipboardActionsPlugin(), blockedElementLabelsPlugin()];
|
|
201
|
-
const
|
|
202
|
-
if (
|
|
203
|
-
const levels = captureConsoleLogs === true ? ALL_CONSOLE_LEVELS : captureConsoleLogs.levels;
|
|
204
|
-
if (levels.length > 0) {
|
|
205
|
-
plugins.push(getRecordConsolePlugin({ level: levels }));
|
|
206
|
-
}
|
|
207
|
-
}
|
|
199
|
+
const consoleCapturePlugin = getConsoleCapturePlugin(config.captureConsoleLogs, config.debugLogger);
|
|
200
|
+
if (consoleCapturePlugin) plugins.push(consoleCapturePlugin);
|
|
208
201
|
|
|
209
202
|
this.stopFn =
|
|
210
|
-
record({
|
|
211
|
-
emit:
|
|
212
|
-
onEvent(event as unknown as RecordingEvent);
|
|
213
|
-
},
|
|
203
|
+
record<RecordingEvent>({
|
|
204
|
+
emit: onEvent,
|
|
214
205
|
maskAllInputs: config.maskInputs ?? true,
|
|
215
206
|
...(maskSelectors.length ? { maskTextSelector: maskSelectors.join(',') } : {}),
|
|
216
207
|
...(blockSelectors.length ? { blockSelector: blockSelectors.join(',') } : {}),
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,9 @@ export { RrwebEngine } from './engine/rrweb-engine';
|
|
|
4
4
|
export {
|
|
5
5
|
type RecorderOptions,
|
|
6
6
|
type RecordingConfig,
|
|
7
|
+
type CaptureSanitizer,
|
|
8
|
+
type ConsoleCaptureOptions,
|
|
9
|
+
type NetworkCaptureOptions,
|
|
7
10
|
RecorderState,
|
|
8
11
|
DEFAULT_MASK_SELECTORS,
|
|
9
12
|
DEFAULT_BLOCK_SELECTORS,
|
package/src/recorder.test.ts
CHANGED
|
@@ -97,6 +97,7 @@ describe('Recorder network request capture', () => {
|
|
|
97
97
|
|
|
98
98
|
afterEach(() => {
|
|
99
99
|
globalThis.fetch = originalFetch;
|
|
100
|
+
vi.unstubAllGlobals();
|
|
100
101
|
});
|
|
101
102
|
|
|
102
103
|
it('does not patch fetch by default', () => {
|
|
@@ -151,6 +152,201 @@ describe('Recorder network request capture', () => {
|
|
|
151
152
|
recorder.stop();
|
|
152
153
|
});
|
|
153
154
|
|
|
155
|
+
it('keeps raw network URLs when sanitization is not configured', async () => {
|
|
156
|
+
globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 200 }));
|
|
157
|
+
|
|
158
|
+
const onEvent = vi.fn();
|
|
159
|
+
const recorder = new Recorder({ engine: new MockEngine(), onEvent });
|
|
160
|
+
recorder.start({ captureNetworkRequests: true });
|
|
161
|
+
|
|
162
|
+
await globalThis.fetch('https://api.example.com/payment?client_secret=secret#result');
|
|
163
|
+
|
|
164
|
+
expect(networkRequestEvents(onEvent)[0].payload.url).toBe(
|
|
165
|
+
'https://api.example.com/payment?client_secret=secret#result',
|
|
166
|
+
);
|
|
167
|
+
recorder.stop();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('strips query strings and fragments with built-in network sanitization', async () => {
|
|
171
|
+
globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 200 }));
|
|
172
|
+
|
|
173
|
+
const onEvent = vi.fn();
|
|
174
|
+
const recorder = new Recorder({ engine: new MockEngine(), onEvent });
|
|
175
|
+
recorder.start({ captureNetworkRequests: { sanitize: true } });
|
|
176
|
+
|
|
177
|
+
await globalThis.fetch('https://api.example.com/payment?client_secret=secret#result');
|
|
178
|
+
|
|
179
|
+
expect(networkRequestEvents(onEvent)[0].payload.url).toBe('https://api.example.com/payment');
|
|
180
|
+
recorder.stop();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('uses a custom network sanitizer', async () => {
|
|
184
|
+
globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 200 }));
|
|
185
|
+
|
|
186
|
+
const onEvent = vi.fn();
|
|
187
|
+
const recorder = new Recorder({ engine: new MockEngine(), onEvent });
|
|
188
|
+
recorder.start({
|
|
189
|
+
captureNetworkRequests: {
|
|
190
|
+
sanitize: url => url.replace(/client_secret=[^&]+/, 'client_secret=[REDACTED]'),
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await globalThis.fetch('https://api.example.com/payment?client_secret=secret&expand=customer');
|
|
195
|
+
|
|
196
|
+
expect(networkRequestEvents(onEvent)[0].payload.url).toBe(
|
|
197
|
+
'https://api.example.com/payment?client_secret=[REDACTED]&expand=customer',
|
|
198
|
+
);
|
|
199
|
+
recorder.stop();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('sanitizes string, URL, and Request fetch inputs', async () => {
|
|
203
|
+
globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 200 }));
|
|
204
|
+
|
|
205
|
+
const onEvent = vi.fn();
|
|
206
|
+
const recorder = new Recorder({ engine: new MockEngine(), onEvent });
|
|
207
|
+
recorder.start({ captureNetworkRequests: { sanitize: true } });
|
|
208
|
+
|
|
209
|
+
await globalThis.fetch('/relative?secret=one#hash');
|
|
210
|
+
await globalThis.fetch(new URL('https://api.example.com/url?secret=two#hash'));
|
|
211
|
+
await globalThis.fetch(new Request('https://api.example.com/request?secret=three#hash'));
|
|
212
|
+
|
|
213
|
+
expect(networkRequestEvents(onEvent).map(event => event.payload.url)).toEqual([
|
|
214
|
+
'/relative',
|
|
215
|
+
'https://api.example.com/url',
|
|
216
|
+
'https://api.example.com/request',
|
|
217
|
+
]);
|
|
218
|
+
recorder.stop();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('sanitizes XMLHttpRequest URLs without changing the request', () => {
|
|
222
|
+
class FakeXMLHttpRequest {
|
|
223
|
+
status = 204;
|
|
224
|
+
openedUrl: string | URL | undefined;
|
|
225
|
+
private loadend: (() => void) | undefined;
|
|
226
|
+
|
|
227
|
+
open(_method: string, url: string | URL) {
|
|
228
|
+
this.openedUrl = url;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
send() {
|
|
232
|
+
this.loadend?.call(this);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
addEventListener(_type: string, listener: () => void) {
|
|
236
|
+
this.loadend = listener;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
getResponseHeader() {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
vi.stubGlobal('XMLHttpRequest', FakeXMLHttpRequest);
|
|
244
|
+
|
|
245
|
+
const onEvent = vi.fn();
|
|
246
|
+
const recorder = new Recorder({ engine: new MockEngine(), onEvent });
|
|
247
|
+
recorder.start({ captureNetworkRequests: { sanitize: true } });
|
|
248
|
+
const xhr = new FakeXMLHttpRequest();
|
|
249
|
+
|
|
250
|
+
xhr.open('GET', 'https://api.example.com/payment?client_secret=secret#result');
|
|
251
|
+
xhr.send();
|
|
252
|
+
|
|
253
|
+
expect(xhr.openedUrl).toBe('https://api.example.com/payment?client_secret=secret#result');
|
|
254
|
+
expect(networkRequestEvents(onEvent)[0].payload).toMatchObject({
|
|
255
|
+
initiator: 'xhr',
|
|
256
|
+
url: 'https://api.example.com/payment',
|
|
257
|
+
status: 204,
|
|
258
|
+
});
|
|
259
|
+
recorder.stop();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('drops network metadata and logs when a custom sanitizer throws', async () => {
|
|
263
|
+
globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 200 }));
|
|
264
|
+
|
|
265
|
+
const onEvent = vi.fn();
|
|
266
|
+
const debugLogger = vi.fn();
|
|
267
|
+
const recorder = new Recorder({ engine: new MockEngine(), onEvent });
|
|
268
|
+
recorder.start({
|
|
269
|
+
captureNetworkRequests: {
|
|
270
|
+
sanitize: () => {
|
|
271
|
+
throw new Error('sanitizer bug');
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
debugLogger,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
await expect(globalThis.fetch('https://api.example.com/payment?client_secret=secret')).resolves.toBeInstanceOf(
|
|
278
|
+
Response,
|
|
279
|
+
);
|
|
280
|
+
await globalThis.fetch('https://api.example.com/payment?client_secret=another-secret');
|
|
281
|
+
|
|
282
|
+
expect(networkRequestEvents(onEvent)).toHaveLength(0);
|
|
283
|
+
expect(debugLogger).toHaveBeenCalledWith(expect.stringMatching(/SECURITY.*network.*dropped/i));
|
|
284
|
+
expect(debugLogger).toHaveBeenCalledTimes(1);
|
|
285
|
+
expect(debugLogger.mock.calls.join(' ')).not.toContain('client_secret=secret');
|
|
286
|
+
recorder.stop();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('keeps sanitizing a request that is still in flight when the recorder stops', async () => {
|
|
290
|
+
let settleFetch: (response: Response) => void = () => {};
|
|
291
|
+
globalThis.fetch = vi.fn().mockReturnValue(new Promise<Response>(resolve => (settleFetch = resolve)));
|
|
292
|
+
|
|
293
|
+
const onEvent = vi.fn();
|
|
294
|
+
const recorder = new Recorder({ engine: new MockEngine(), onEvent });
|
|
295
|
+
recorder.start({ captureNetworkRequests: { sanitize: true } });
|
|
296
|
+
|
|
297
|
+
// `stop()` cannot cancel an in-flight request, so its patched handler still emits.
|
|
298
|
+
const inFlight = globalThis.fetch('https://api.example.com/payment?client_secret=secret');
|
|
299
|
+
recorder.stop();
|
|
300
|
+
settleFetch(new Response('', { status: 200 }));
|
|
301
|
+
await inFlight;
|
|
302
|
+
|
|
303
|
+
expect(networkRequestEvents(onEvent)[0].payload.url).toBe('https://api.example.com/payment');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('skips only the requests its sanitizer cannot handle and keeps capturing the rest', async () => {
|
|
307
|
+
globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 200 }));
|
|
308
|
+
|
|
309
|
+
const onEvent = vi.fn();
|
|
310
|
+
const recorder = new Recorder({ engine: new MockEngine(), onEvent });
|
|
311
|
+
recorder.start({
|
|
312
|
+
captureNetworkRequests: {
|
|
313
|
+
sanitize: url => {
|
|
314
|
+
if (url.includes('/unhandled')) throw new Error('sanitizer bug');
|
|
315
|
+
return url.split('?')[0];
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
await globalThis.fetch('https://api.example.com/unhandled?client_secret=secret');
|
|
321
|
+
await globalThis.fetch('https://api.example.com/next?client_secret=secret');
|
|
322
|
+
|
|
323
|
+
expect(networkRequestEvents(onEvent).map(event => event.payload.url)).toEqual(['https://api.example.com/next']);
|
|
324
|
+
recorder.stop();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('does not let a failing debug logger affect the application request', async () => {
|
|
328
|
+
globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 200 }));
|
|
329
|
+
|
|
330
|
+
const onEvent = vi.fn();
|
|
331
|
+
const recorder = new Recorder({ engine: new MockEngine(), onEvent });
|
|
332
|
+
recorder.start({
|
|
333
|
+
captureNetworkRequests: {
|
|
334
|
+
sanitize: () => {
|
|
335
|
+
throw new Error('sanitizer bug');
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
debugLogger: () => {
|
|
339
|
+
throw new Error('logger bug');
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
await expect(globalThis.fetch('https://api.example.com/payment?client_secret=secret')).resolves.toBeInstanceOf(
|
|
344
|
+
Response,
|
|
345
|
+
);
|
|
346
|
+
expect(networkRequestEvents(onEvent)).toHaveLength(0);
|
|
347
|
+
recorder.stop();
|
|
348
|
+
});
|
|
349
|
+
|
|
154
350
|
it('emits status 0 for a failed fetch', async () => {
|
|
155
351
|
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('Network error'));
|
|
156
352
|
|
package/src/recorder.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { RecorderOptions, RecorderState, RecordingConfig } from './types';
|
|
|
12
12
|
import { RecordingEngine } from './engine';
|
|
13
13
|
import { defaultParameterizeRoute } from './route-parameterizer';
|
|
14
14
|
import { extractGraphQLRequestMetadata } from './graphql-request';
|
|
15
|
+
import { createOnceLogger, dropOnFailure, getValueSanitizer, type ValueSanitizer } from './capture-sanitizer';
|
|
15
16
|
|
|
16
17
|
export class Recorder {
|
|
17
18
|
private readonly engine: RecordingEngine;
|
|
@@ -26,6 +27,8 @@ export class Recorder {
|
|
|
26
27
|
private originalReplaceState: typeof history.replaceState | null = null;
|
|
27
28
|
private popstateHandler: (() => void) | null = null;
|
|
28
29
|
private parameterizeRoute!: (route: string) => string;
|
|
30
|
+
private sanitizeNetworkUrl: ValueSanitizer | undefined;
|
|
31
|
+
private warnNetworkDrop = createOnceLogger();
|
|
29
32
|
|
|
30
33
|
constructor(options: RecorderOptions) {
|
|
31
34
|
this.engine = options.engine;
|
|
@@ -42,6 +45,11 @@ export class Recorder {
|
|
|
42
45
|
}
|
|
43
46
|
this.state = RecorderState.Recording;
|
|
44
47
|
this.parameterizeRoute = config?.parameterizeRoute ?? defaultParameterizeRoute;
|
|
48
|
+
this.sanitizeNetworkUrl = getValueSanitizer(
|
|
49
|
+
typeof config?.captureNetworkRequests === 'object' ? config.captureNetworkRequests.sanitize : undefined,
|
|
50
|
+
'network',
|
|
51
|
+
);
|
|
52
|
+
this.warnNetworkDrop = createOnceLogger(config?.debugLogger);
|
|
45
53
|
|
|
46
54
|
this.engine.start(config ?? {}, event => {
|
|
47
55
|
if (event.type === RecordingEventType.Meta) {
|
|
@@ -86,7 +94,13 @@ export class Recorder {
|
|
|
86
94
|
}
|
|
87
95
|
}
|
|
88
96
|
|
|
89
|
-
private emitNetworkRequest(
|
|
97
|
+
private emitNetworkRequest(raw: NetworkRequestPluginData['payload']): void {
|
|
98
|
+
const sanitizeUrl = this.sanitizeNetworkUrl;
|
|
99
|
+
const payload = sanitizeUrl
|
|
100
|
+
? dropOnFailure(() => ({ ...raw, url: sanitizeUrl(raw.url) }), this.warnNetworkDrop, 'network')
|
|
101
|
+
: raw;
|
|
102
|
+
if (!payload) return;
|
|
103
|
+
|
|
90
104
|
const data: NetworkRequestPluginData = {
|
|
91
105
|
plugin: RecordingPluginName.NetworkRequest,
|
|
92
106
|
payload,
|
|
@@ -304,6 +318,15 @@ export class Recorder {
|
|
|
304
318
|
window.removeEventListener('pageshow', this.pageShowHandler);
|
|
305
319
|
this.pageShowHandler = null;
|
|
306
320
|
}
|
|
321
|
+
// The sanitizer is deliberately left in place. `restoreNetwork` cannot cancel
|
|
322
|
+
// in-flight requests, and their patched handlers still emit after `stop()`.
|
|
323
|
+
// Clearing it here would send those URLs raw.
|
|
324
|
+
// The sanitizer is deliberately left in place. `restoreNetwork` cannot cancel
|
|
325
|
+
// in-flight requests, and their patched handlers still emit after `stop()`.
|
|
326
|
+
// Clearing it here would send those URLs raw.
|
|
327
|
+
// The sanitizer is deliberately left in place. `restoreNetwork` cannot cancel
|
|
328
|
+
// in-flight requests, and their patched handlers still emit after `stop()`.
|
|
329
|
+
// Clearing it here would send those URLs raw.
|
|
307
330
|
this.restoreNetwork();
|
|
308
331
|
this.restoreRouting();
|
|
309
332
|
this.engine.stop();
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { CaptureSanitizer } from './capture-sanitizer';
|
|
2
|
+
|
|
1
3
|
export interface RecorderOptions {
|
|
2
4
|
/** Engine used to capture DOM events (defaults to rrweb). */
|
|
3
5
|
engine: import('./engine').RecordingEngine;
|
|
@@ -8,6 +10,17 @@ export interface RecorderOptions {
|
|
|
8
10
|
export const DEFAULT_MASK_SELECTORS: string[] = ['[data-csr-mask]'];
|
|
9
11
|
export const DEFAULT_BLOCK_SELECTORS: string[] = ['[data-csr-block]', 'video'];
|
|
10
12
|
|
|
13
|
+
export type { CaptureSanitizer };
|
|
14
|
+
|
|
15
|
+
export interface ConsoleCaptureOptions {
|
|
16
|
+
levels?: import('@spotify-confidence/csr-common').ConsoleLogLevel[];
|
|
17
|
+
sanitize?: CaptureSanitizer;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface NetworkCaptureOptions {
|
|
21
|
+
sanitize?: CaptureSanitizer;
|
|
22
|
+
}
|
|
23
|
+
|
|
11
24
|
export interface RecordingConfig {
|
|
12
25
|
/**
|
|
13
26
|
* CSS selectors for text content that should be masked. Joined with `,` and
|
|
@@ -38,8 +51,10 @@ export interface RecordingConfig {
|
|
|
38
51
|
*
|
|
39
52
|
* - `true` — capture all levels (log, warn, error, debug, info).
|
|
40
53
|
* - `{ levels: [...] }` — capture only the listed levels.
|
|
54
|
+
* - Add `sanitize: true` to remove query strings and fragments from URLs,
|
|
55
|
+
* or provide a function to sanitize each captured payload and trace.
|
|
41
56
|
*/
|
|
42
|
-
captureConsoleLogs?: boolean |
|
|
57
|
+
captureConsoleLogs?: boolean | ConsoleCaptureOptions;
|
|
43
58
|
/**
|
|
44
59
|
* Capture network requests (fetch and XMLHttpRequest) during the recording.
|
|
45
60
|
* Defaults to `false` because request URLs and metadata can contain PII,
|
|
@@ -47,8 +62,14 @@ export interface RecordingConfig {
|
|
|
47
62
|
*
|
|
48
63
|
* Only metadata is captured (method, URL, status, duration, sizes) — no
|
|
49
64
|
* headers or bodies are recorded.
|
|
65
|
+
*
|
|
66
|
+
* - `true` — capture raw URLs, preserving existing behavior.
|
|
67
|
+
* - `{ sanitize: true }` — remove query strings and fragments.
|
|
68
|
+
* - `{ sanitize: value => ... }` — apply a custom sanitizer.
|
|
50
69
|
*/
|
|
51
|
-
captureNetworkRequests?: boolean;
|
|
70
|
+
captureNetworkRequests?: boolean | NetworkCaptureOptions;
|
|
71
|
+
/** Optional diagnostic logger used for fail-closed sanitizer warnings. */
|
|
72
|
+
debugLogger?: (message: string) => void;
|
|
52
73
|
/**
|
|
53
74
|
* Capture client-side route changes during the recording. Defaults to
|
|
54
75
|
* `true`. Only the pathname is recorded; origin, query strings, and
|