@spotify-confidence/csr-recorder 0.0.0 → 0.17.2
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 +34 -0
- package/README.md +36 -0
- package/dist/index.cjs +11090 -0
- package/dist/index.d.cts +124 -0
- package/dist/index.d.ts +124 -0
- package/dist/index.js +11084 -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 +11 -0
- package/src/recorder-routing.test.ts +162 -0
- package/src/recorder.test.ts +207 -0
- package/src/recorder.ts +266 -0
- package/src/start-recording.ts +21 -0
- package/src/types.ts +64 -0
package/src/recorder.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
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
|
+
|
|
12
|
+
export class Recorder {
|
|
13
|
+
private readonly engine: RecordingEngine;
|
|
14
|
+
private readonly onEvent: (event: RecordingEvent) => void;
|
|
15
|
+
private state: RecorderState = RecorderState.Idle;
|
|
16
|
+
private visibilityHandler: (() => void) | null = null;
|
|
17
|
+
private originalFetch: typeof globalThis.fetch | null = null;
|
|
18
|
+
private originalXhrOpen: typeof XMLHttpRequest.prototype.open | null = null;
|
|
19
|
+
private originalXhrSend: typeof XMLHttpRequest.prototype.send | null = null;
|
|
20
|
+
private originalPushState: typeof history.pushState | null = null;
|
|
21
|
+
private originalReplaceState: typeof history.replaceState | null = null;
|
|
22
|
+
private popstateHandler: (() => void) | null = null;
|
|
23
|
+
|
|
24
|
+
constructor(options: RecorderOptions) {
|
|
25
|
+
this.engine = options.engine;
|
|
26
|
+
this.onEvent = options.onEvent;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get currentState(): RecorderState {
|
|
30
|
+
return this.state;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
start(config?: RecordingConfig): void {
|
|
34
|
+
if (this.state === RecorderState.Recording) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
this.state = RecorderState.Recording;
|
|
38
|
+
this.engine.start(config ?? {}, event => {
|
|
39
|
+
this.onEvent(event);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (typeof document !== 'undefined') {
|
|
43
|
+
this.visibilityHandler = () => {
|
|
44
|
+
const data: TabVisibilityPluginData = {
|
|
45
|
+
plugin: 'csr:tabVisibility',
|
|
46
|
+
payload: { hidden: document.hidden },
|
|
47
|
+
};
|
|
48
|
+
this.onEvent({
|
|
49
|
+
type: RecordingEventType.Plugin,
|
|
50
|
+
timestamp: Date.now(),
|
|
51
|
+
data,
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
document.addEventListener('visibilitychange', this.visibilityHandler);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (config?.captureNetworkRequests) {
|
|
58
|
+
this.patchNetwork();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (config?.captureRouteChanges !== false) {
|
|
62
|
+
this.patchRouting();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private emitNetworkRequest(payload: NetworkRequestPluginData['payload']): void {
|
|
67
|
+
const data: NetworkRequestPluginData = {
|
|
68
|
+
plugin: 'csr:networkRequest',
|
|
69
|
+
payload,
|
|
70
|
+
};
|
|
71
|
+
this.onEvent({
|
|
72
|
+
type: RecordingEventType.Plugin,
|
|
73
|
+
timestamp: Date.now(),
|
|
74
|
+
data,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private patchNetwork(): void {
|
|
79
|
+
this.patchFetch();
|
|
80
|
+
this.patchXhr();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private patchFetch(): void {
|
|
84
|
+
if (typeof globalThis.fetch !== 'function') return;
|
|
85
|
+
const originalFetch = globalThis.fetch;
|
|
86
|
+
this.originalFetch = originalFetch;
|
|
87
|
+
const emit = this.emitNetworkRequest.bind(this);
|
|
88
|
+
|
|
89
|
+
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
|
90
|
+
const method = input instanceof Request ? input.method : init?.method ?? 'GET';
|
|
91
|
+
let url: string;
|
|
92
|
+
if (input instanceof Request) {
|
|
93
|
+
url = input.url;
|
|
94
|
+
} else if (input instanceof URL) {
|
|
95
|
+
url = input.href;
|
|
96
|
+
} else {
|
|
97
|
+
url = String(input);
|
|
98
|
+
}
|
|
99
|
+
const requestSize = init?.body ? new Blob([init.body as BlobPart]).size : undefined;
|
|
100
|
+
const start = Date.now();
|
|
101
|
+
|
|
102
|
+
return originalFetch.call(globalThis, input, init).then(
|
|
103
|
+
response => {
|
|
104
|
+
const contentLength = response.headers.get('content-length');
|
|
105
|
+
emit({
|
|
106
|
+
initiator: 'fetch',
|
|
107
|
+
method: method.toUpperCase(),
|
|
108
|
+
url,
|
|
109
|
+
status: response.status,
|
|
110
|
+
durationMs: Date.now() - start,
|
|
111
|
+
...(requestSize !== null && requestSize !== undefined ? { requestSize } : {}),
|
|
112
|
+
...(contentLength ? { responseSize: Number(contentLength) } : {}),
|
|
113
|
+
});
|
|
114
|
+
return response;
|
|
115
|
+
},
|
|
116
|
+
error => {
|
|
117
|
+
emit({
|
|
118
|
+
initiator: 'fetch',
|
|
119
|
+
method: method.toUpperCase(),
|
|
120
|
+
url,
|
|
121
|
+
status: 0,
|
|
122
|
+
durationMs: Date.now() - start,
|
|
123
|
+
...(requestSize !== null && requestSize !== undefined ? { requestSize } : {}),
|
|
124
|
+
});
|
|
125
|
+
throw error;
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private patchXhr(): void {
|
|
132
|
+
if (typeof XMLHttpRequest === 'undefined') return;
|
|
133
|
+
const originalOpen = XMLHttpRequest.prototype.open;
|
|
134
|
+
const originalSend = XMLHttpRequest.prototype.send;
|
|
135
|
+
this.originalXhrOpen = originalOpen;
|
|
136
|
+
this.originalXhrSend = originalSend;
|
|
137
|
+
const emit = this.emitNetworkRequest.bind(this);
|
|
138
|
+
|
|
139
|
+
XMLHttpRequest.prototype.open = function csrOpen(
|
|
140
|
+
this: XMLHttpRequest,
|
|
141
|
+
method: string,
|
|
142
|
+
url: string | URL,
|
|
143
|
+
...rest: unknown[]
|
|
144
|
+
) {
|
|
145
|
+
(this as unknown as Record<string, unknown>).__csr_method = method;
|
|
146
|
+
(this as unknown as Record<string, unknown>).__csr_url = String(url);
|
|
147
|
+
return originalOpen.apply(this, [method, url, ...rest] as Parameters<typeof XMLHttpRequest.prototype.open>);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
XMLHttpRequest.prototype.send = function csrSend(
|
|
151
|
+
this: XMLHttpRequest,
|
|
152
|
+
body?: Document | XMLHttpRequestBodyInit | null,
|
|
153
|
+
) {
|
|
154
|
+
const meta = this as unknown as Record<string, unknown>;
|
|
155
|
+
const method = (meta.__csr_method as string) ?? 'GET';
|
|
156
|
+
const url = (meta.__csr_url as string) ?? '';
|
|
157
|
+
const requestSize = body !== null && body !== undefined ? new Blob([body as BlobPart]).size : undefined;
|
|
158
|
+
const start = Date.now();
|
|
159
|
+
|
|
160
|
+
this.addEventListener('loadend', function csrLoadend(this: XMLHttpRequest) {
|
|
161
|
+
const contentLength = this.getResponseHeader('content-length');
|
|
162
|
+
emit({
|
|
163
|
+
initiator: 'xhr',
|
|
164
|
+
method: method.toUpperCase(),
|
|
165
|
+
url,
|
|
166
|
+
status: this.status,
|
|
167
|
+
durationMs: Date.now() - start,
|
|
168
|
+
...(requestSize !== null && requestSize !== undefined ? { requestSize } : {}),
|
|
169
|
+
...(contentLength ? { responseSize: Number(contentLength) } : {}),
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
return originalSend.call(this, body);
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private emitRouteChange(from: string, to: string, trigger: RouteChangeTrigger): void {
|
|
178
|
+
if (from === to) return;
|
|
179
|
+
const data: RouteChangePluginData = {
|
|
180
|
+
plugin: 'csr:routeChange',
|
|
181
|
+
payload: { from, to, trigger },
|
|
182
|
+
};
|
|
183
|
+
this.onEvent({
|
|
184
|
+
type: RecordingEventType.Plugin,
|
|
185
|
+
timestamp: Date.now(),
|
|
186
|
+
data,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private static currentPathname(): string {
|
|
191
|
+
return window.location.pathname;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private patchRouting(): void {
|
|
195
|
+
if (typeof window === 'undefined') return;
|
|
196
|
+
|
|
197
|
+
let lastUrl = Recorder.currentPathname();
|
|
198
|
+
|
|
199
|
+
const emitNav = (trigger: RouteChangeTrigger) => {
|
|
200
|
+
const before = lastUrl;
|
|
201
|
+
lastUrl = Recorder.currentPathname();
|
|
202
|
+
this.emitRouteChange(before, lastUrl, trigger);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const originalPushState = history.pushState.bind(history);
|
|
206
|
+
this.originalPushState = history.pushState;
|
|
207
|
+
history.pushState = (...args: Parameters<typeof history.pushState>) => {
|
|
208
|
+
originalPushState(...args);
|
|
209
|
+
emitNav('pushState');
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const originalReplaceState = history.replaceState.bind(history);
|
|
213
|
+
this.originalReplaceState = history.replaceState;
|
|
214
|
+
history.replaceState = (...args: Parameters<typeof history.replaceState>) => {
|
|
215
|
+
originalReplaceState(...args);
|
|
216
|
+
emitNav('replaceState');
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
this.popstateHandler = () => emitNav('popstate');
|
|
220
|
+
window.addEventListener('popstate', this.popstateHandler);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private restoreRouting(): void {
|
|
224
|
+
if (this.originalPushState) {
|
|
225
|
+
history.pushState = this.originalPushState;
|
|
226
|
+
this.originalPushState = null;
|
|
227
|
+
}
|
|
228
|
+
if (this.originalReplaceState) {
|
|
229
|
+
history.replaceState = this.originalReplaceState;
|
|
230
|
+
this.originalReplaceState = null;
|
|
231
|
+
}
|
|
232
|
+
if (this.popstateHandler) {
|
|
233
|
+
window.removeEventListener('popstate', this.popstateHandler);
|
|
234
|
+
this.popstateHandler = null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private restoreNetwork(): void {
|
|
239
|
+
if (this.originalFetch) {
|
|
240
|
+
globalThis.fetch = this.originalFetch;
|
|
241
|
+
this.originalFetch = null;
|
|
242
|
+
}
|
|
243
|
+
if (this.originalXhrOpen) {
|
|
244
|
+
XMLHttpRequest.prototype.open = this.originalXhrOpen;
|
|
245
|
+
this.originalXhrOpen = null;
|
|
246
|
+
}
|
|
247
|
+
if (this.originalXhrSend) {
|
|
248
|
+
XMLHttpRequest.prototype.send = this.originalXhrSend;
|
|
249
|
+
this.originalXhrSend = null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
stop(): void {
|
|
254
|
+
if (this.state !== RecorderState.Recording) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (this.visibilityHandler) {
|
|
258
|
+
document.removeEventListener('visibilitychange', this.visibilityHandler);
|
|
259
|
+
this.visibilityHandler = null;
|
|
260
|
+
}
|
|
261
|
+
this.restoreNetwork();
|
|
262
|
+
this.restoreRouting();
|
|
263
|
+
this.engine.stop();
|
|
264
|
+
this.state = RecorderState.Stopped;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -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,64 @@
|
|
|
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
|
+
|
|
60
|
+
export enum RecorderState {
|
|
61
|
+
Idle = 'idle',
|
|
62
|
+
Recording = 'recording',
|
|
63
|
+
Stopped = 'stopped',
|
|
64
|
+
}
|