@spotify-confidence/csr-recorder 0.0.0 → 0.17.3
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 +48 -0
- package/README.md +64 -0
- package/dist/index.cjs +11142 -0
- package/dist/index.d.cts +145 -0
- package/dist/index.d.ts +145 -0
- package/dist/index.js +11135 -0
- package/package.json +49 -1
- package/src/engine/index.ts +11 -0
- package/src/engine/rrweb-engine.test.ts +63 -0
- package/src/engine/rrweb-engine.ts +49 -0
- package/src/index.ts +12 -0
- package/src/recorder-routing.test.ts +278 -0
- package/src/recorder.test.ts +207 -0
- package/src/recorder.ts +289 -0
- package/src/route-parameterizer.test.ts +75 -0
- package/src/route-parameterizer.ts +20 -0
- package/src/start-recording.ts +21 -0
- package/src/types.ts +80 -0
package/src/recorder.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RecordingEvent,
|
|
3
|
+
RecordingEventType,
|
|
4
|
+
type TabVisibilityPluginData,
|
|
5
|
+
type NetworkRequestPluginData,
|
|
6
|
+
type RouteChangePluginData,
|
|
7
|
+
type RouteChangeTrigger,
|
|
8
|
+
} from '@spotify-confidence/csr-common';
|
|
9
|
+
import { RecorderOptions, RecorderState, RecordingConfig } from './types';
|
|
10
|
+
import { RecordingEngine } from './engine';
|
|
11
|
+
import { defaultParameterizeRoute } from './route-parameterizer';
|
|
12
|
+
|
|
13
|
+
export class Recorder {
|
|
14
|
+
private readonly engine: RecordingEngine;
|
|
15
|
+
private readonly onEvent: (event: RecordingEvent) => void;
|
|
16
|
+
private state: RecorderState = RecorderState.Idle;
|
|
17
|
+
private visibilityHandler: (() => void) | null = null;
|
|
18
|
+
private originalFetch: typeof globalThis.fetch | null = null;
|
|
19
|
+
private originalXhrOpen: typeof XMLHttpRequest.prototype.open | null = null;
|
|
20
|
+
private originalXhrSend: typeof XMLHttpRequest.prototype.send | null = null;
|
|
21
|
+
private originalPushState: typeof history.pushState | null = null;
|
|
22
|
+
private originalReplaceState: typeof history.replaceState | null = null;
|
|
23
|
+
private popstateHandler: (() => void) | null = null;
|
|
24
|
+
private parameterizeRoute!: (route: string) => string;
|
|
25
|
+
|
|
26
|
+
constructor(options: RecorderOptions) {
|
|
27
|
+
this.engine = options.engine;
|
|
28
|
+
this.onEvent = options.onEvent;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get currentState(): RecorderState {
|
|
32
|
+
return this.state;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
start(config?: RecordingConfig): void {
|
|
36
|
+
if (this.state === RecorderState.Recording) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
this.state = RecorderState.Recording;
|
|
40
|
+
this.parameterizeRoute = config?.parameterizeRoute ?? defaultParameterizeRoute;
|
|
41
|
+
|
|
42
|
+
this.engine.start(config ?? {}, event => {
|
|
43
|
+
if (event.type === RecordingEventType.Meta) {
|
|
44
|
+
const data = event.data as { href?: string };
|
|
45
|
+
if (typeof data?.href === 'string') {
|
|
46
|
+
event = { ...event, data: { ...data, href: this.parameterizeHref(data.href) } };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
this.onEvent(event);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (typeof document !== 'undefined') {
|
|
53
|
+
this.visibilityHandler = () => {
|
|
54
|
+
const data: TabVisibilityPluginData = {
|
|
55
|
+
plugin: 'csr:tabVisibility',
|
|
56
|
+
payload: { hidden: document.hidden },
|
|
57
|
+
};
|
|
58
|
+
this.onEvent({
|
|
59
|
+
type: RecordingEventType.Plugin,
|
|
60
|
+
timestamp: Date.now(),
|
|
61
|
+
data,
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
document.addEventListener('visibilitychange', this.visibilityHandler);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (config?.captureNetworkRequests) {
|
|
68
|
+
this.patchNetwork();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (config?.captureRouteChanges !== false) {
|
|
72
|
+
this.patchRouting();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private emitNetworkRequest(payload: NetworkRequestPluginData['payload']): void {
|
|
77
|
+
const data: NetworkRequestPluginData = {
|
|
78
|
+
plugin: 'csr:networkRequest',
|
|
79
|
+
payload,
|
|
80
|
+
};
|
|
81
|
+
this.onEvent({
|
|
82
|
+
type: RecordingEventType.Plugin,
|
|
83
|
+
timestamp: Date.now(),
|
|
84
|
+
data,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private patchNetwork(): void {
|
|
89
|
+
this.patchFetch();
|
|
90
|
+
this.patchXhr();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private patchFetch(): void {
|
|
94
|
+
if (typeof globalThis.fetch !== 'function') return;
|
|
95
|
+
const originalFetch = globalThis.fetch;
|
|
96
|
+
this.originalFetch = originalFetch;
|
|
97
|
+
const emit = this.emitNetworkRequest.bind(this);
|
|
98
|
+
|
|
99
|
+
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
|
100
|
+
const method = input instanceof Request ? input.method : init?.method ?? 'GET';
|
|
101
|
+
let url: string;
|
|
102
|
+
if (input instanceof Request) {
|
|
103
|
+
url = input.url;
|
|
104
|
+
} else if (input instanceof URL) {
|
|
105
|
+
url = input.href;
|
|
106
|
+
} else {
|
|
107
|
+
url = String(input);
|
|
108
|
+
}
|
|
109
|
+
const requestSize = init?.body ? new Blob([init.body as BlobPart]).size : undefined;
|
|
110
|
+
const start = Date.now();
|
|
111
|
+
|
|
112
|
+
return originalFetch.call(globalThis, input, init).then(
|
|
113
|
+
response => {
|
|
114
|
+
const contentLength = response.headers.get('content-length');
|
|
115
|
+
emit({
|
|
116
|
+
initiator: 'fetch',
|
|
117
|
+
method: method.toUpperCase(),
|
|
118
|
+
url,
|
|
119
|
+
status: response.status,
|
|
120
|
+
durationMs: Date.now() - start,
|
|
121
|
+
...(requestSize !== null && requestSize !== undefined ? { requestSize } : {}),
|
|
122
|
+
...(contentLength ? { responseSize: Number(contentLength) } : {}),
|
|
123
|
+
});
|
|
124
|
+
return response;
|
|
125
|
+
},
|
|
126
|
+
error => {
|
|
127
|
+
emit({
|
|
128
|
+
initiator: 'fetch',
|
|
129
|
+
method: method.toUpperCase(),
|
|
130
|
+
url,
|
|
131
|
+
status: 0,
|
|
132
|
+
durationMs: Date.now() - start,
|
|
133
|
+
...(requestSize !== null && requestSize !== undefined ? { requestSize } : {}),
|
|
134
|
+
});
|
|
135
|
+
throw error;
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private patchXhr(): void {
|
|
142
|
+
if (typeof XMLHttpRequest === 'undefined') return;
|
|
143
|
+
const originalOpen = XMLHttpRequest.prototype.open;
|
|
144
|
+
const originalSend = XMLHttpRequest.prototype.send;
|
|
145
|
+
this.originalXhrOpen = originalOpen;
|
|
146
|
+
this.originalXhrSend = originalSend;
|
|
147
|
+
const emit = this.emitNetworkRequest.bind(this);
|
|
148
|
+
|
|
149
|
+
XMLHttpRequest.prototype.open = function csrOpen(
|
|
150
|
+
this: XMLHttpRequest,
|
|
151
|
+
method: string,
|
|
152
|
+
url: string | URL,
|
|
153
|
+
...rest: unknown[]
|
|
154
|
+
) {
|
|
155
|
+
(this as unknown as Record<string, unknown>).__csr_method = method;
|
|
156
|
+
(this as unknown as Record<string, unknown>).__csr_url = String(url);
|
|
157
|
+
return originalOpen.apply(this, [method, url, ...rest] as Parameters<typeof XMLHttpRequest.prototype.open>);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
XMLHttpRequest.prototype.send = function csrSend(
|
|
161
|
+
this: XMLHttpRequest,
|
|
162
|
+
body?: Document | XMLHttpRequestBodyInit | null,
|
|
163
|
+
) {
|
|
164
|
+
const meta = this as unknown as Record<string, unknown>;
|
|
165
|
+
const method = (meta.__csr_method as string) ?? 'GET';
|
|
166
|
+
const url = (meta.__csr_url as string) ?? '';
|
|
167
|
+
const requestSize = body !== null && body !== undefined ? new Blob([body as BlobPart]).size : undefined;
|
|
168
|
+
const start = Date.now();
|
|
169
|
+
|
|
170
|
+
this.addEventListener('loadend', function csrLoadend(this: XMLHttpRequest) {
|
|
171
|
+
const contentLength = this.getResponseHeader('content-length');
|
|
172
|
+
emit({
|
|
173
|
+
initiator: 'xhr',
|
|
174
|
+
method: method.toUpperCase(),
|
|
175
|
+
url,
|
|
176
|
+
status: this.status,
|
|
177
|
+
durationMs: Date.now() - start,
|
|
178
|
+
...(requestSize !== null && requestSize !== undefined ? { requestSize } : {}),
|
|
179
|
+
...(contentLength ? { responseSize: Number(contentLength) } : {}),
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return originalSend.call(this, body);
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private parameterizeHref(href: string): string {
|
|
188
|
+
try {
|
|
189
|
+
const url = new URL(href);
|
|
190
|
+
url.pathname = this.parameterizeRoute(url.pathname);
|
|
191
|
+
return url.toString();
|
|
192
|
+
} catch (_e) {
|
|
193
|
+
return this.parameterizeRoute(href);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private emitRouteChange(from: string, to: string, trigger: RouteChangeTrigger): void {
|
|
198
|
+
if (from === to) return;
|
|
199
|
+
const paramFrom = this.parameterizeRoute(from);
|
|
200
|
+
const paramTo = this.parameterizeRoute(to);
|
|
201
|
+
if (paramFrom === paramTo) return;
|
|
202
|
+
const data: RouteChangePluginData = {
|
|
203
|
+
plugin: 'csr:routeChange',
|
|
204
|
+
payload: { from: paramFrom, to: paramTo, trigger },
|
|
205
|
+
};
|
|
206
|
+
this.onEvent({
|
|
207
|
+
type: RecordingEventType.Plugin,
|
|
208
|
+
timestamp: Date.now(),
|
|
209
|
+
data,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private static currentPathname(): string {
|
|
214
|
+
return window.location.pathname;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private patchRouting(): void {
|
|
218
|
+
if (typeof window === 'undefined') return;
|
|
219
|
+
|
|
220
|
+
let lastUrl = Recorder.currentPathname();
|
|
221
|
+
|
|
222
|
+
const emitNav = (trigger: RouteChangeTrigger) => {
|
|
223
|
+
const before = lastUrl;
|
|
224
|
+
lastUrl = Recorder.currentPathname();
|
|
225
|
+
this.emitRouteChange(before, lastUrl, trigger);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const originalPushState = history.pushState.bind(history);
|
|
229
|
+
this.originalPushState = history.pushState;
|
|
230
|
+
history.pushState = (...args: Parameters<typeof history.pushState>) => {
|
|
231
|
+
originalPushState(...args);
|
|
232
|
+
emitNav('pushState');
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const originalReplaceState = history.replaceState.bind(history);
|
|
236
|
+
this.originalReplaceState = history.replaceState;
|
|
237
|
+
history.replaceState = (...args: Parameters<typeof history.replaceState>) => {
|
|
238
|
+
originalReplaceState(...args);
|
|
239
|
+
emitNav('replaceState');
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
this.popstateHandler = () => emitNav('popstate');
|
|
243
|
+
window.addEventListener('popstate', this.popstateHandler);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private restoreRouting(): void {
|
|
247
|
+
if (this.originalPushState) {
|
|
248
|
+
history.pushState = this.originalPushState;
|
|
249
|
+
this.originalPushState = null;
|
|
250
|
+
}
|
|
251
|
+
if (this.originalReplaceState) {
|
|
252
|
+
history.replaceState = this.originalReplaceState;
|
|
253
|
+
this.originalReplaceState = null;
|
|
254
|
+
}
|
|
255
|
+
if (this.popstateHandler) {
|
|
256
|
+
window.removeEventListener('popstate', this.popstateHandler);
|
|
257
|
+
this.popstateHandler = null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private restoreNetwork(): void {
|
|
262
|
+
if (this.originalFetch) {
|
|
263
|
+
globalThis.fetch = this.originalFetch;
|
|
264
|
+
this.originalFetch = null;
|
|
265
|
+
}
|
|
266
|
+
if (this.originalXhrOpen) {
|
|
267
|
+
XMLHttpRequest.prototype.open = this.originalXhrOpen;
|
|
268
|
+
this.originalXhrOpen = null;
|
|
269
|
+
}
|
|
270
|
+
if (this.originalXhrSend) {
|
|
271
|
+
XMLHttpRequest.prototype.send = this.originalXhrSend;
|
|
272
|
+
this.originalXhrSend = null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
stop(): void {
|
|
277
|
+
if (this.state !== RecorderState.Recording) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (this.visibilityHandler) {
|
|
281
|
+
document.removeEventListener('visibilitychange', this.visibilityHandler);
|
|
282
|
+
this.visibilityHandler = null;
|
|
283
|
+
}
|
|
284
|
+
this.restoreNetwork();
|
|
285
|
+
this.restoreRouting();
|
|
286
|
+
this.engine.stop();
|
|
287
|
+
this.state = RecorderState.Stopped;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { defaultParameterizeRoute } from './route-parameterizer';
|
|
3
|
+
|
|
4
|
+
describe('defaultParameterizeRoute', () => {
|
|
5
|
+
it('leaves static routes unchanged', () => {
|
|
6
|
+
expect(defaultParameterizeRoute('/users/settings')).toBe('/users/settings');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('replaces numeric IDs', () => {
|
|
10
|
+
expect(defaultParameterizeRoute('/users/123')).toBe('/users/:id');
|
|
11
|
+
expect(defaultParameterizeRoute('/users/123/posts/456')).toBe('/users/:id/posts/:id');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('replaces UUIDs', () => {
|
|
15
|
+
expect(defaultParameterizeRoute('/users/550e8400-e29b-41d4-a716-446655440000')).toBe('/users/:uuid');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('replaces uppercase UUIDs', () => {
|
|
19
|
+
expect(defaultParameterizeRoute('/users/550E8400-E29B-41D4-A716-446655440000')).toBe('/users/:uuid');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('replaces long hex strings (MongoDB ObjectIDs)', () => {
|
|
23
|
+
expect(defaultParameterizeRoute('/items/507f1f77bcf86cd799439011')).toBe('/items/:id');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('handles mixed segments', () => {
|
|
27
|
+
expect(defaultParameterizeRoute('/org/550e8400-e29b-41d4-a716-446655440000/users/42/profile')).toBe(
|
|
28
|
+
'/org/:uuid/users/:id/profile',
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('preserves root path', () => {
|
|
33
|
+
expect(defaultParameterizeRoute('/')).toBe('/');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('preserves empty string', () => {
|
|
37
|
+
expect(defaultParameterizeRoute('')).toBe('');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('does not replace short hex strings', () => {
|
|
41
|
+
expect(defaultParameterizeRoute('/features/abcdef')).toBe('/features/abcdef');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('does not replace words that look vaguely hex-ish', () => {
|
|
45
|
+
expect(defaultParameterizeRoute('/dashboard/feed')).toBe('/dashboard/feed');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('handles trailing slash', () => {
|
|
49
|
+
expect(defaultParameterizeRoute('/users/123/')).toBe('/users/:id/');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('replaces AIP-122 generated IDs', () => {
|
|
53
|
+
expect(defaultParameterizeRoute('/workflows/abtest/instances/cmvkznnjmbkc9rw2oxws/report')).toBe(
|
|
54
|
+
'/workflows/abtest/instances/:id/report',
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('replaces multiple AIP-122 IDs', () => {
|
|
59
|
+
expect(defaultParameterizeRoute('/admin/workflows/a0smva5nxuhv4yts6pax/instances/cmvkznnjmbkc9rw2oxws')).toBe(
|
|
60
|
+
'/admin/workflows/:id/instances/:id',
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('does not replace short lowercase strings as AIP-122', () => {
|
|
65
|
+
expect(defaultParameterizeRoute('/workflows/abtest')).toBe('/workflows/abtest');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('replaces segments containing embedded hex IDs', () => {
|
|
69
|
+
expect(defaultParameterizeRoute('/items/prefix507f1f77bcf86cd799439011suffix')).toBe('/items/:id');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('replaces MD5 hashes', () => {
|
|
73
|
+
expect(defaultParameterizeRoute('/cache/d41d8cd98f00b204e9800998ecf8427e')).toBe('/cache/:id');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const SEGMENT_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
|
|
2
|
+
{ pattern: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, replacement: ':uuid' },
|
|
3
|
+
{ pattern: /^\d+$/, replacement: ':id' },
|
|
4
|
+
// AIP-122 generated IDs: 20-char lowercase alphanumeric starting with a letter
|
|
5
|
+
{ pattern: /^[a-z][a-z0-9]{19}$/, replacement: ':id' },
|
|
6
|
+
{ pattern: /[0-9a-f]{20}/i, replacement: ':id' },
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
export function defaultParameterizeRoute(route: string): string {
|
|
10
|
+
return route
|
|
11
|
+
.split('/')
|
|
12
|
+
.map(segment => {
|
|
13
|
+
if (!segment) return segment;
|
|
14
|
+
for (const { pattern, replacement } of SEGMENT_PATTERNS) {
|
|
15
|
+
if (pattern.test(segment)) return replacement;
|
|
16
|
+
}
|
|
17
|
+
return segment;
|
|
18
|
+
})
|
|
19
|
+
.join('/');
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { RecordingEvent } from '@spotify-confidence/csr-common';
|
|
2
|
+
import { Recorder } from './recorder';
|
|
3
|
+
import { RrwebEngine } from './engine/rrweb-engine';
|
|
4
|
+
import { RecordingConfig } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Start recording DOM events. Each event is passed to the callback
|
|
8
|
+
* as it is captured — no buffering or batching.
|
|
9
|
+
*
|
|
10
|
+
* Returns a function that stops the recording.
|
|
11
|
+
*/
|
|
12
|
+
export function record(onEvent: (event: RecordingEvent) => void, config?: RecordingConfig): () => void {
|
|
13
|
+
const recorder = new Recorder({
|
|
14
|
+
engine: new RrwebEngine(),
|
|
15
|
+
onEvent,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
recorder.start(config);
|
|
19
|
+
|
|
20
|
+
return () => recorder.stop();
|
|
21
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export interface RecorderOptions {
|
|
2
|
+
/** Engine used to capture DOM events (defaults to rrweb). */
|
|
3
|
+
engine: import('./engine').RecordingEngine;
|
|
4
|
+
/** Called for each recorded event. */
|
|
5
|
+
onEvent: (event: import('@spotify-confidence/csr-common').RecordingEvent) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_MASK_SELECTORS: string[] = ['[data-csr-mask]'];
|
|
9
|
+
export const DEFAULT_BLOCK_SELECTORS: string[] = ['[data-csr-block]'];
|
|
10
|
+
|
|
11
|
+
export interface RecordingConfig {
|
|
12
|
+
/**
|
|
13
|
+
* CSS selectors for text content that should be masked. Joined with `,` and
|
|
14
|
+
* passed to rrweb as `maskTextSelector`. Text inside matching elements is
|
|
15
|
+
* replaced with `*` of the same length on the wire.
|
|
16
|
+
*/
|
|
17
|
+
maskSelectors?: string[];
|
|
18
|
+
/**
|
|
19
|
+
* CSS selectors for elements whose subtree should be blocked entirely.
|
|
20
|
+
* Joined with `,` and passed to rrweb as `blockSelector`. Matching elements
|
|
21
|
+
* are replaced with a same-sized placeholder; their contents are never
|
|
22
|
+
* serialized — stronger than masking, use for media, third-party widgets,
|
|
23
|
+
* or anything that shouldn't leave the page at all.
|
|
24
|
+
*/
|
|
25
|
+
blockSelectors?: string[];
|
|
26
|
+
/**
|
|
27
|
+
* Mask the values of every `<input>` / `<textarea>` / `contenteditable`.
|
|
28
|
+
* Defaults to `true` — typed PII (emails, names, search queries) doesn't
|
|
29
|
+
* leave the page. Pass `false` to record raw input values; `<input
|
|
30
|
+
* type="password">` is masked by rrweb regardless.
|
|
31
|
+
*/
|
|
32
|
+
maskInputs?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Capture browser console output during the recording. Defaults to `false`
|
|
35
|
+
* because console output frequently contains PII, tokens, and other
|
|
36
|
+
* sensitive data that should not be ingested into the recording pipeline
|
|
37
|
+
* without explicit opt-in.
|
|
38
|
+
*
|
|
39
|
+
* - `true` — capture all levels (log, warn, error, debug, info).
|
|
40
|
+
* - `{ levels: [...] }` — capture only the listed levels.
|
|
41
|
+
*/
|
|
42
|
+
captureConsoleLogs?: boolean | { levels: import('@spotify-confidence/csr-common').ConsoleLogLevel[] };
|
|
43
|
+
/**
|
|
44
|
+
* Capture network requests (fetch and XMLHttpRequest) during the recording.
|
|
45
|
+
* Defaults to `false` because request URLs and metadata can contain PII,
|
|
46
|
+
* tokens, or other sensitive data.
|
|
47
|
+
*
|
|
48
|
+
* Only metadata is captured (method, URL, status, duration, sizes) — no
|
|
49
|
+
* headers or bodies are recorded.
|
|
50
|
+
*/
|
|
51
|
+
captureNetworkRequests?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Capture client-side route changes during the recording. Defaults to
|
|
54
|
+
* `true`. Only the pathname is recorded; origin, query strings, and
|
|
55
|
+
* hashes are stripped.
|
|
56
|
+
*/
|
|
57
|
+
captureRouteChanges?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Transform a raw pathname into a route pattern before it is emitted in
|
|
60
|
+
* route-change and Meta events. For example, `/users/123/profile` becomes
|
|
61
|
+
* `/users/:id/profile`. This ensures per-page metrics are grouped by route
|
|
62
|
+
* rather than by individual page visit.
|
|
63
|
+
*
|
|
64
|
+
* The default implementation replaces common dynamic segments:
|
|
65
|
+
* - UUIDs → `:uuid`
|
|
66
|
+
* - Numeric IDs → `:id`
|
|
67
|
+
* - AIP-122 IDs → `:id`
|
|
68
|
+
* - Long hex strings (20+ chars, e.g. MongoDB ObjectIDs) → `:id`
|
|
69
|
+
*
|
|
70
|
+
* Provide a custom function to handle application-specific patterns.
|
|
71
|
+
* Import `defaultParameterizeRoute` to compose with the built-in rules.
|
|
72
|
+
*/
|
|
73
|
+
parameterizeRoute?: (route: string) => string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export enum RecorderState {
|
|
77
|
+
Idle = 'idle',
|
|
78
|
+
Recording = 'recording',
|
|
79
|
+
Stopped = 'stopped',
|
|
80
|
+
}
|