@spotify-confidence/csr-recorder 0.17.11 → 0.17.13
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 +28 -0
- package/dist/index.cjs +79 -4
- package/dist/index.js +79 -4
- package/package.json +2 -2
- package/src/engine/rrweb-engine.test.ts +66 -1
- package/src/engine/rrweb-engine.ts +55 -3
- package/src/graphql-request.test.ts +28 -0
- package/src/graphql-request.ts +32 -0
- package/src/recorder.test.ts +28 -0
- package/src/recorder.ts +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.17.13](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.12...csr-recorder-v0.17.13) (2026-08-18)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ✨ New Features
|
|
7
|
+
|
|
8
|
+
* **csr-recorder:** capture click modifier keys ([#435](https://github.com/spotify/confidence-sdk-js/issues/435)) ([a1e7a96](https://github.com/spotify/confidence-sdk-js/commit/a1e7a96996cd2f361f3b44cc924d6b4d5ff0b003))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Dependencies
|
|
12
|
+
|
|
13
|
+
* The following workspace dependencies were updated
|
|
14
|
+
* dependencies
|
|
15
|
+
* @spotify-confidence/csr-common bumped to 0.18.7
|
|
16
|
+
|
|
17
|
+
## [0.17.12](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.11...csr-recorder-v0.17.12) (2026-08-18)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### ✨ New Features
|
|
21
|
+
|
|
22
|
+
* **csr-recorder:** capture GraphQL operation names ([#434](https://github.com/spotify/confidence-sdk-js/issues/434)) ([c4610ef](https://github.com/spotify/confidence-sdk-js/commit/c4610ef2945cecf632a3027b58ec373bc8b562e7))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Dependencies
|
|
26
|
+
|
|
27
|
+
* The following workspace dependencies were updated
|
|
28
|
+
* dependencies
|
|
29
|
+
* @spotify-confidence/csr-common bumped to 0.18.6
|
|
30
|
+
|
|
3
31
|
## [0.17.11](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.10...csr-recorder-v0.17.11) (2026-08-12)
|
|
4
32
|
|
|
5
33
|
|
package/dist/index.cjs
CHANGED
|
@@ -37,6 +37,33 @@ function defaultParameterizeRoute(route) {
|
|
|
37
37
|
}).join("/");
|
|
38
38
|
}
|
|
39
39
|
//#endregion
|
|
40
|
+
//#region src/graphql-request.ts
|
|
41
|
+
function operationNameFromDocument(document) {
|
|
42
|
+
return document.match(/\b(?:query|mutation|subscription)\s+([_A-Za-z][_0-9A-Za-z]*)/)?.[1];
|
|
43
|
+
}
|
|
44
|
+
function operationNameFromBody(body) {
|
|
45
|
+
if (typeof body !== "string") return void 0;
|
|
46
|
+
try {
|
|
47
|
+
const request = JSON.parse(body);
|
|
48
|
+
if (typeof request.operationName === "string" && request.operationName) return request.operationName;
|
|
49
|
+
return typeof request.query === "string" ? operationNameFromDocument(request.query) : void 0;
|
|
50
|
+
} catch (_error) {
|
|
51
|
+
return operationNameFromDocument(body);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function isGraphQLRequestUrl(url) {
|
|
55
|
+
try {
|
|
56
|
+
return new URL(url, globalThis.location?.href).pathname.endsWith("/graphql");
|
|
57
|
+
} catch (_error) {
|
|
58
|
+
return url.split(/[?#]/, 1)[0].endsWith("/graphql");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function extractGraphQLRequestMetadata(url, body) {
|
|
62
|
+
if (!isGraphQLRequestUrl(url)) return void 0;
|
|
63
|
+
const operationName = operationNameFromBody(body);
|
|
64
|
+
return operationName ? { operationName } : void 0;
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
40
67
|
//#region src/recorder.ts
|
|
41
68
|
var Recorder = class Recorder {
|
|
42
69
|
engine;
|
|
@@ -118,6 +145,7 @@ var Recorder = class Recorder {
|
|
|
118
145
|
else if (input instanceof URL) url = input.href;
|
|
119
146
|
else url = String(input);
|
|
120
147
|
const requestSize = init?.body ? new Blob([init.body]).size : void 0;
|
|
148
|
+
const graphql = extractGraphQLRequestMetadata(url, init?.body);
|
|
121
149
|
const start = Date.now();
|
|
122
150
|
return originalFetch.call(globalThis, input, init).then((response) => {
|
|
123
151
|
const contentLength = response.headers.get("content-length");
|
|
@@ -128,7 +156,8 @@ var Recorder = class Recorder {
|
|
|
128
156
|
status: response.status,
|
|
129
157
|
durationMs: Date.now() - start,
|
|
130
158
|
...requestSize !== null && requestSize !== void 0 ? { requestSize } : {},
|
|
131
|
-
...contentLength ? { responseSize: Number(contentLength) } : {}
|
|
159
|
+
...contentLength ? { responseSize: Number(contentLength) } : {},
|
|
160
|
+
...graphql ? { graphql } : {}
|
|
132
161
|
});
|
|
133
162
|
return response;
|
|
134
163
|
}, (error) => {
|
|
@@ -138,7 +167,8 @@ var Recorder = class Recorder {
|
|
|
138
167
|
url,
|
|
139
168
|
status: 0,
|
|
140
169
|
durationMs: Date.now() - start,
|
|
141
|
-
...requestSize !== null && requestSize !== void 0 ? { requestSize } : {}
|
|
170
|
+
...requestSize !== null && requestSize !== void 0 ? { requestSize } : {},
|
|
171
|
+
...graphql ? { graphql } : {}
|
|
142
172
|
});
|
|
143
173
|
throw error;
|
|
144
174
|
});
|
|
@@ -165,6 +195,7 @@ var Recorder = class Recorder {
|
|
|
165
195
|
const method = meta.__csr_method ?? "GET";
|
|
166
196
|
const url = meta.__csr_url ?? "";
|
|
167
197
|
const requestSize = body !== null && body !== void 0 ? new Blob([body]).size : void 0;
|
|
198
|
+
const graphql = extractGraphQLRequestMetadata(url, body);
|
|
168
199
|
const start = Date.now();
|
|
169
200
|
this.addEventListener("loadend", function csrLoadend() {
|
|
170
201
|
const contentLength = this.getResponseHeader("content-length");
|
|
@@ -175,7 +206,8 @@ var Recorder = class Recorder {
|
|
|
175
206
|
status: this.status,
|
|
176
207
|
durationMs: Date.now() - start,
|
|
177
208
|
...requestSize !== null && requestSize !== void 0 ? { requestSize } : {},
|
|
178
|
-
...contentLength ? { responseSize: Number(contentLength) } : {}
|
|
209
|
+
...contentLength ? { responseSize: Number(contentLength) } : {},
|
|
210
|
+
...graphql ? { graphql } : {}
|
|
179
211
|
});
|
|
180
212
|
});
|
|
181
213
|
return originalSend.call(this, body);
|
|
@@ -11083,6 +11115,49 @@ const ALL_CONSOLE_LEVELS = [
|
|
|
11083
11115
|
"info"
|
|
11084
11116
|
];
|
|
11085
11117
|
/**
|
|
11118
|
+
* rrweb does not include modifier keys in mouse-interaction events. Capture
|
|
11119
|
+
* the native click first, then add its safe, non-text metadata to the rrweb
|
|
11120
|
+
* event emitted during the same browser event dispatch.
|
|
11121
|
+
*/
|
|
11122
|
+
function clickModifiersPlugin() {
|
|
11123
|
+
let pendingClick = null;
|
|
11124
|
+
return {
|
|
11125
|
+
name: "csr/click-modifiers@1",
|
|
11126
|
+
options: {},
|
|
11127
|
+
observer: (_callback, win) => {
|
|
11128
|
+
const onClick = (event) => {
|
|
11129
|
+
const click = event;
|
|
11130
|
+
pendingClick = {
|
|
11131
|
+
button: click.button,
|
|
11132
|
+
altKey: click.altKey,
|
|
11133
|
+
ctrlKey: click.ctrlKey,
|
|
11134
|
+
metaKey: click.metaKey,
|
|
11135
|
+
shiftKey: click.shiftKey
|
|
11136
|
+
};
|
|
11137
|
+
queueMicrotask(() => {
|
|
11138
|
+
pendingClick = null;
|
|
11139
|
+
});
|
|
11140
|
+
};
|
|
11141
|
+
win.addEventListener("click", onClick, true);
|
|
11142
|
+
return () => win.removeEventListener("click", onClick, true);
|
|
11143
|
+
},
|
|
11144
|
+
eventProcessor: (event) => {
|
|
11145
|
+
if (pendingClick && event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.MouseInteraction && event.data.type === MouseInteractions.Click) {
|
|
11146
|
+
const click = pendingClick;
|
|
11147
|
+
pendingClick = null;
|
|
11148
|
+
return {
|
|
11149
|
+
...event,
|
|
11150
|
+
data: {
|
|
11151
|
+
...event.data,
|
|
11152
|
+
...click
|
|
11153
|
+
}
|
|
11154
|
+
};
|
|
11155
|
+
}
|
|
11156
|
+
return event;
|
|
11157
|
+
}
|
|
11158
|
+
};
|
|
11159
|
+
}
|
|
11160
|
+
/**
|
|
11086
11161
|
* rrweb adapter — bundles rrweb so consumers don't take a peer-dep on it.
|
|
11087
11162
|
*/
|
|
11088
11163
|
var RrwebEngine = class {
|
|
@@ -11090,7 +11165,7 @@ var RrwebEngine = class {
|
|
|
11090
11165
|
start(config, onEvent) {
|
|
11091
11166
|
const maskSelectors = config.maskSelectors ?? DEFAULT_MASK_SELECTORS;
|
|
11092
11167
|
const blockSelectors = config.blockSelectors ?? DEFAULT_BLOCK_SELECTORS;
|
|
11093
|
-
const plugins = [];
|
|
11168
|
+
const plugins = [clickModifiersPlugin()];
|
|
11094
11169
|
const { captureConsoleLogs } = config;
|
|
11095
11170
|
if (captureConsoleLogs) {
|
|
11096
11171
|
const levels = captureConsoleLogs === true ? ALL_CONSOLE_LEVELS : captureConsoleLogs.levels;
|
package/dist/index.js
CHANGED
|
@@ -36,6 +36,33 @@ function defaultParameterizeRoute(route) {
|
|
|
36
36
|
}).join("/");
|
|
37
37
|
}
|
|
38
38
|
//#endregion
|
|
39
|
+
//#region src/graphql-request.ts
|
|
40
|
+
function operationNameFromDocument(document) {
|
|
41
|
+
return document.match(/\b(?:query|mutation|subscription)\s+([_A-Za-z][_0-9A-Za-z]*)/)?.[1];
|
|
42
|
+
}
|
|
43
|
+
function operationNameFromBody(body) {
|
|
44
|
+
if (typeof body !== "string") return void 0;
|
|
45
|
+
try {
|
|
46
|
+
const request = JSON.parse(body);
|
|
47
|
+
if (typeof request.operationName === "string" && request.operationName) return request.operationName;
|
|
48
|
+
return typeof request.query === "string" ? operationNameFromDocument(request.query) : void 0;
|
|
49
|
+
} catch (_error) {
|
|
50
|
+
return operationNameFromDocument(body);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function isGraphQLRequestUrl(url) {
|
|
54
|
+
try {
|
|
55
|
+
return new URL(url, globalThis.location?.href).pathname.endsWith("/graphql");
|
|
56
|
+
} catch (_error) {
|
|
57
|
+
return url.split(/[?#]/, 1)[0].endsWith("/graphql");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function extractGraphQLRequestMetadata(url, body) {
|
|
61
|
+
if (!isGraphQLRequestUrl(url)) return void 0;
|
|
62
|
+
const operationName = operationNameFromBody(body);
|
|
63
|
+
return operationName ? { operationName } : void 0;
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
39
66
|
//#region src/recorder.ts
|
|
40
67
|
var Recorder = class Recorder {
|
|
41
68
|
engine;
|
|
@@ -117,6 +144,7 @@ var Recorder = class Recorder {
|
|
|
117
144
|
else if (input instanceof URL) url = input.href;
|
|
118
145
|
else url = String(input);
|
|
119
146
|
const requestSize = init?.body ? new Blob([init.body]).size : void 0;
|
|
147
|
+
const graphql = extractGraphQLRequestMetadata(url, init?.body);
|
|
120
148
|
const start = Date.now();
|
|
121
149
|
return originalFetch.call(globalThis, input, init).then((response) => {
|
|
122
150
|
const contentLength = response.headers.get("content-length");
|
|
@@ -127,7 +155,8 @@ var Recorder = class Recorder {
|
|
|
127
155
|
status: response.status,
|
|
128
156
|
durationMs: Date.now() - start,
|
|
129
157
|
...requestSize !== null && requestSize !== void 0 ? { requestSize } : {},
|
|
130
|
-
...contentLength ? { responseSize: Number(contentLength) } : {}
|
|
158
|
+
...contentLength ? { responseSize: Number(contentLength) } : {},
|
|
159
|
+
...graphql ? { graphql } : {}
|
|
131
160
|
});
|
|
132
161
|
return response;
|
|
133
162
|
}, (error) => {
|
|
@@ -137,7 +166,8 @@ var Recorder = class Recorder {
|
|
|
137
166
|
url,
|
|
138
167
|
status: 0,
|
|
139
168
|
durationMs: Date.now() - start,
|
|
140
|
-
...requestSize !== null && requestSize !== void 0 ? { requestSize } : {}
|
|
169
|
+
...requestSize !== null && requestSize !== void 0 ? { requestSize } : {},
|
|
170
|
+
...graphql ? { graphql } : {}
|
|
141
171
|
});
|
|
142
172
|
throw error;
|
|
143
173
|
});
|
|
@@ -164,6 +194,7 @@ var Recorder = class Recorder {
|
|
|
164
194
|
const method = meta.__csr_method ?? "GET";
|
|
165
195
|
const url = meta.__csr_url ?? "";
|
|
166
196
|
const requestSize = body !== null && body !== void 0 ? new Blob([body]).size : void 0;
|
|
197
|
+
const graphql = extractGraphQLRequestMetadata(url, body);
|
|
167
198
|
const start = Date.now();
|
|
168
199
|
this.addEventListener("loadend", function csrLoadend() {
|
|
169
200
|
const contentLength = this.getResponseHeader("content-length");
|
|
@@ -174,7 +205,8 @@ var Recorder = class Recorder {
|
|
|
174
205
|
status: this.status,
|
|
175
206
|
durationMs: Date.now() - start,
|
|
176
207
|
...requestSize !== null && requestSize !== void 0 ? { requestSize } : {},
|
|
177
|
-
...contentLength ? { responseSize: Number(contentLength) } : {}
|
|
208
|
+
...contentLength ? { responseSize: Number(contentLength) } : {},
|
|
209
|
+
...graphql ? { graphql } : {}
|
|
178
210
|
});
|
|
179
211
|
});
|
|
180
212
|
return originalSend.call(this, body);
|
|
@@ -11082,6 +11114,49 @@ const ALL_CONSOLE_LEVELS = [
|
|
|
11082
11114
|
"info"
|
|
11083
11115
|
];
|
|
11084
11116
|
/**
|
|
11117
|
+
* rrweb does not include modifier keys in mouse-interaction events. Capture
|
|
11118
|
+
* the native click first, then add its safe, non-text metadata to the rrweb
|
|
11119
|
+
* event emitted during the same browser event dispatch.
|
|
11120
|
+
*/
|
|
11121
|
+
function clickModifiersPlugin() {
|
|
11122
|
+
let pendingClick = null;
|
|
11123
|
+
return {
|
|
11124
|
+
name: "csr/click-modifiers@1",
|
|
11125
|
+
options: {},
|
|
11126
|
+
observer: (_callback, win) => {
|
|
11127
|
+
const onClick = (event) => {
|
|
11128
|
+
const click = event;
|
|
11129
|
+
pendingClick = {
|
|
11130
|
+
button: click.button,
|
|
11131
|
+
altKey: click.altKey,
|
|
11132
|
+
ctrlKey: click.ctrlKey,
|
|
11133
|
+
metaKey: click.metaKey,
|
|
11134
|
+
shiftKey: click.shiftKey
|
|
11135
|
+
};
|
|
11136
|
+
queueMicrotask(() => {
|
|
11137
|
+
pendingClick = null;
|
|
11138
|
+
});
|
|
11139
|
+
};
|
|
11140
|
+
win.addEventListener("click", onClick, true);
|
|
11141
|
+
return () => win.removeEventListener("click", onClick, true);
|
|
11142
|
+
},
|
|
11143
|
+
eventProcessor: (event) => {
|
|
11144
|
+
if (pendingClick && event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.MouseInteraction && event.data.type === MouseInteractions.Click) {
|
|
11145
|
+
const click = pendingClick;
|
|
11146
|
+
pendingClick = null;
|
|
11147
|
+
return {
|
|
11148
|
+
...event,
|
|
11149
|
+
data: {
|
|
11150
|
+
...event.data,
|
|
11151
|
+
...click
|
|
11152
|
+
}
|
|
11153
|
+
};
|
|
11154
|
+
}
|
|
11155
|
+
return event;
|
|
11156
|
+
}
|
|
11157
|
+
};
|
|
11158
|
+
}
|
|
11159
|
+
/**
|
|
11085
11160
|
* rrweb adapter — bundles rrweb so consumers don't take a peer-dep on it.
|
|
11086
11161
|
*/
|
|
11087
11162
|
var RrwebEngine = class {
|
|
@@ -11089,7 +11164,7 @@ var RrwebEngine = class {
|
|
|
11089
11164
|
start(config, onEvent) {
|
|
11090
11165
|
const maskSelectors = config.maskSelectors ?? DEFAULT_MASK_SELECTORS;
|
|
11091
11166
|
const blockSelectors = config.blockSelectors ?? DEFAULT_BLOCK_SELECTORS;
|
|
11092
|
-
const plugins = [];
|
|
11167
|
+
const plugins = [clickModifiersPlugin()];
|
|
11093
11168
|
const { captureConsoleLogs } = config;
|
|
11094
11169
|
if (captureConsoleLogs) {
|
|
11095
11170
|
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.13",
|
|
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.7",
|
|
39
39
|
"rrweb": "^2.0.1"
|
|
40
40
|
},
|
|
41
41
|
"module": "./dist/index.js",
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
1
3
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
4
|
import { RrwebEngine } from './rrweb-engine';
|
|
3
5
|
|
|
4
6
|
const recordSpy = vi.fn().mockReturnValue(() => {});
|
|
5
7
|
|
|
6
|
-
vi.mock('rrweb',
|
|
8
|
+
vi.mock('rrweb', async importOriginal => ({
|
|
9
|
+
...(await importOriginal<typeof import('rrweb')>()),
|
|
7
10
|
record: (opts: unknown) => recordSpy(opts),
|
|
8
11
|
}));
|
|
9
12
|
|
|
@@ -60,4 +63,66 @@ describe('RrwebEngine', () => {
|
|
|
60
63
|
new RrwebEngine().start({}, () => {});
|
|
61
64
|
expect(recordSpy.mock.calls[0][0].slimDOMOptions).toBe('all');
|
|
62
65
|
});
|
|
66
|
+
|
|
67
|
+
it('adds native click modifier keys to the rrweb click event', () => {
|
|
68
|
+
new RrwebEngine().start({}, () => {});
|
|
69
|
+
const plugin = recordSpy.mock.calls[0][0].plugins.find(
|
|
70
|
+
({ name }: { name: string }) => name === 'csr/click-modifiers@1',
|
|
71
|
+
);
|
|
72
|
+
const removeObserver = plugin.observer(() => {}, window);
|
|
73
|
+
|
|
74
|
+
document.dispatchEvent(
|
|
75
|
+
new MouseEvent('click', {
|
|
76
|
+
button: 0,
|
|
77
|
+
altKey: true,
|
|
78
|
+
ctrlKey: true,
|
|
79
|
+
metaKey: true,
|
|
80
|
+
shiftKey: true,
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(
|
|
85
|
+
plugin.eventProcessor({
|
|
86
|
+
type: 3,
|
|
87
|
+
timestamp: 1,
|
|
88
|
+
data: { source: 2, type: 2, id: 7, x: 10, y: 20 },
|
|
89
|
+
}),
|
|
90
|
+
).toEqual({
|
|
91
|
+
type: 3,
|
|
92
|
+
timestamp: 1,
|
|
93
|
+
data: {
|
|
94
|
+
source: 2,
|
|
95
|
+
type: 2,
|
|
96
|
+
id: 7,
|
|
97
|
+
x: 10,
|
|
98
|
+
y: 20,
|
|
99
|
+
button: 0,
|
|
100
|
+
altKey: true,
|
|
101
|
+
ctrlKey: true,
|
|
102
|
+
metaKey: true,
|
|
103
|
+
shiftKey: true,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
removeObserver();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('does not add stale modifiers to a later click', async () => {
|
|
111
|
+
new RrwebEngine().start({}, () => {});
|
|
112
|
+
const plugin = recordSpy.mock.calls[0][0].plugins.find(
|
|
113
|
+
({ name }: { name: string }) => name === 'csr/click-modifiers@1',
|
|
114
|
+
);
|
|
115
|
+
const removeObserver = plugin.observer(() => {}, window);
|
|
116
|
+
document.dispatchEvent(new MouseEvent('click', { metaKey: true }));
|
|
117
|
+
await Promise.resolve();
|
|
118
|
+
|
|
119
|
+
const event = {
|
|
120
|
+
type: 3,
|
|
121
|
+
timestamp: 1,
|
|
122
|
+
data: { source: 2, type: 2, id: 7 },
|
|
123
|
+
};
|
|
124
|
+
expect(plugin.eventProcessor(event)).toBe(event);
|
|
125
|
+
|
|
126
|
+
removeObserver();
|
|
127
|
+
});
|
|
63
128
|
});
|
|
@@ -1,11 +1,63 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { 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
|
-
import { record } from 'rrweb';
|
|
4
|
+
import { EventType, IncrementalSource, MouseInteractions, record, type recordOptions } from 'rrweb';
|
|
5
5
|
import { getRecordConsolePlugin } from '@rrweb/rrweb-plugin-console-record';
|
|
6
6
|
|
|
7
7
|
const ALL_CONSOLE_LEVELS: ConsoleLogLevel[] = ['log', 'warn', 'error', 'debug', 'info'];
|
|
8
8
|
|
|
9
|
+
type RrwebPlugin = NonNullable<recordOptions<RecordingEvent>['plugins']>[number];
|
|
10
|
+
|
|
11
|
+
type ClickModifiers = Pick<MouseEvent, 'button' | 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* rrweb does not include modifier keys in mouse-interaction events. Capture
|
|
15
|
+
* the native click first, then add its safe, non-text metadata to the rrweb
|
|
16
|
+
* event emitted during the same browser event dispatch.
|
|
17
|
+
*/
|
|
18
|
+
function clickModifiersPlugin(): RrwebPlugin {
|
|
19
|
+
let pendingClick: ClickModifiers | null = null;
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
name: 'csr/click-modifiers@1',
|
|
23
|
+
options: {},
|
|
24
|
+
observer: (_callback, win) => {
|
|
25
|
+
const onClick = (event: Event) => {
|
|
26
|
+
const click = event as MouseEvent;
|
|
27
|
+
pendingClick = {
|
|
28
|
+
button: click.button,
|
|
29
|
+
altKey: click.altKey,
|
|
30
|
+
ctrlKey: click.ctrlKey,
|
|
31
|
+
metaKey: click.metaKey,
|
|
32
|
+
shiftKey: click.shiftKey,
|
|
33
|
+
};
|
|
34
|
+
queueMicrotask(() => {
|
|
35
|
+
pendingClick = null;
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
win.addEventListener('click', onClick, true);
|
|
40
|
+
return () => win.removeEventListener('click', onClick, true);
|
|
41
|
+
},
|
|
42
|
+
eventProcessor: event => {
|
|
43
|
+
if (
|
|
44
|
+
pendingClick &&
|
|
45
|
+
event.type === EventType.IncrementalSnapshot &&
|
|
46
|
+
event.data.source === IncrementalSource.MouseInteraction &&
|
|
47
|
+
event.data.type === MouseInteractions.Click
|
|
48
|
+
) {
|
|
49
|
+
const click = pendingClick;
|
|
50
|
+
pendingClick = null;
|
|
51
|
+
return {
|
|
52
|
+
...event,
|
|
53
|
+
data: { ...event.data, ...click },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return event;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
9
61
|
/**
|
|
10
62
|
* rrweb adapter — bundles rrweb so consumers don't take a peer-dep on it.
|
|
11
63
|
*/
|
|
@@ -16,7 +68,7 @@ export class RrwebEngine implements RecordingEngine {
|
|
|
16
68
|
const maskSelectors = config.maskSelectors ?? DEFAULT_MASK_SELECTORS;
|
|
17
69
|
const blockSelectors = config.blockSelectors ?? DEFAULT_BLOCK_SELECTORS;
|
|
18
70
|
|
|
19
|
-
const plugins = [];
|
|
71
|
+
const plugins: RrwebPlugin[] = [clickModifiersPlugin()];
|
|
20
72
|
const { captureConsoleLogs } = config;
|
|
21
73
|
if (captureConsoleLogs) {
|
|
22
74
|
const levels = captureConsoleLogs === true ? ALL_CONSOLE_LEVELS : captureConsoleLogs.levels;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { extractGraphQLRequestMetadata } from './graphql-request';
|
|
3
|
+
|
|
4
|
+
describe('extractGraphQLRequestMetadata', () => {
|
|
5
|
+
it('uses the explicit operation name', () => {
|
|
6
|
+
const body = JSON.stringify({
|
|
7
|
+
operationName: 'UpdateProfile',
|
|
8
|
+
query: 'mutation UpdateProfile($input: ProfileInput!) { updateProfile(input: $input) { id } }',
|
|
9
|
+
variables: { input: { displayName: 'Private value' } },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
expect(extractGraphQLRequestMetadata('/graphql', body)).toEqual({ operationName: 'UpdateProfile' });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('gets the operation name from the document when it is not explicit', () => {
|
|
16
|
+
const body = JSON.stringify({ query: 'query Viewer { viewer { id email } }' });
|
|
17
|
+
|
|
18
|
+
expect(extractGraphQLRequestMetadata('/graphql', body)).toEqual({ operationName: 'Viewer' });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('does not record anonymous query fields', () => {
|
|
22
|
+
expect(extractGraphQLRequestMetadata('/graphql', '{ viewer { id email } }')).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('ignores requests to other paths', () => {
|
|
26
|
+
expect(extractGraphQLRequestMetadata('/api/query', 'query Viewer { viewer { id } }')).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { GraphQLRequestMetadata } from '@spotify-confidence/csr-common';
|
|
2
|
+
|
|
3
|
+
function operationNameFromDocument(document: string): string | undefined {
|
|
4
|
+
const match = document.match(/\b(?:query|mutation|subscription)\s+([_A-Za-z][_0-9A-Za-z]*)/);
|
|
5
|
+
return match?.[1];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function operationNameFromBody(body: unknown): string | undefined {
|
|
9
|
+
if (typeof body !== 'string') return undefined;
|
|
10
|
+
try {
|
|
11
|
+
const request = JSON.parse(body) as { operationName?: unknown; query?: unknown };
|
|
12
|
+
if (typeof request.operationName === 'string' && request.operationName) return request.operationName;
|
|
13
|
+
return typeof request.query === 'string' ? operationNameFromDocument(request.query) : undefined;
|
|
14
|
+
} catch (_error) {
|
|
15
|
+
return operationNameFromDocument(body);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isGraphQLRequestUrl(url: string): boolean {
|
|
20
|
+
try {
|
|
21
|
+
return new URL(url, globalThis.location?.href).pathname.endsWith('/graphql');
|
|
22
|
+
} catch (_error) {
|
|
23
|
+
return url.split(/[?#]/, 1)[0].endsWith('/graphql');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function extractGraphQLRequestMetadata(url: string, body: unknown): GraphQLRequestMetadata | undefined {
|
|
28
|
+
if (!isGraphQLRequestUrl(url)) return undefined;
|
|
29
|
+
|
|
30
|
+
const operationName = operationNameFromBody(body);
|
|
31
|
+
return operationName ? { operationName } : undefined;
|
|
32
|
+
}
|
package/src/recorder.test.ts
CHANGED
|
@@ -204,4 +204,32 @@ describe('Recorder network request capture', () => {
|
|
|
204
204
|
|
|
205
205
|
recorder.stop();
|
|
206
206
|
});
|
|
207
|
+
|
|
208
|
+
it('captures only the GraphQL operation name', async () => {
|
|
209
|
+
globalThis.fetch = vi.fn().mockResolvedValue(new Response('{}', { status: 500 }));
|
|
210
|
+
|
|
211
|
+
const engine = new MockEngine();
|
|
212
|
+
const onEvent = vi.fn();
|
|
213
|
+
const recorder = new Recorder({ engine, onEvent });
|
|
214
|
+
recorder.start({ captureNetworkRequests: true });
|
|
215
|
+
|
|
216
|
+
await globalThis.fetch('https://api.example.com/graphql', {
|
|
217
|
+
method: 'POST',
|
|
218
|
+
body: JSON.stringify({
|
|
219
|
+
operationName: 'GetUser',
|
|
220
|
+
query: `query GetUser($id: ID!) {
|
|
221
|
+
viewer { id }
|
|
222
|
+
selectedUser: user(id: $id) { ...UserDetails }
|
|
223
|
+
}
|
|
224
|
+
fragment UserDetails on User { name email }`,
|
|
225
|
+
variables: { id: 'private-user-id' },
|
|
226
|
+
}),
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const data = onEvent.mock.calls[0][0].data as NetworkRequestPluginData;
|
|
230
|
+
expect(data.payload.graphql).toEqual({ operationName: 'GetUser' });
|
|
231
|
+
expect(JSON.stringify(data.payload)).not.toContain('private-user-id');
|
|
232
|
+
|
|
233
|
+
recorder.stop();
|
|
234
|
+
});
|
|
207
235
|
});
|
package/src/recorder.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { RecorderOptions, RecorderState, RecordingConfig } from './types';
|
|
10
10
|
import { RecordingEngine } from './engine';
|
|
11
11
|
import { defaultParameterizeRoute } from './route-parameterizer';
|
|
12
|
+
import { extractGraphQLRequestMetadata } from './graphql-request';
|
|
12
13
|
|
|
13
14
|
export class Recorder {
|
|
14
15
|
private readonly engine: RecordingEngine;
|
|
@@ -107,6 +108,7 @@ export class Recorder {
|
|
|
107
108
|
url = String(input);
|
|
108
109
|
}
|
|
109
110
|
const requestSize = init?.body ? new Blob([init.body as BlobPart]).size : undefined;
|
|
111
|
+
const graphql = extractGraphQLRequestMetadata(url, init?.body);
|
|
110
112
|
const start = Date.now();
|
|
111
113
|
|
|
112
114
|
return originalFetch.call(globalThis, input, init).then(
|
|
@@ -120,6 +122,7 @@ export class Recorder {
|
|
|
120
122
|
durationMs: Date.now() - start,
|
|
121
123
|
...(requestSize !== null && requestSize !== undefined ? { requestSize } : {}),
|
|
122
124
|
...(contentLength ? { responseSize: Number(contentLength) } : {}),
|
|
125
|
+
...(graphql ? { graphql } : {}),
|
|
123
126
|
});
|
|
124
127
|
return response;
|
|
125
128
|
},
|
|
@@ -131,6 +134,7 @@ export class Recorder {
|
|
|
131
134
|
status: 0,
|
|
132
135
|
durationMs: Date.now() - start,
|
|
133
136
|
...(requestSize !== null && requestSize !== undefined ? { requestSize } : {}),
|
|
137
|
+
...(graphql ? { graphql } : {}),
|
|
134
138
|
});
|
|
135
139
|
throw error;
|
|
136
140
|
},
|
|
@@ -165,6 +169,7 @@ export class Recorder {
|
|
|
165
169
|
const method = (meta.__csr_method as string) ?? 'GET';
|
|
166
170
|
const url = (meta.__csr_url as string) ?? '';
|
|
167
171
|
const requestSize = body !== null && body !== undefined ? new Blob([body as BlobPart]).size : undefined;
|
|
172
|
+
const graphql = extractGraphQLRequestMetadata(url, body);
|
|
168
173
|
const start = Date.now();
|
|
169
174
|
|
|
170
175
|
this.addEventListener('loadend', function csrLoadend(this: XMLHttpRequest) {
|
|
@@ -177,6 +182,7 @@ export class Recorder {
|
|
|
177
182
|
durationMs: Date.now() - start,
|
|
178
183
|
...(requestSize !== null && requestSize !== undefined ? { requestSize } : {}),
|
|
179
184
|
...(contentLength ? { responseSize: Number(contentLength) } : {}),
|
|
185
|
+
...(graphql ? { graphql } : {}),
|
|
180
186
|
});
|
|
181
187
|
});
|
|
182
188
|
|