@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/package.json CHANGED
@@ -1 +1,49 @@
1
- {"name":"@spotify-confidence/csr-recorder","version":"0.0.0","description":"Placeholder"}
1
+ {
2
+ "name": "@spotify-confidence/csr-recorder",
3
+ "license": "Apache-2.0",
4
+ "version": "0.17.2",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/spotify/confidence-sdk-js.git",
8
+ "directory": "csr/csr-recorder"
9
+ },
10
+ "type": "module",
11
+ "main": "./dist/index.cjs",
12
+ "types": "./dist/index.d.ts",
13
+ "files": [
14
+ "dist",
15
+ "src"
16
+ ],
17
+ "scripts": {
18
+ "build": "yarn run -T tsdown",
19
+ "typecheck": "tsc --noEmit"
20
+ },
21
+ "publishConfig": {
22
+ "registry": "https://registry.npmjs.org/",
23
+ "access": "public",
24
+ "type": "module",
25
+ "main": "./dist/index.cjs",
26
+ "module": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js",
32
+ "require": "./dist/index.cjs"
33
+ }
34
+ }
35
+ },
36
+ "dependencies": {
37
+ "@rrweb/rrweb-plugin-console-record": "^2.0.0-alpha.20",
38
+ "@spotify-confidence/csr-common": "^0.17.2",
39
+ "rrweb": "^2.0.0-alpha.20"
40
+ },
41
+ "module": "./dist/index.js",
42
+ "exports": {
43
+ ".": {
44
+ "types": "./dist/index.d.ts",
45
+ "import": "./dist/index.js",
46
+ "require": "./dist/index.cjs"
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,11 @@
1
+ import { RecordingEvent } from '@spotify-confidence/csr-common';
2
+ import { RecordingConfig } from '../types';
3
+
4
+ /**
5
+ * Abstraction over the underlying recording library (rrweb, custom, etc.).
6
+ * The Recorder class depends on this interface — never on rrweb directly.
7
+ */
8
+ export interface RecordingEngine {
9
+ start(config: RecordingConfig, onEvent: (event: RecordingEvent) => void): void;
10
+ stop(): void;
11
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { RrwebEngine } from './rrweb-engine';
3
+
4
+ const recordSpy = vi.fn().mockReturnValue(() => {});
5
+
6
+ vi.mock('rrweb', () => ({
7
+ record: (opts: unknown) => recordSpy(opts),
8
+ }));
9
+
10
+ describe('RrwebEngine', () => {
11
+ beforeEach(() => recordSpy.mockClear());
12
+
13
+ it('defaults maskAllInputs=true when maskInputs is omitted', () => {
14
+ new RrwebEngine().start({}, () => {});
15
+ expect(recordSpy.mock.calls[0][0].maskAllInputs).toBe(true);
16
+ });
17
+
18
+ it('forwards maskInputs=false to maskAllInputs', () => {
19
+ new RrwebEngine().start({ maskInputs: false }, () => {});
20
+ expect(recordSpy.mock.calls[0][0].maskAllInputs).toBe(false);
21
+ });
22
+
23
+ it('joins maskSelectors with `,` for maskTextSelector', () => {
24
+ new RrwebEngine().start({ maskSelectors: ['.private', '[data-pii]'] }, () => {});
25
+ expect(recordSpy.mock.calls[0][0].maskTextSelector).toBe('.private,[data-pii]');
26
+ });
27
+
28
+ it('omits maskTextSelector when maskSelectors is explicitly empty', () => {
29
+ new RrwebEngine().start({ maskSelectors: [] }, () => {});
30
+ expect(recordSpy.mock.calls[0][0]).not.toHaveProperty('maskTextSelector');
31
+ });
32
+
33
+ it('applies default maskTextSelector when maskSelectors is absent', () => {
34
+ new RrwebEngine().start({}, () => {});
35
+ expect(recordSpy.mock.calls[0][0].maskTextSelector).toBe('[data-csr-mask]');
36
+ });
37
+
38
+ it('joins blockSelectors with `,` for blockSelector', () => {
39
+ new RrwebEngine().start({ blockSelectors: ['video', '.third-party'] }, () => {});
40
+ expect(recordSpy.mock.calls[0][0].blockSelector).toBe('video,.third-party');
41
+ });
42
+
43
+ it('omits blockSelector when blockSelectors is explicitly empty', () => {
44
+ new RrwebEngine().start({ blockSelectors: [] }, () => {});
45
+ expect(recordSpy.mock.calls[0][0]).not.toHaveProperty('blockSelector');
46
+ });
47
+
48
+ it('applies default blockSelector when blockSelectors is absent', () => {
49
+ new RrwebEngine().start({}, () => {});
50
+ expect(recordSpy.mock.calls[0][0].blockSelector).toBe('[data-csr-block]');
51
+ });
52
+
53
+ it('throttles mousemove to 100ms and records only last input value', () => {
54
+ new RrwebEngine().start({}, () => {});
55
+ const opts = recordSpy.mock.calls[0][0];
56
+ expect(opts.sampling).toEqual({ mousemove: 100, input: 'last' });
57
+ });
58
+
59
+ it('enables slimDOMOptions to strip head noise', () => {
60
+ new RrwebEngine().start({}, () => {});
61
+ expect(recordSpy.mock.calls[0][0].slimDOMOptions).toBe('all');
62
+ });
63
+ });
@@ -0,0 +1,49 @@
1
+ import { RecordingEvent, type ConsoleLogLevel } from '@spotify-confidence/csr-common';
2
+ import { RecordingConfig, DEFAULT_MASK_SELECTORS, DEFAULT_BLOCK_SELECTORS } from '../types';
3
+ import { RecordingEngine } from './index';
4
+ import { record } from 'rrweb';
5
+ import { getRecordConsolePlugin } from '@rrweb/rrweb-plugin-console-record';
6
+
7
+ const ALL_CONSOLE_LEVELS: ConsoleLogLevel[] = ['log', 'warn', 'error', 'debug', 'info'];
8
+
9
+ /**
10
+ * rrweb adapter — bundles rrweb so consumers don't take a peer-dep on it.
11
+ */
12
+ export class RrwebEngine implements RecordingEngine {
13
+ private stopFn: (() => void) | null = null;
14
+
15
+ start(config: RecordingConfig, onEvent: (event: RecordingEvent) => void): void {
16
+ const maskSelectors = config.maskSelectors ?? DEFAULT_MASK_SELECTORS;
17
+ const blockSelectors = config.blockSelectors ?? DEFAULT_BLOCK_SELECTORS;
18
+
19
+ const plugins = [];
20
+ const { captureConsoleLogs } = config;
21
+ if (captureConsoleLogs) {
22
+ const levels = captureConsoleLogs === true ? ALL_CONSOLE_LEVELS : captureConsoleLogs.levels;
23
+ if (levels.length > 0) {
24
+ plugins.push(getRecordConsolePlugin({ level: levels }));
25
+ }
26
+ }
27
+
28
+ this.stopFn =
29
+ record({
30
+ emit: event => {
31
+ onEvent(event as unknown as RecordingEvent);
32
+ },
33
+ maskAllInputs: config.maskInputs ?? true,
34
+ ...(maskSelectors.length ? { maskTextSelector: maskSelectors.join(',') } : {}),
35
+ ...(blockSelectors.length ? { blockSelector: blockSelectors.join(',') } : {}),
36
+ ...(plugins.length ? { plugins } : {}),
37
+ sampling: {
38
+ mousemove: 100,
39
+ input: 'last',
40
+ },
41
+ slimDOMOptions: 'all',
42
+ }) ?? null;
43
+ }
44
+
45
+ stop(): void {
46
+ this.stopFn?.();
47
+ this.stopFn = null;
48
+ }
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export { Recorder } from './recorder';
2
+ export { type RecordingEngine } from './engine';
3
+ export { RrwebEngine } from './engine/rrweb-engine';
4
+ export {
5
+ type RecorderOptions,
6
+ type RecordingConfig,
7
+ RecorderState,
8
+ DEFAULT_MASK_SELECTORS,
9
+ DEFAULT_BLOCK_SELECTORS,
10
+ } from './types';
11
+ export { record } from './start-recording';
@@ -0,0 +1,162 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
+ import { RecordingEvent, RecordingEventType, type RouteChangePluginData } from '@spotify-confidence/csr-common';
4
+ import { Recorder } from './recorder';
5
+ import { RecordingEngine } from './engine';
6
+
7
+ class MockEngine implements RecordingEngine {
8
+ private onEvent: ((event: RecordingEvent) => void) | null = null;
9
+ startCalled = false;
10
+ stopCalled = false;
11
+
12
+ start(_config: unknown, onEvent: (event: RecordingEvent) => void): void {
13
+ this.startCalled = true;
14
+ this.onEvent = onEvent;
15
+ }
16
+
17
+ stop(): void {
18
+ this.stopCalled = true;
19
+ this.onEvent = null;
20
+ }
21
+
22
+ emit(event: RecordingEvent): void {
23
+ this.onEvent?.(event);
24
+ }
25
+ }
26
+
27
+ function routeChangeEvents(onEvent: ReturnType<typeof vi.fn>) {
28
+ return (onEvent.mock.calls as [RecordingEvent][])
29
+ .map(([e]) => e)
30
+ .filter(e => e.type === RecordingEventType.Plugin && (e.data as RouteChangePluginData).plugin === 'csr:routeChange')
31
+ .map(e => (e.data as RouteChangePluginData).payload);
32
+ }
33
+
34
+ describe('Recorder route change capture', () => {
35
+ let originalPushState: typeof history.pushState;
36
+ let originalReplaceState: typeof history.replaceState;
37
+
38
+ beforeEach(() => {
39
+ originalPushState = history.pushState;
40
+ originalReplaceState = history.replaceState;
41
+ });
42
+
43
+ afterEach(() => {
44
+ history.pushState = originalPushState;
45
+ history.replaceState = originalReplaceState;
46
+ });
47
+
48
+ it('patches history by default', () => {
49
+ const engine = new MockEngine();
50
+ const recorder = new Recorder({ engine, onEvent: vi.fn() });
51
+ recorder.start();
52
+ expect(history.pushState).not.toBe(originalPushState);
53
+ recorder.stop();
54
+ });
55
+
56
+ it('does not patch history when captureRouteChanges is false', () => {
57
+ const engine = new MockEngine();
58
+ const recorder = new Recorder({ engine, onEvent: vi.fn() });
59
+ recorder.start({ captureRouteChanges: false });
60
+ expect(history.pushState).toBe(originalPushState);
61
+ recorder.stop();
62
+ });
63
+
64
+ it('restores history on stop', () => {
65
+ const engine = new MockEngine();
66
+ const recorder = new Recorder({ engine, onEvent: vi.fn() });
67
+ recorder.start();
68
+ recorder.stop();
69
+ expect(history.pushState).toBe(originalPushState);
70
+ expect(history.replaceState).toBe(originalReplaceState);
71
+ });
72
+
73
+ it('emits a Plugin event for pushState', () => {
74
+ const engine = new MockEngine();
75
+ const onEvent = vi.fn();
76
+ const recorder = new Recorder({ engine, onEvent });
77
+ recorder.start();
78
+
79
+ history.pushState({}, '', '/new-page');
80
+
81
+ const events = routeChangeEvents(onEvent);
82
+ expect(events).toHaveLength(1);
83
+ expect(events[0].to).toBe('/new-page');
84
+ expect(events[0].trigger).toBe('pushState');
85
+
86
+ recorder.stop();
87
+ });
88
+
89
+ it('emits a Plugin event for replaceState', () => {
90
+ const engine = new MockEngine();
91
+ const onEvent = vi.fn();
92
+ const recorder = new Recorder({ engine, onEvent });
93
+ recorder.start();
94
+
95
+ history.replaceState({}, '', '/replaced');
96
+
97
+ const events = routeChangeEvents(onEvent);
98
+ expect(events).toHaveLength(1);
99
+ expect(events[0].to).toBe('/replaced');
100
+ expect(events[0].trigger).toBe('replaceState');
101
+
102
+ recorder.stop();
103
+ });
104
+
105
+ it('does not emit when URL is unchanged', () => {
106
+ const engine = new MockEngine();
107
+ const onEvent = vi.fn();
108
+ const recorder = new Recorder({ engine, onEvent });
109
+ recorder.start();
110
+
111
+ const currentPath = window.location.pathname;
112
+ history.replaceState({}, '', currentPath);
113
+
114
+ const events = routeChangeEvents(onEvent);
115
+ expect(events).toHaveLength(0);
116
+
117
+ recorder.stop();
118
+ });
119
+
120
+ it('emits for popstate events', () => {
121
+ const engine = new MockEngine();
122
+ const onEvent = vi.fn();
123
+ const recorder = new Recorder({ engine, onEvent });
124
+ recorder.start();
125
+
126
+ history.pushState({}, '', '/page-a');
127
+ history.pushState({}, '', '/page-b');
128
+
129
+ const prePopEvents = routeChangeEvents(onEvent);
130
+ expect(prePopEvents).toHaveLength(2);
131
+
132
+ history.back();
133
+ // happy-dom fires popstate synchronously on history.back()
134
+ window.dispatchEvent(new PopStateEvent('popstate'));
135
+
136
+ const allEvents = routeChangeEvents(onEvent);
137
+ const popstateEvents = allEvents.filter(e => e.trigger === 'popstate');
138
+ expect(popstateEvents.length).toBeGreaterThanOrEqual(1);
139
+
140
+ recorder.stop();
141
+ });
142
+
143
+ it('removes popstate listener on stop', () => {
144
+ const addSpy = vi.spyOn(window, 'addEventListener');
145
+ const removeSpy = vi.spyOn(window, 'removeEventListener');
146
+
147
+ const engine = new MockEngine();
148
+ const recorder = new Recorder({ engine, onEvent: vi.fn() });
149
+ recorder.start();
150
+
151
+ const popstateAdds = addSpy.mock.calls.filter(([t]) => t === 'popstate');
152
+ expect(popstateAdds.length).toBe(1);
153
+
154
+ recorder.stop();
155
+
156
+ const popstateRemoves = removeSpy.mock.calls.filter(([t]) => t === 'popstate');
157
+ expect(popstateRemoves.length).toBe(1);
158
+
159
+ addSpy.mockRestore();
160
+ removeSpy.mockRestore();
161
+ });
162
+ });
@@ -0,0 +1,207 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { RecordingEvent, RecordingEventType, type NetworkRequestPluginData } from '@spotify-confidence/csr-common';
3
+ import { Recorder } from './recorder';
4
+ import { RecordingEngine } from './engine';
5
+ import { RecorderState } from './types';
6
+
7
+ function makeEvent(timestamp: number): RecordingEvent {
8
+ return { type: RecordingEventType.Meta, timestamp, data: {} };
9
+ }
10
+
11
+ class MockEngine implements RecordingEngine {
12
+ private onEvent: ((event: RecordingEvent) => void) | null = null;
13
+ startCalled = false;
14
+ stopCalled = false;
15
+
16
+ start(_config: unknown, onEvent: (event: RecordingEvent) => void): void {
17
+ this.startCalled = true;
18
+ this.onEvent = onEvent;
19
+ }
20
+
21
+ stop(): void {
22
+ this.stopCalled = true;
23
+ this.onEvent = null;
24
+ }
25
+
26
+ emit(event: RecordingEvent): void {
27
+ this.onEvent?.(event);
28
+ }
29
+ }
30
+
31
+ describe('Recorder', () => {
32
+ it('starts in Idle state', () => {
33
+ const engine = new MockEngine();
34
+ const recorder = new Recorder({ engine, onEvent: vi.fn() });
35
+ expect(recorder.currentState).toBe(RecorderState.Idle);
36
+ });
37
+
38
+ it('transitions to Recording on start', () => {
39
+ const engine = new MockEngine();
40
+ const recorder = new Recorder({ engine, onEvent: vi.fn() });
41
+ recorder.start();
42
+ expect(recorder.currentState).toBe(RecorderState.Recording);
43
+ expect(engine.startCalled).toBe(true);
44
+ });
45
+
46
+ it('transitions to Stopped on stop', () => {
47
+ const engine = new MockEngine();
48
+ const recorder = new Recorder({ engine, onEvent: vi.fn() });
49
+ recorder.start();
50
+ recorder.stop();
51
+ expect(recorder.currentState).toBe(RecorderState.Stopped);
52
+ expect(engine.stopCalled).toBe(true);
53
+ });
54
+
55
+ it('ignores duplicate start calls', () => {
56
+ const engine = new MockEngine();
57
+ const recorder = new Recorder({ engine, onEvent: vi.fn() });
58
+ recorder.start();
59
+ recorder.start();
60
+ expect(recorder.currentState).toBe(RecorderState.Recording);
61
+ });
62
+
63
+ it('ignores stop when not recording', () => {
64
+ const engine = new MockEngine();
65
+ const recorder = new Recorder({ engine, onEvent: vi.fn() });
66
+ recorder.stop();
67
+ expect(recorder.currentState).toBe(RecorderState.Idle);
68
+ expect(engine.stopCalled).toBe(false);
69
+ });
70
+
71
+ it('passes each event to the onEvent callback', () => {
72
+ const engine = new MockEngine();
73
+ const onEvent = vi.fn();
74
+ const recorder = new Recorder({ engine, onEvent });
75
+ recorder.start();
76
+
77
+ engine.emit(makeEvent(1));
78
+ engine.emit(makeEvent(2));
79
+
80
+ expect(onEvent).toHaveBeenCalledTimes(2);
81
+ expect(onEvent).toHaveBeenNthCalledWith(1, makeEvent(1));
82
+ expect(onEvent).toHaveBeenNthCalledWith(2, makeEvent(2));
83
+ });
84
+ });
85
+
86
+ describe('Recorder network request capture', () => {
87
+ const originalFetch = globalThis.fetch;
88
+
89
+ afterEach(() => {
90
+ globalThis.fetch = originalFetch;
91
+ });
92
+
93
+ it('does not patch fetch by default', () => {
94
+ const engine = new MockEngine();
95
+ const recorder = new Recorder({ engine, onEvent: vi.fn() });
96
+ recorder.start();
97
+ expect(globalThis.fetch).toBe(originalFetch);
98
+ recorder.stop();
99
+ });
100
+
101
+ it('patches fetch when captureNetworkRequests is true', () => {
102
+ const engine = new MockEngine();
103
+ const recorder = new Recorder({ engine, onEvent: vi.fn() });
104
+ recorder.start({ captureNetworkRequests: true });
105
+ expect(globalThis.fetch).not.toBe(originalFetch);
106
+ recorder.stop();
107
+ });
108
+
109
+ it('restores fetch on stop', () => {
110
+ const engine = new MockEngine();
111
+ const recorder = new Recorder({ engine, onEvent: vi.fn() });
112
+ recorder.start({ captureNetworkRequests: true });
113
+ recorder.stop();
114
+ expect(globalThis.fetch).toBe(originalFetch);
115
+ });
116
+
117
+ it('emits a Plugin event for a successful fetch', async () => {
118
+ const mockResponse = new Response('ok', {
119
+ status: 200,
120
+ headers: { 'content-length': '2' },
121
+ });
122
+ globalThis.fetch = vi.fn().mockResolvedValue(mockResponse);
123
+
124
+ const engine = new MockEngine();
125
+ const onEvent = vi.fn();
126
+ const recorder = new Recorder({ engine, onEvent });
127
+ recorder.start({ captureNetworkRequests: true });
128
+
129
+ await globalThis.fetch('https://api.example.com/data');
130
+
131
+ const pluginEvents = onEvent.mock.calls
132
+ .map(([e]: [RecordingEvent]) => e)
133
+ .filter(e => e.type === RecordingEventType.Plugin);
134
+ expect(pluginEvents).toHaveLength(1);
135
+ const data = pluginEvents[0].data as NetworkRequestPluginData;
136
+ expect(data.plugin).toBe('csr:networkRequest');
137
+ expect(data.payload.initiator).toBe('fetch');
138
+ expect(data.payload.method).toBe('GET');
139
+ expect(data.payload.url).toBe('https://api.example.com/data');
140
+ expect(data.payload.status).toBe(200);
141
+ expect(data.payload.responseSize).toBe(2);
142
+ expect(data.payload.durationMs).toBeGreaterThanOrEqual(0);
143
+
144
+ recorder.stop();
145
+ });
146
+
147
+ it('emits status 0 for a failed fetch', async () => {
148
+ globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('Network error'));
149
+
150
+ const engine = new MockEngine();
151
+ const onEvent = vi.fn();
152
+ const recorder = new Recorder({ engine, onEvent });
153
+ recorder.start({ captureNetworkRequests: true });
154
+
155
+ await globalThis.fetch('https://api.example.com/data').catch(() => {});
156
+
157
+ const pluginEvents = onEvent.mock.calls
158
+ .map(([e]: [RecordingEvent]) => e)
159
+ .filter(e => e.type === RecordingEventType.Plugin);
160
+ expect(pluginEvents).toHaveLength(1);
161
+ const data = pluginEvents[0].data as NetworkRequestPluginData;
162
+ expect(data.payload.status).toBe(0);
163
+
164
+ recorder.stop();
165
+ });
166
+
167
+ it('captures the method from init', async () => {
168
+ const mockResponse = new Response('', { status: 201 });
169
+ globalThis.fetch = vi.fn().mockResolvedValue(mockResponse);
170
+
171
+ const engine = new MockEngine();
172
+ const onEvent = vi.fn();
173
+ const recorder = new Recorder({ engine, onEvent });
174
+ recorder.start({ captureNetworkRequests: true });
175
+
176
+ await globalThis.fetch('https://api.example.com/data', { method: 'post' });
177
+
178
+ const pluginEvents = onEvent.mock.calls
179
+ .map(([e]: [RecordingEvent]) => e)
180
+ .filter(e => e.type === RecordingEventType.Plugin);
181
+ const data = pluginEvents[0].data as NetworkRequestPluginData;
182
+ expect(data.payload.method).toBe('POST');
183
+
184
+ recorder.stop();
185
+ });
186
+
187
+ it('captures the method from a Request object', async () => {
188
+ const mockResponse = new Response('', { status: 200 });
189
+ globalThis.fetch = vi.fn().mockResolvedValue(mockResponse);
190
+
191
+ const engine = new MockEngine();
192
+ const onEvent = vi.fn();
193
+ const recorder = new Recorder({ engine, onEvent });
194
+ recorder.start({ captureNetworkRequests: true });
195
+
196
+ await globalThis.fetch(new Request('https://api.example.com/data', { method: 'DELETE' }));
197
+
198
+ const pluginEvents = onEvent.mock.calls
199
+ .map(([e]: [RecordingEvent]) => e)
200
+ .filter(e => e.type === RecordingEventType.Plugin);
201
+ const data = pluginEvents[0].data as NetworkRequestPluginData;
202
+ expect(data.payload.method).toBe('DELETE');
203
+ expect(data.payload.url).toBe('https://api.example.com/data');
204
+
205
+ recorder.stop();
206
+ });
207
+ });