@spotify-confidence/csr-recorder 0.17.15 → 0.17.17
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 +33 -0
- package/dist/index.cjs +67 -6
- package/dist/index.js +68 -7
- package/package.json +2 -2
- package/src/engine/rrweb-engine.test.ts +88 -3
- package/src/engine/rrweb-engine.ts +83 -3
- package/src/recorder-routing.test.ts +6 -5
- package/src/recorder.test.ts +18 -21
- package/src/recorder.ts +4 -3
- package/src/types.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.17.17](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.16...csr-recorder-v0.17.17) (2026-09-07)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### 🐛 Bug Fixes
|
|
7
|
+
|
|
8
|
+
* stop passive recording frames extending sessions ([#449](https://github.com/spotify/confidence-sdk-js/issues/449)) ([df763a1](https://github.com/spotify/confidence-sdk-js/commit/df763a10d6f16ce5b871530ca3fe8b5c153c16d7))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Dependencies
|
|
12
|
+
|
|
13
|
+
* The following workspace dependencies were updated
|
|
14
|
+
* dependencies
|
|
15
|
+
* @spotify-confidence/csr-common bumped to 0.18.9
|
|
16
|
+
|
|
17
|
+
## [0.17.16](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.15...csr-recorder-v0.17.16) (2026-08-28)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### 🐛 Bug Fixes
|
|
21
|
+
|
|
22
|
+
* replace blocked videos with labelled placeholders ([#444](https://github.com/spotify/confidence-sdk-js/issues/444)) ([d1385b4](https://github.com/spotify/confidence-sdk-js/commit/d1385b4b19e267ba12e1a51890f085bf4746fb16))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### ✨ New Features
|
|
26
|
+
|
|
27
|
+
* record clipboard actions ([#445](https://github.com/spotify/confidence-sdk-js/issues/445)) ([7a4aed4](https://github.com/spotify/confidence-sdk-js/commit/7a4aed4bada3b38de513f50aad4224e54488bbbb))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
### Dependencies
|
|
31
|
+
|
|
32
|
+
* The following workspace dependencies were updated
|
|
33
|
+
* dependencies
|
|
34
|
+
* @spotify-confidence/csr-common bumped to 0.18.8
|
|
35
|
+
|
|
3
36
|
## [0.17.15](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.14...csr-recorder-v0.17.15) (2026-08-20)
|
|
4
37
|
|
|
5
38
|
|
package/dist/index.cjs
CHANGED
|
@@ -2,7 +2,7 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
|
2
2
|
let _spotify_confidence_csr_common = require("@spotify-confidence/csr-common");
|
|
3
3
|
//#region src/types.ts
|
|
4
4
|
const DEFAULT_MASK_SELECTORS = ["[data-csr-mask]"];
|
|
5
|
-
const DEFAULT_BLOCK_SELECTORS = ["[data-csr-block]"];
|
|
5
|
+
const DEFAULT_BLOCK_SELECTORS = ["[data-csr-block]", "video"];
|
|
6
6
|
let RecorderState = /* @__PURE__ */ function(RecorderState) {
|
|
7
7
|
RecorderState["Idle"] = "idle";
|
|
8
8
|
RecorderState["Recording"] = "recording";
|
|
@@ -105,7 +105,7 @@ var Recorder = class Recorder {
|
|
|
105
105
|
if (typeof document !== "undefined") {
|
|
106
106
|
this.visibilityHandler = () => {
|
|
107
107
|
const data = {
|
|
108
|
-
plugin:
|
|
108
|
+
plugin: _spotify_confidence_csr_common.RecordingPluginName.TabVisibility,
|
|
109
109
|
payload: { hidden: document.hidden }
|
|
110
110
|
};
|
|
111
111
|
this.onEvent({
|
|
@@ -127,7 +127,7 @@ var Recorder = class Recorder {
|
|
|
127
127
|
}
|
|
128
128
|
emitNetworkRequest(payload) {
|
|
129
129
|
const data = {
|
|
130
|
-
plugin:
|
|
130
|
+
plugin: _spotify_confidence_csr_common.RecordingPluginName.NetworkRequest,
|
|
131
131
|
payload
|
|
132
132
|
};
|
|
133
133
|
this.onEvent({
|
|
@@ -235,7 +235,7 @@ var Recorder = class Recorder {
|
|
|
235
235
|
const paramTo = this.parameterizeRoute(to);
|
|
236
236
|
if (paramFrom === paramTo) return;
|
|
237
237
|
const data = {
|
|
238
|
-
plugin:
|
|
238
|
+
plugin: _spotify_confidence_csr_common.RecordingPluginName.RouteChange,
|
|
239
239
|
payload: {
|
|
240
240
|
from: paramFrom,
|
|
241
241
|
to: paramTo,
|
|
@@ -11125,6 +11125,63 @@ const ALL_CONSOLE_LEVELS = [
|
|
|
11125
11125
|
"debug",
|
|
11126
11126
|
"info"
|
|
11127
11127
|
];
|
|
11128
|
+
const BLOCKED_ELEMENT_ATTRIBUTE = "data-csr-blocked-element";
|
|
11129
|
+
function labelBlockedElement(node) {
|
|
11130
|
+
if (node.type === 2 && node.tagName !== void 0 && node.attributes !== void 0 && typeof node.attributes.rr_width === "string" && typeof node.attributes.rr_height === "string") {
|
|
11131
|
+
node.attributes[BLOCKED_ELEMENT_ATTRIBUTE] = node.tagName;
|
|
11132
|
+
node.tagName = "div";
|
|
11133
|
+
}
|
|
11134
|
+
node.childNodes?.forEach(labelBlockedElement);
|
|
11135
|
+
}
|
|
11136
|
+
/**
|
|
11137
|
+
* rrweb strips blocked elements down to their dimensions, but retains their
|
|
11138
|
+
* original tag name. Rebuild them as inert divs and keep the tag name as safe
|
|
11139
|
+
* metadata so players can render a useful placeholder label.
|
|
11140
|
+
*/
|
|
11141
|
+
function blockedElementLabelsPlugin() {
|
|
11142
|
+
return {
|
|
11143
|
+
name: _spotify_confidence_csr_common.RecordingPluginName.BlockedElementLabels,
|
|
11144
|
+
options: {},
|
|
11145
|
+
observer: () => () => {},
|
|
11146
|
+
eventProcessor: (event) => {
|
|
11147
|
+
if (event.type === EventType.FullSnapshot) labelBlockedElement(event.data.node);
|
|
11148
|
+
else if (event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.Mutation) event.data.adds.forEach((add) => labelBlockedElement(add.node));
|
|
11149
|
+
return event;
|
|
11150
|
+
}
|
|
11151
|
+
};
|
|
11152
|
+
}
|
|
11153
|
+
/**
|
|
11154
|
+
* Record clipboard actions and their DOM target without reading clipboard
|
|
11155
|
+
* contents. The resulting rrweb Plugin events can explain otherwise
|
|
11156
|
+
* surprising input changes during analysis.
|
|
11157
|
+
*/
|
|
11158
|
+
function clipboardActionsPlugin() {
|
|
11159
|
+
let getId;
|
|
11160
|
+
return {
|
|
11161
|
+
name: _spotify_confidence_csr_common.RecordingPluginName.Clipboard,
|
|
11162
|
+
options: {},
|
|
11163
|
+
getMirror: ({ nodeMirror }) => {
|
|
11164
|
+
getId = (node) => nodeMirror.getId(node);
|
|
11165
|
+
},
|
|
11166
|
+
observer: (callback, win) => {
|
|
11167
|
+
const handlers = [
|
|
11168
|
+
"copy",
|
|
11169
|
+
"cut",
|
|
11170
|
+
"paste"
|
|
11171
|
+
].map((action) => {
|
|
11172
|
+
const handler = (event) => {
|
|
11173
|
+
callback({
|
|
11174
|
+
action,
|
|
11175
|
+
targetId: event.target instanceof win.Node ? getId?.(event.target) ?? -1 : -1
|
|
11176
|
+
});
|
|
11177
|
+
};
|
|
11178
|
+
win.document.addEventListener(action, handler, true);
|
|
11179
|
+
return () => win.document.removeEventListener(action, handler, true);
|
|
11180
|
+
});
|
|
11181
|
+
return () => handlers.forEach((remove) => remove());
|
|
11182
|
+
}
|
|
11183
|
+
};
|
|
11184
|
+
}
|
|
11128
11185
|
/**
|
|
11129
11186
|
* rrweb does not include modifier keys in mouse-interaction events. Capture
|
|
11130
11187
|
* the native click first, then add its safe, non-text metadata to the rrweb
|
|
@@ -11133,7 +11190,7 @@ const ALL_CONSOLE_LEVELS = [
|
|
|
11133
11190
|
function clickModifiersPlugin() {
|
|
11134
11191
|
let pendingClick = null;
|
|
11135
11192
|
return {
|
|
11136
|
-
name:
|
|
11193
|
+
name: _spotify_confidence_csr_common.RecordingPluginName.ClickModifiers,
|
|
11137
11194
|
options: {},
|
|
11138
11195
|
observer: (_callback, win) => {
|
|
11139
11196
|
const onClick = (event) => {
|
|
@@ -11177,7 +11234,11 @@ var RrwebEngine = class {
|
|
|
11177
11234
|
start(config, onEvent) {
|
|
11178
11235
|
const maskSelectors = config.maskSelectors ?? DEFAULT_MASK_SELECTORS;
|
|
11179
11236
|
const blockSelectors = config.blockSelectors ?? DEFAULT_BLOCK_SELECTORS;
|
|
11180
|
-
const plugins = [
|
|
11237
|
+
const plugins = [
|
|
11238
|
+
clickModifiersPlugin(),
|
|
11239
|
+
clipboardActionsPlugin(),
|
|
11240
|
+
blockedElementLabelsPlugin()
|
|
11241
|
+
];
|
|
11181
11242
|
const { captureConsoleLogs } = config;
|
|
11182
11243
|
if (captureConsoleLogs) {
|
|
11183
11244
|
const levels = captureConsoleLogs === true ? ALL_CONSOLE_LEVELS : captureConsoleLogs.levels;
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { RecordingEventType } from "@spotify-confidence/csr-common";
|
|
1
|
+
import { RecordingEventType, RecordingPluginName } from "@spotify-confidence/csr-common";
|
|
2
2
|
//#region src/types.ts
|
|
3
3
|
const DEFAULT_MASK_SELECTORS = ["[data-csr-mask]"];
|
|
4
|
-
const DEFAULT_BLOCK_SELECTORS = ["[data-csr-block]"];
|
|
4
|
+
const DEFAULT_BLOCK_SELECTORS = ["[data-csr-block]", "video"];
|
|
5
5
|
let RecorderState = /* @__PURE__ */ function(RecorderState) {
|
|
6
6
|
RecorderState["Idle"] = "idle";
|
|
7
7
|
RecorderState["Recording"] = "recording";
|
|
@@ -104,7 +104,7 @@ var Recorder = class Recorder {
|
|
|
104
104
|
if (typeof document !== "undefined") {
|
|
105
105
|
this.visibilityHandler = () => {
|
|
106
106
|
const data = {
|
|
107
|
-
plugin:
|
|
107
|
+
plugin: RecordingPluginName.TabVisibility,
|
|
108
108
|
payload: { hidden: document.hidden }
|
|
109
109
|
};
|
|
110
110
|
this.onEvent({
|
|
@@ -126,7 +126,7 @@ var Recorder = class Recorder {
|
|
|
126
126
|
}
|
|
127
127
|
emitNetworkRequest(payload) {
|
|
128
128
|
const data = {
|
|
129
|
-
plugin:
|
|
129
|
+
plugin: RecordingPluginName.NetworkRequest,
|
|
130
130
|
payload
|
|
131
131
|
};
|
|
132
132
|
this.onEvent({
|
|
@@ -234,7 +234,7 @@ var Recorder = class Recorder {
|
|
|
234
234
|
const paramTo = this.parameterizeRoute(to);
|
|
235
235
|
if (paramFrom === paramTo) return;
|
|
236
236
|
const data = {
|
|
237
|
-
plugin:
|
|
237
|
+
plugin: RecordingPluginName.RouteChange,
|
|
238
238
|
payload: {
|
|
239
239
|
from: paramFrom,
|
|
240
240
|
to: paramTo,
|
|
@@ -11124,6 +11124,63 @@ const ALL_CONSOLE_LEVELS = [
|
|
|
11124
11124
|
"debug",
|
|
11125
11125
|
"info"
|
|
11126
11126
|
];
|
|
11127
|
+
const BLOCKED_ELEMENT_ATTRIBUTE = "data-csr-blocked-element";
|
|
11128
|
+
function labelBlockedElement(node) {
|
|
11129
|
+
if (node.type === 2 && node.tagName !== void 0 && node.attributes !== void 0 && typeof node.attributes.rr_width === "string" && typeof node.attributes.rr_height === "string") {
|
|
11130
|
+
node.attributes[BLOCKED_ELEMENT_ATTRIBUTE] = node.tagName;
|
|
11131
|
+
node.tagName = "div";
|
|
11132
|
+
}
|
|
11133
|
+
node.childNodes?.forEach(labelBlockedElement);
|
|
11134
|
+
}
|
|
11135
|
+
/**
|
|
11136
|
+
* rrweb strips blocked elements down to their dimensions, but retains their
|
|
11137
|
+
* original tag name. Rebuild them as inert divs and keep the tag name as safe
|
|
11138
|
+
* metadata so players can render a useful placeholder label.
|
|
11139
|
+
*/
|
|
11140
|
+
function blockedElementLabelsPlugin() {
|
|
11141
|
+
return {
|
|
11142
|
+
name: RecordingPluginName.BlockedElementLabels,
|
|
11143
|
+
options: {},
|
|
11144
|
+
observer: () => () => {},
|
|
11145
|
+
eventProcessor: (event) => {
|
|
11146
|
+
if (event.type === EventType.FullSnapshot) labelBlockedElement(event.data.node);
|
|
11147
|
+
else if (event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.Mutation) event.data.adds.forEach((add) => labelBlockedElement(add.node));
|
|
11148
|
+
return event;
|
|
11149
|
+
}
|
|
11150
|
+
};
|
|
11151
|
+
}
|
|
11152
|
+
/**
|
|
11153
|
+
* Record clipboard actions and their DOM target without reading clipboard
|
|
11154
|
+
* contents. The resulting rrweb Plugin events can explain otherwise
|
|
11155
|
+
* surprising input changes during analysis.
|
|
11156
|
+
*/
|
|
11157
|
+
function clipboardActionsPlugin() {
|
|
11158
|
+
let getId;
|
|
11159
|
+
return {
|
|
11160
|
+
name: RecordingPluginName.Clipboard,
|
|
11161
|
+
options: {},
|
|
11162
|
+
getMirror: ({ nodeMirror }) => {
|
|
11163
|
+
getId = (node) => nodeMirror.getId(node);
|
|
11164
|
+
},
|
|
11165
|
+
observer: (callback, win) => {
|
|
11166
|
+
const handlers = [
|
|
11167
|
+
"copy",
|
|
11168
|
+
"cut",
|
|
11169
|
+
"paste"
|
|
11170
|
+
].map((action) => {
|
|
11171
|
+
const handler = (event) => {
|
|
11172
|
+
callback({
|
|
11173
|
+
action,
|
|
11174
|
+
targetId: event.target instanceof win.Node ? getId?.(event.target) ?? -1 : -1
|
|
11175
|
+
});
|
|
11176
|
+
};
|
|
11177
|
+
win.document.addEventListener(action, handler, true);
|
|
11178
|
+
return () => win.document.removeEventListener(action, handler, true);
|
|
11179
|
+
});
|
|
11180
|
+
return () => handlers.forEach((remove) => remove());
|
|
11181
|
+
}
|
|
11182
|
+
};
|
|
11183
|
+
}
|
|
11127
11184
|
/**
|
|
11128
11185
|
* rrweb does not include modifier keys in mouse-interaction events. Capture
|
|
11129
11186
|
* the native click first, then add its safe, non-text metadata to the rrweb
|
|
@@ -11132,7 +11189,7 @@ const ALL_CONSOLE_LEVELS = [
|
|
|
11132
11189
|
function clickModifiersPlugin() {
|
|
11133
11190
|
let pendingClick = null;
|
|
11134
11191
|
return {
|
|
11135
|
-
name:
|
|
11192
|
+
name: RecordingPluginName.ClickModifiers,
|
|
11136
11193
|
options: {},
|
|
11137
11194
|
observer: (_callback, win) => {
|
|
11138
11195
|
const onClick = (event) => {
|
|
@@ -11176,7 +11233,11 @@ var RrwebEngine = class {
|
|
|
11176
11233
|
start(config, onEvent) {
|
|
11177
11234
|
const maskSelectors = config.maskSelectors ?? DEFAULT_MASK_SELECTORS;
|
|
11178
11235
|
const blockSelectors = config.blockSelectors ?? DEFAULT_BLOCK_SELECTORS;
|
|
11179
|
-
const plugins = [
|
|
11236
|
+
const plugins = [
|
|
11237
|
+
clickModifiersPlugin(),
|
|
11238
|
+
clipboardActionsPlugin(),
|
|
11239
|
+
blockedElementLabelsPlugin()
|
|
11240
|
+
];
|
|
11180
11241
|
const { captureConsoleLogs } = config;
|
|
11181
11242
|
if (captureConsoleLogs) {
|
|
11182
11243
|
const levels = captureConsoleLogs === true ? ALL_CONSOLE_LEVELS : captureConsoleLogs.levels;
|
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.
|
|
4
|
+
"version": "0.17.17",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/spotify/confidence-sdk-js.git",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@rrweb/rrweb-plugin-console-record": "^2.0.1",
|
|
38
|
-
"@spotify-confidence/csr-common": "0.18.
|
|
38
|
+
"@spotify-confidence/csr-common": "0.18.9",
|
|
39
39
|
"rrweb": "^2.0.1"
|
|
40
40
|
},
|
|
41
41
|
"module": "./dist/index.js",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
4
|
+
import { RecordingPluginName } from '@spotify-confidence/csr-common';
|
|
4
5
|
import { RrwebEngine } from './rrweb-engine';
|
|
5
6
|
|
|
6
7
|
const recordSpy = vi.fn().mockReturnValue(() => {});
|
|
@@ -55,7 +56,56 @@ describe('RrwebEngine', () => {
|
|
|
55
56
|
|
|
56
57
|
it('applies default blockSelector when blockSelectors is absent', () => {
|
|
57
58
|
new RrwebEngine().start({}, () => {});
|
|
58
|
-
expect(recordSpy.mock.calls[0][0].blockSelector).toBe('[data-csr-block]');
|
|
59
|
+
expect(recordSpy.mock.calls[0][0].blockSelector).toBe('[data-csr-block],video');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rebuilds blocked elements as labelled inert placeholders', () => {
|
|
63
|
+
new RrwebEngine().start({}, () => {});
|
|
64
|
+
const plugin = recordSpy.mock.calls[0][0].plugins.find(
|
|
65
|
+
({ name }: { name: string }) => name === RecordingPluginName.BlockedElementLabels,
|
|
66
|
+
);
|
|
67
|
+
const event = {
|
|
68
|
+
type: 2,
|
|
69
|
+
timestamp: 1,
|
|
70
|
+
data: {
|
|
71
|
+
node: {
|
|
72
|
+
type: 0,
|
|
73
|
+
id: 1,
|
|
74
|
+
childNodes: [
|
|
75
|
+
{
|
|
76
|
+
type: 2,
|
|
77
|
+
id: 2,
|
|
78
|
+
tagName: 'video',
|
|
79
|
+
attributes: { rr_width: '640px', rr_height: '360px' },
|
|
80
|
+
childNodes: [],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
expect(plugin.eventProcessor(event)).toEqual({
|
|
88
|
+
...event,
|
|
89
|
+
data: {
|
|
90
|
+
node: {
|
|
91
|
+
type: 0,
|
|
92
|
+
id: 1,
|
|
93
|
+
childNodes: [
|
|
94
|
+
{
|
|
95
|
+
type: 2,
|
|
96
|
+
id: 2,
|
|
97
|
+
tagName: 'div',
|
|
98
|
+
attributes: {
|
|
99
|
+
rr_width: '640px',
|
|
100
|
+
rr_height: '360px',
|
|
101
|
+
'data-csr-blocked-element': 'video',
|
|
102
|
+
},
|
|
103
|
+
childNodes: [],
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
});
|
|
59
109
|
});
|
|
60
110
|
|
|
61
111
|
it('throttles mousemove to 100ms and records only last input value', () => {
|
|
@@ -69,10 +119,45 @@ describe('RrwebEngine', () => {
|
|
|
69
119
|
expect(recordSpy.mock.calls[0][0].slimDOMOptions).toBe('all');
|
|
70
120
|
});
|
|
71
121
|
|
|
122
|
+
it('records copy, cut, and paste actions without reading clipboard contents', () => {
|
|
123
|
+
new RrwebEngine().start({}, () => {});
|
|
124
|
+
const plugin = recordSpy.mock.calls[0][0].plugins.find(
|
|
125
|
+
({ name }: { name: string }) => name === RecordingPluginName.Clipboard,
|
|
126
|
+
);
|
|
127
|
+
const getId = vi.fn().mockReturnValue(42);
|
|
128
|
+
plugin.getMirror({ nodeMirror: { getId } });
|
|
129
|
+
const callback = vi.fn();
|
|
130
|
+
const removeObserver = plugin.observer(callback, window);
|
|
131
|
+
const input = document.createElement('input');
|
|
132
|
+
document.body.appendChild(input);
|
|
133
|
+
|
|
134
|
+
for (const action of ['copy', 'cut', 'paste']) {
|
|
135
|
+
const event = new Event(action, { bubbles: true });
|
|
136
|
+
Object.defineProperty(event, 'clipboardData', {
|
|
137
|
+
get: () => {
|
|
138
|
+
throw new Error('clipboard contents must not be read');
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
expect(() => input.dispatchEvent(event)).not.toThrow();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
expect(callback.mock.calls.map(([payload]) => payload)).toEqual([
|
|
145
|
+
{ action: 'copy', targetId: 42 },
|
|
146
|
+
{ action: 'cut', targetId: 42 },
|
|
147
|
+
{ action: 'paste', targetId: 42 },
|
|
148
|
+
]);
|
|
149
|
+
expect(getId).toHaveBeenCalledTimes(3);
|
|
150
|
+
|
|
151
|
+
removeObserver();
|
|
152
|
+
input.dispatchEvent(new Event('paste', { bubbles: true }));
|
|
153
|
+
expect(callback).toHaveBeenCalledTimes(3);
|
|
154
|
+
input.remove();
|
|
155
|
+
});
|
|
156
|
+
|
|
72
157
|
it('keeps native click modifiers through a browser microtask checkpoint', async () => {
|
|
73
158
|
new RrwebEngine().start({}, () => {});
|
|
74
159
|
const plugin = recordSpy.mock.calls[0][0].plugins.find(
|
|
75
|
-
({ name }: { name: string }) => name ===
|
|
160
|
+
({ name }: { name: string }) => name === RecordingPluginName.ClickModifiers,
|
|
76
161
|
);
|
|
77
162
|
const removeObserver = plugin.observer(() => {}, window);
|
|
78
163
|
|
|
@@ -118,7 +203,7 @@ describe('RrwebEngine', () => {
|
|
|
118
203
|
it('does not add stale modifiers to a later click', async () => {
|
|
119
204
|
new RrwebEngine().start({}, () => {});
|
|
120
205
|
const plugin = recordSpy.mock.calls[0][0].plugins.find(
|
|
121
|
-
({ name }: { name: string }) => name ===
|
|
206
|
+
({ name }: { name: string }) => name === RecordingPluginName.ClickModifiers,
|
|
122
207
|
);
|
|
123
208
|
const removeObserver = plugin.observer(() => {}, window);
|
|
124
209
|
document.dispatchEvent(new MouseEvent('click', { metaKey: true }));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ConsoleLogLevel, type RecordingEvent } from '@spotify-confidence/csr-common';
|
|
1
|
+
import { RecordingPluginName, type ConsoleLogLevel, 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';
|
|
@@ -10,6 +10,86 @@ type RrwebPlugin = NonNullable<recordOptions<RecordingEvent>['plugins']>[number]
|
|
|
10
10
|
|
|
11
11
|
type ClickModifiers = Pick<MouseEvent, 'button' | 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'>;
|
|
12
12
|
|
|
13
|
+
const BLOCKED_ELEMENT_ATTRIBUTE = 'data-csr-blocked-element';
|
|
14
|
+
|
|
15
|
+
type SerializedNode = {
|
|
16
|
+
type: number;
|
|
17
|
+
tagName?: string;
|
|
18
|
+
attributes?: Record<string, unknown>;
|
|
19
|
+
childNodes?: SerializedNode[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function labelBlockedElement(node: SerializedNode): void {
|
|
23
|
+
const isBlockedElement =
|
|
24
|
+
node.type === 2 &&
|
|
25
|
+
node.tagName !== undefined &&
|
|
26
|
+
node.attributes !== undefined &&
|
|
27
|
+
typeof node.attributes.rr_width === 'string' &&
|
|
28
|
+
typeof node.attributes.rr_height === 'string';
|
|
29
|
+
|
|
30
|
+
if (isBlockedElement) {
|
|
31
|
+
node.attributes![BLOCKED_ELEMENT_ATTRIBUTE] = node.tagName;
|
|
32
|
+
node.tagName = 'div';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
node.childNodes?.forEach(labelBlockedElement);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* rrweb strips blocked elements down to their dimensions, but retains their
|
|
40
|
+
* original tag name. Rebuild them as inert divs and keep the tag name as safe
|
|
41
|
+
* metadata so players can render a useful placeholder label.
|
|
42
|
+
*/
|
|
43
|
+
function blockedElementLabelsPlugin(): RrwebPlugin {
|
|
44
|
+
return {
|
|
45
|
+
name: RecordingPluginName.BlockedElementLabels,
|
|
46
|
+
options: {},
|
|
47
|
+
observer: () => () => {},
|
|
48
|
+
eventProcessor: event => {
|
|
49
|
+
if (event.type === EventType.FullSnapshot) {
|
|
50
|
+
labelBlockedElement(event.data.node as SerializedNode);
|
|
51
|
+
} else if (event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.Mutation) {
|
|
52
|
+
event.data.adds.forEach(add => labelBlockedElement(add.node as SerializedNode));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return event;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type ClipboardAction = 'copy' | 'cut' | 'paste';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Record clipboard actions and their DOM target without reading clipboard
|
|
64
|
+
* contents. The resulting rrweb Plugin events can explain otherwise
|
|
65
|
+
* surprising input changes during analysis.
|
|
66
|
+
*/
|
|
67
|
+
function clipboardActionsPlugin(): RrwebPlugin {
|
|
68
|
+
let getId: ((node: Node) => number) | undefined;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
name: RecordingPluginName.Clipboard,
|
|
72
|
+
options: {},
|
|
73
|
+
getMirror: ({ nodeMirror }) => {
|
|
74
|
+
getId = node => nodeMirror.getId(node);
|
|
75
|
+
},
|
|
76
|
+
observer: (callback, win) => {
|
|
77
|
+
const actions: ClipboardAction[] = ['copy', 'cut', 'paste'];
|
|
78
|
+
const handlers = actions.map(action => {
|
|
79
|
+
const handler = (event: Event) => {
|
|
80
|
+
const targetId = event.target instanceof win.Node ? getId?.(event.target) ?? -1 : -1;
|
|
81
|
+
callback({ action, targetId });
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
win.document.addEventListener(action, handler, true);
|
|
85
|
+
return () => win.document.removeEventListener(action, handler, true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return () => handlers.forEach(remove => remove());
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
13
93
|
/**
|
|
14
94
|
* rrweb does not include modifier keys in mouse-interaction events. Capture
|
|
15
95
|
* the native click first, then add its safe, non-text metadata to the rrweb
|
|
@@ -19,7 +99,7 @@ function clickModifiersPlugin(): RrwebPlugin {
|
|
|
19
99
|
let pendingClick: ClickModifiers | null = null;
|
|
20
100
|
|
|
21
101
|
return {
|
|
22
|
-
name:
|
|
102
|
+
name: RecordingPluginName.ClickModifiers,
|
|
23
103
|
options: {},
|
|
24
104
|
observer: (_callback, win) => {
|
|
25
105
|
const onClick = (event: Event) => {
|
|
@@ -69,7 +149,7 @@ export class RrwebEngine implements RecordingEngine {
|
|
|
69
149
|
const maskSelectors = config.maskSelectors ?? DEFAULT_MASK_SELECTORS;
|
|
70
150
|
const blockSelectors = config.blockSelectors ?? DEFAULT_BLOCK_SELECTORS;
|
|
71
151
|
|
|
72
|
-
const plugins: RrwebPlugin[] = [clickModifiersPlugin()];
|
|
152
|
+
const plugins: RrwebPlugin[] = [clickModifiersPlugin(), clipboardActionsPlugin(), blockedElementLabelsPlugin()];
|
|
73
153
|
const { captureConsoleLogs } = config;
|
|
74
154
|
if (captureConsoleLogs) {
|
|
75
155
|
const levels = captureConsoleLogs === true ? ALL_CONSOLE_LEVELS : captureConsoleLogs.levels;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
-
import { RecordingEvent, RecordingEventType,
|
|
3
|
+
import { RecordingEvent, RecordingEventType, RecordingPluginName } from '@spotify-confidence/csr-common';
|
|
4
4
|
import { Recorder } from './recorder';
|
|
5
5
|
import { RecordingEngine } from './engine';
|
|
6
6
|
|
|
@@ -32,10 +32,11 @@ class MockEngine implements RecordingEngine {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
function routeChangeEvents(onEvent: ReturnType<typeof vi.fn>) {
|
|
35
|
-
return
|
|
36
|
-
.
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
return onEvent.mock.calls.flatMap(([event]: [RecordingEvent]) =>
|
|
36
|
+
event.type === RecordingEventType.Plugin && event.data.plugin === RecordingPluginName.RouteChange
|
|
37
|
+
? [event.data.payload]
|
|
38
|
+
: [],
|
|
39
|
+
);
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
describe('Recorder route change capture', () => {
|
package/src/recorder.test.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
-
import { RecordingEvent, RecordingEventType,
|
|
2
|
+
import { RecordingEvent, RecordingEventType, RecordingPluginName } from '@spotify-confidence/csr-common';
|
|
3
3
|
import { Recorder } from './recorder';
|
|
4
4
|
import { RecordingEngine } from './engine';
|
|
5
5
|
import { RecorderState } from './types';
|
|
6
6
|
|
|
7
|
+
const networkRequestEvents = (onEvent: ReturnType<typeof vi.fn>) =>
|
|
8
|
+
onEvent.mock.calls.flatMap(([event]: [RecordingEvent]) =>
|
|
9
|
+
event.type === RecordingEventType.Plugin && event.data.plugin === RecordingPluginName.NetworkRequest
|
|
10
|
+
? [event.data]
|
|
11
|
+
: [],
|
|
12
|
+
);
|
|
13
|
+
|
|
7
14
|
function makeEvent(timestamp: number): RecordingEvent {
|
|
8
15
|
return { type: RecordingEventType.Meta, timestamp, data: {} };
|
|
9
16
|
}
|
|
@@ -130,12 +137,10 @@ describe('Recorder network request capture', () => {
|
|
|
130
137
|
|
|
131
138
|
await globalThis.fetch('https://api.example.com/data');
|
|
132
139
|
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
expect(
|
|
137
|
-
const data = pluginEvents[0].data as NetworkRequestPluginData;
|
|
138
|
-
expect(data.plugin).toBe('csr:networkRequest');
|
|
140
|
+
const events = networkRequestEvents(onEvent);
|
|
141
|
+
expect(events).toHaveLength(1);
|
|
142
|
+
const data = events[0];
|
|
143
|
+
expect(data.plugin).toBe(RecordingPluginName.NetworkRequest);
|
|
139
144
|
expect(data.payload.initiator).toBe('fetch');
|
|
140
145
|
expect(data.payload.method).toBe('GET');
|
|
141
146
|
expect(data.payload.url).toBe('https://api.example.com/data');
|
|
@@ -156,11 +161,9 @@ describe('Recorder network request capture', () => {
|
|
|
156
161
|
|
|
157
162
|
await globalThis.fetch('https://api.example.com/data').catch(() => {});
|
|
158
163
|
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
expect(pluginEvents).toHaveLength(1);
|
|
163
|
-
const data = pluginEvents[0].data as NetworkRequestPluginData;
|
|
164
|
+
const events = networkRequestEvents(onEvent);
|
|
165
|
+
expect(events).toHaveLength(1);
|
|
166
|
+
const data = events[0];
|
|
164
167
|
expect(data.payload.status).toBe(0);
|
|
165
168
|
|
|
166
169
|
recorder.stop();
|
|
@@ -177,10 +180,7 @@ describe('Recorder network request capture', () => {
|
|
|
177
180
|
|
|
178
181
|
await globalThis.fetch('https://api.example.com/data', { method: 'post' });
|
|
179
182
|
|
|
180
|
-
const
|
|
181
|
-
.map(([e]: [RecordingEvent]) => e)
|
|
182
|
-
.filter(e => e.type === RecordingEventType.Plugin);
|
|
183
|
-
const data = pluginEvents[0].data as NetworkRequestPluginData;
|
|
183
|
+
const data = networkRequestEvents(onEvent)[0];
|
|
184
184
|
expect(data.payload.method).toBe('POST');
|
|
185
185
|
|
|
186
186
|
recorder.stop();
|
|
@@ -197,10 +197,7 @@ describe('Recorder network request capture', () => {
|
|
|
197
197
|
|
|
198
198
|
await globalThis.fetch(new Request('https://api.example.com/data', { method: 'DELETE' }));
|
|
199
199
|
|
|
200
|
-
const
|
|
201
|
-
.map(([e]: [RecordingEvent]) => e)
|
|
202
|
-
.filter(e => e.type === RecordingEventType.Plugin);
|
|
203
|
-
const data = pluginEvents[0].data as NetworkRequestPluginData;
|
|
200
|
+
const data = networkRequestEvents(onEvent)[0];
|
|
204
201
|
expect(data.payload.method).toBe('DELETE');
|
|
205
202
|
expect(data.payload.url).toBe('https://api.example.com/data');
|
|
206
203
|
|
|
@@ -228,7 +225,7 @@ describe('Recorder network request capture', () => {
|
|
|
228
225
|
}),
|
|
229
226
|
});
|
|
230
227
|
|
|
231
|
-
const data = onEvent
|
|
228
|
+
const data = networkRequestEvents(onEvent)[0];
|
|
232
229
|
expect(data.payload.graphql).toEqual({ operationName: 'GetUser' });
|
|
233
230
|
expect(JSON.stringify(data.payload)).not.toContain('private-user-id');
|
|
234
231
|
|
package/src/recorder.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
RecordingEvent,
|
|
3
3
|
RecordingEventType,
|
|
4
|
+
RecordingPluginName,
|
|
4
5
|
type TabVisibilityPluginData,
|
|
5
6
|
type NetworkRequestPluginData,
|
|
6
7
|
type RouteChangePluginData,
|
|
@@ -54,7 +55,7 @@ export class Recorder {
|
|
|
54
55
|
if (typeof document !== 'undefined') {
|
|
55
56
|
this.visibilityHandler = () => {
|
|
56
57
|
const data: TabVisibilityPluginData = {
|
|
57
|
-
plugin:
|
|
58
|
+
plugin: RecordingPluginName.TabVisibility,
|
|
58
59
|
payload: { hidden: document.hidden },
|
|
59
60
|
};
|
|
60
61
|
this.onEvent({
|
|
@@ -86,7 +87,7 @@ export class Recorder {
|
|
|
86
87
|
|
|
87
88
|
private emitNetworkRequest(payload: NetworkRequestPluginData['payload']): void {
|
|
88
89
|
const data: NetworkRequestPluginData = {
|
|
89
|
-
plugin:
|
|
90
|
+
plugin: RecordingPluginName.NetworkRequest,
|
|
90
91
|
payload,
|
|
91
92
|
};
|
|
92
93
|
this.onEvent({
|
|
@@ -216,7 +217,7 @@ export class Recorder {
|
|
|
216
217
|
const paramTo = this.parameterizeRoute(to);
|
|
217
218
|
if (paramFrom === paramTo) return;
|
|
218
219
|
const data: RouteChangePluginData = {
|
|
219
|
-
plugin:
|
|
220
|
+
plugin: RecordingPluginName.RouteChange,
|
|
220
221
|
payload: { from: paramFrom, to: paramTo, trigger },
|
|
221
222
|
};
|
|
222
223
|
this.onEvent({
|
package/src/types.ts
CHANGED
|
@@ -6,7 +6,7 @@ export interface RecorderOptions {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export const DEFAULT_MASK_SELECTORS: string[] = ['[data-csr-mask]'];
|
|
9
|
-
export const DEFAULT_BLOCK_SELECTORS: string[] = ['[data-csr-block]'];
|
|
9
|
+
export const DEFAULT_BLOCK_SELECTORS: string[] = ['[data-csr-block]', 'video'];
|
|
10
10
|
|
|
11
11
|
export interface RecordingConfig {
|
|
12
12
|
/**
|