@spotify-confidence/csr-recorder 0.17.11 → 0.17.12

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.17.12](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.11...csr-recorder-v0.17.12) (2026-08-18)
4
+
5
+
6
+ ### ✨ New Features
7
+
8
+ * **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))
9
+
10
+
11
+ ### Dependencies
12
+
13
+ * The following workspace dependencies were updated
14
+ * dependencies
15
+ * @spotify-confidence/csr-common bumped to 0.18.6
16
+
3
17
  ## [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
18
 
5
19
 
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);
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);
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.11",
4
+ "version": "0.17.12",
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.5",
38
+ "@spotify-confidence/csr-common": "0.18.6",
39
39
  "rrweb": "^2.0.1"
40
40
  },
41
41
  "module": "./dist/index.js",
@@ -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
+ }
@@ -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