@spotify-confidence/csr-recorder 0.17.15 → 0.17.16

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,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.17.16](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.15...csr-recorder-v0.17.16) (2026-08-28)
4
+
5
+
6
+ ### 🐛 Bug Fixes
7
+
8
+ * 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))
9
+
10
+
11
+ ### ✨ New Features
12
+
13
+ * record clipboard actions ([#445](https://github.com/spotify/confidence-sdk-js/issues/445)) ([7a4aed4](https://github.com/spotify/confidence-sdk-js/commit/7a4aed4bada3b38de513f50aad4224e54488bbbb))
14
+
15
+
16
+ ### Dependencies
17
+
18
+ * The following workspace dependencies were updated
19
+ * dependencies
20
+ * @spotify-confidence/csr-common bumped to 0.18.8
21
+
3
22
  ## [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
23
 
5
24
 
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";
@@ -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: "csr/blocked-element-labels@1",
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: "csr/clipboard@1",
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
@@ -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 = [clickModifiersPlugin()];
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
1
  import { RecordingEventType } 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";
@@ -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: "csr/blocked-element-labels@1",
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: "csr/clipboard@1",
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
@@ -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 = [clickModifiersPlugin()];
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.15",
4
+ "version": "0.17.16",
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.7",
38
+ "@spotify-confidence/csr-common": "0.18.8",
39
39
  "rrweb": "^2.0.1"
40
40
  },
41
41
  "module": "./dist/index.js",
@@ -55,7 +55,56 @@ describe('RrwebEngine', () => {
55
55
 
56
56
  it('applies default blockSelector when blockSelectors is absent', () => {
57
57
  new RrwebEngine().start({}, () => {});
58
- expect(recordSpy.mock.calls[0][0].blockSelector).toBe('[data-csr-block]');
58
+ expect(recordSpy.mock.calls[0][0].blockSelector).toBe('[data-csr-block],video');
59
+ });
60
+
61
+ it('rebuilds blocked elements as labelled inert placeholders', () => {
62
+ new RrwebEngine().start({}, () => {});
63
+ const plugin = recordSpy.mock.calls[0][0].plugins.find(
64
+ ({ name }: { name: string }) => name === 'csr/blocked-element-labels@1',
65
+ );
66
+ const event = {
67
+ type: 2,
68
+ timestamp: 1,
69
+ data: {
70
+ node: {
71
+ type: 0,
72
+ id: 1,
73
+ childNodes: [
74
+ {
75
+ type: 2,
76
+ id: 2,
77
+ tagName: 'video',
78
+ attributes: { rr_width: '640px', rr_height: '360px' },
79
+ childNodes: [],
80
+ },
81
+ ],
82
+ },
83
+ },
84
+ };
85
+
86
+ expect(plugin.eventProcessor(event)).toEqual({
87
+ ...event,
88
+ data: {
89
+ node: {
90
+ type: 0,
91
+ id: 1,
92
+ childNodes: [
93
+ {
94
+ type: 2,
95
+ id: 2,
96
+ tagName: 'div',
97
+ attributes: {
98
+ rr_width: '640px',
99
+ rr_height: '360px',
100
+ 'data-csr-blocked-element': 'video',
101
+ },
102
+ childNodes: [],
103
+ },
104
+ ],
105
+ },
106
+ },
107
+ });
59
108
  });
60
109
 
61
110
  it('throttles mousemove to 100ms and records only last input value', () => {
@@ -69,6 +118,39 @@ describe('RrwebEngine', () => {
69
118
  expect(recordSpy.mock.calls[0][0].slimDOMOptions).toBe('all');
70
119
  });
71
120
 
121
+ it('records copy, cut, and paste actions without reading clipboard contents', () => {
122
+ new RrwebEngine().start({}, () => {});
123
+ const plugin = recordSpy.mock.calls[0][0].plugins.find(({ name }: { name: string }) => name === 'csr/clipboard@1');
124
+ const getId = vi.fn().mockReturnValue(42);
125
+ plugin.getMirror({ nodeMirror: { getId } });
126
+ const callback = vi.fn();
127
+ const removeObserver = plugin.observer(callback, window);
128
+ const input = document.createElement('input');
129
+ document.body.appendChild(input);
130
+
131
+ for (const action of ['copy', 'cut', 'paste']) {
132
+ const event = new Event(action, { bubbles: true });
133
+ Object.defineProperty(event, 'clipboardData', {
134
+ get: () => {
135
+ throw new Error('clipboard contents must not be read');
136
+ },
137
+ });
138
+ expect(() => input.dispatchEvent(event)).not.toThrow();
139
+ }
140
+
141
+ expect(callback.mock.calls.map(([payload]) => payload)).toEqual([
142
+ { action: 'copy', targetId: 42 },
143
+ { action: 'cut', targetId: 42 },
144
+ { action: 'paste', targetId: 42 },
145
+ ]);
146
+ expect(getId).toHaveBeenCalledTimes(3);
147
+
148
+ removeObserver();
149
+ input.dispatchEvent(new Event('paste', { bubbles: true }));
150
+ expect(callback).toHaveBeenCalledTimes(3);
151
+ input.remove();
152
+ });
153
+
72
154
  it('keeps native click modifiers through a browser microtask checkpoint', async () => {
73
155
  new RrwebEngine().start({}, () => {});
74
156
  const plugin = recordSpy.mock.calls[0][0].plugins.find(
@@ -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: 'csr/blocked-element-labels@1',
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: 'csr/clipboard@1',
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
@@ -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;
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
  /**