@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 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(payload) {
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/rrweb-engine.ts
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 { captureConsoleLogs } = config;
11278
- if (captureConsoleLogs) {
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: (event) => {
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(payload) {
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/rrweb-engine.ts
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 { captureConsoleLogs } = config;
11277
- if (captureConsoleLogs) {
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: (event) => {
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spotify-confidence/csr-recorder",
3
3
  "license": "Apache-2.0",
4
- "version": "0.17.21",
4
+ "version": "0.17.22",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/spotify/confidence-sdk-js.git",
@@ -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 ConsoleLogLevel, type RecordingEvent } from '@spotify-confidence/csr-common';
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 { getRecordConsolePlugin } from '@rrweb/rrweb-plugin-console-record';
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 { captureConsoleLogs } = config;
202
- if (captureConsoleLogs) {
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: event => {
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,
@@ -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(payload: NetworkRequestPluginData['payload']): void {
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 | { levels: import('@spotify-confidence/csr-common').ConsoleLogLevel[] };
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