@spotify-confidence/session-recording 0.17.2 → 0.17.3
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 +16 -0
- package/README.md +19 -0
- package/dist/index.cjs +100 -4
- package/dist/index.d.cts +10 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +100 -4
- package/package.json +3 -3
- package/src/flag-observer.test.ts +96 -0
- package/src/flag-observer.ts +32 -0
- package/src/index.ts +30 -0
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.17.3](https://github.com/spotify/confidence-sdk-js/compare/session-recording-v0.17.2...session-recording-v0.17.3) (2026-06-16)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ✨ New Features
|
|
7
|
+
|
|
8
|
+
* add route parameterization for route change and meta events ([#370](https://github.com/spotify/confidence-sdk-js/issues/370)) ([6efbee0](https://github.com/spotify/confidence-sdk-js/commit/6efbee08776c88b05dfa0949844f6c330bb0bcaa))
|
|
9
|
+
* emit flag evaluations into session recordings ([#369](https://github.com/spotify/confidence-sdk-js/issues/369)) ([3728c6d](https://github.com/spotify/confidence-sdk-js/commit/3728c6d682f97c3b2f18c8979dc91c50328b2345))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Dependencies
|
|
13
|
+
|
|
14
|
+
* The following workspace dependencies were updated
|
|
15
|
+
* dependencies
|
|
16
|
+
* @spotify-confidence/csr-common bumped to 0.17.3
|
|
17
|
+
* @spotify-confidence/csr-recorder bumped to 0.17.3
|
|
18
|
+
|
|
3
19
|
## [0.17.2](https://github.com/spotify/confidence-sdk-js/compare/session-recording-v0.17.1...session-recording-v0.17.2) (2026-06-15)
|
|
4
20
|
|
|
5
21
|
|
package/README.md
CHANGED
|
@@ -92,6 +92,25 @@ const recorder = initSessionRecorder({
|
|
|
92
92
|
});
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
## Route parameterization
|
|
96
|
+
|
|
97
|
+
Routes containing dynamic segments (such as IDs in the URL) are automatically normalized into patterns — for example, `/users/123/profile` becomes `/users/:id/profile`. This ensures that per-page metrics are grouped by route rather than by individual page visit, keeping dashboards meaningful and query performance fast.
|
|
98
|
+
|
|
99
|
+
If your app uses URL patterns that aren't automatically detected, you can provide a custom `parameterizeRoute` function to control how routes are grouped:
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
import { defaultParameterizeRoute } from '@spotify-confidence/csr-recorder';
|
|
103
|
+
|
|
104
|
+
const recorder = initSessionRecorder({
|
|
105
|
+
clientSecret: '<your-client-secret>',
|
|
106
|
+
parameterizeRoute: route => {
|
|
107
|
+
return defaultParameterizeRoute(route).replace(/\/teams\/[^/]+/, '/teams/:slug');
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
See the [`@spotify-confidence/csr-recorder` README](../csr-recorder/README.md#route-parameterization) for the full list of default patterns.
|
|
113
|
+
|
|
95
114
|
## Manual mode
|
|
96
115
|
|
|
97
116
|
Use `manual` mode to control when recording starts — useful for gating on user consent or feature flags.
|
package/dist/index.cjs
CHANGED
|
@@ -44,6 +44,33 @@ function validateMeasureValue(value) {
|
|
|
44
44
|
const DEFAULT_MASK_SELECTORS = ["[data-csr-mask]"];
|
|
45
45
|
const DEFAULT_BLOCK_SELECTORS = ["[data-csr-block]"];
|
|
46
46
|
//#endregion
|
|
47
|
+
//#region ../csr-recorder/src/route-parameterizer.ts
|
|
48
|
+
const SEGMENT_PATTERNS = [
|
|
49
|
+
{
|
|
50
|
+
pattern: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
|
51
|
+
replacement: ":uuid"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
pattern: /^\d+$/,
|
|
55
|
+
replacement: ":id"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
pattern: /^[a-z][a-z0-9]{19}$/,
|
|
59
|
+
replacement: ":id"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
pattern: /[0-9a-f]{20}/i,
|
|
63
|
+
replacement: ":id"
|
|
64
|
+
}
|
|
65
|
+
];
|
|
66
|
+
function defaultParameterizeRoute(route) {
|
|
67
|
+
return route.split("/").map((segment) => {
|
|
68
|
+
if (!segment) return segment;
|
|
69
|
+
for (const { pattern, replacement } of SEGMENT_PATTERNS) if (pattern.test(segment)) return replacement;
|
|
70
|
+
return segment;
|
|
71
|
+
}).join("/");
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
47
74
|
//#region ../csr-recorder/src/recorder.ts
|
|
48
75
|
var Recorder = class Recorder {
|
|
49
76
|
engine;
|
|
@@ -56,6 +83,7 @@ var Recorder = class Recorder {
|
|
|
56
83
|
originalPushState = null;
|
|
57
84
|
originalReplaceState = null;
|
|
58
85
|
popstateHandler = null;
|
|
86
|
+
parameterizeRoute;
|
|
59
87
|
constructor(options) {
|
|
60
88
|
this.engine = options.engine;
|
|
61
89
|
this.onEvent = options.onEvent;
|
|
@@ -66,7 +94,18 @@ var Recorder = class Recorder {
|
|
|
66
94
|
start(config) {
|
|
67
95
|
if (this.state === "recording") return;
|
|
68
96
|
this.state = "recording";
|
|
97
|
+
this.parameterizeRoute = config?.parameterizeRoute ?? defaultParameterizeRoute;
|
|
69
98
|
this.engine.start(config ?? {}, (event) => {
|
|
99
|
+
if (event.type === 4) {
|
|
100
|
+
const data = event.data;
|
|
101
|
+
if (typeof data?.href === "string") event = {
|
|
102
|
+
...event,
|
|
103
|
+
data: {
|
|
104
|
+
...data,
|
|
105
|
+
href: this.parameterizeHref(data.href)
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
70
109
|
this.onEvent(event);
|
|
71
110
|
});
|
|
72
111
|
if (typeof document !== "undefined") {
|
|
@@ -176,13 +215,25 @@ var Recorder = class Recorder {
|
|
|
176
215
|
return originalSend.call(this, body);
|
|
177
216
|
};
|
|
178
217
|
}
|
|
218
|
+
parameterizeHref(href) {
|
|
219
|
+
try {
|
|
220
|
+
const url = new URL(href);
|
|
221
|
+
url.pathname = this.parameterizeRoute(url.pathname);
|
|
222
|
+
return url.toString();
|
|
223
|
+
} catch (_e) {
|
|
224
|
+
return this.parameterizeRoute(href);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
179
227
|
emitRouteChange(from, to, trigger) {
|
|
180
228
|
if (from === to) return;
|
|
229
|
+
const paramFrom = this.parameterizeRoute(from);
|
|
230
|
+
const paramTo = this.parameterizeRoute(to);
|
|
231
|
+
if (paramFrom === paramTo) return;
|
|
181
232
|
const data = {
|
|
182
233
|
plugin: "csr:routeChange",
|
|
183
234
|
payload: {
|
|
184
|
-
from,
|
|
185
|
-
to,
|
|
235
|
+
from: paramFrom,
|
|
236
|
+
to: paramTo,
|
|
186
237
|
trigger
|
|
187
238
|
}
|
|
188
239
|
};
|
|
@@ -11116,6 +11167,31 @@ function record(onEvent, config) {
|
|
|
11116
11167
|
return () => recorder.stop();
|
|
11117
11168
|
}
|
|
11118
11169
|
//#endregion
|
|
11170
|
+
//#region src/flag-observer.ts
|
|
11171
|
+
function observeFlags(onFlagWrite) {
|
|
11172
|
+
if (typeof window === "undefined") return () => {};
|
|
11173
|
+
const confidence = window.__confidence ??= {};
|
|
11174
|
+
const existing = confidence.flags ?? {};
|
|
11175
|
+
const target = { ...existing };
|
|
11176
|
+
confidence.flags = new Proxy(target, { set(_target, prop, value) {
|
|
11177
|
+
if (typeof prop === "string" && value && typeof value.variant === "string") {
|
|
11178
|
+
_target[prop] = value;
|
|
11179
|
+
onFlagWrite({
|
|
11180
|
+
flagKey: prop,
|
|
11181
|
+
variant: value.variant
|
|
11182
|
+
});
|
|
11183
|
+
}
|
|
11184
|
+
return true;
|
|
11185
|
+
} });
|
|
11186
|
+
for (const [name, data] of Object.entries(existing)) if (data && typeof data.variant === "string") onFlagWrite({
|
|
11187
|
+
flagKey: name,
|
|
11188
|
+
variant: data.variant
|
|
11189
|
+
});
|
|
11190
|
+
return () => {
|
|
11191
|
+
confidence.flags = { ...target };
|
|
11192
|
+
};
|
|
11193
|
+
}
|
|
11194
|
+
//#endregion
|
|
11119
11195
|
//#region ../csr-common/src/uploader/client-context.ts
|
|
11120
11196
|
var import_es5 = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
11121
11197
|
(function(e, t) {
|
|
@@ -13077,7 +13153,7 @@ function writeCounter(counter) {
|
|
|
13077
13153
|
}
|
|
13078
13154
|
//#endregion
|
|
13079
13155
|
//#region src/version.ts
|
|
13080
|
-
const SDK_VERSION = "0.17.
|
|
13156
|
+
const SDK_VERSION = "0.17.3";
|
|
13081
13157
|
//#endregion
|
|
13082
13158
|
//#region src/index.ts
|
|
13083
13159
|
const DEFAULT_API_URL = "https://recording.confidence.dev";
|
|
@@ -13099,6 +13175,7 @@ function initSessionRecorder(options) {
|
|
|
13099
13175
|
const debugLogger = userLogger ? (msg) => userLogger(`[CSR] ${msg}`) : void 0;
|
|
13100
13176
|
let stopRecorder = null;
|
|
13101
13177
|
let closeUploader = null;
|
|
13178
|
+
let stopObservingFlags = null;
|
|
13102
13179
|
let sendEvent = null;
|
|
13103
13180
|
let started = false;
|
|
13104
13181
|
let stopped = false;
|
|
@@ -13108,7 +13185,8 @@ function initSessionRecorder(options) {
|
|
|
13108
13185
|
maskInputs: options.maskInputs,
|
|
13109
13186
|
captureConsoleLogs: options.captureConsoleLogs,
|
|
13110
13187
|
captureNetworkRequests: options.captureNetworkRequests,
|
|
13111
|
-
captureRouteChanges: options.captureRouteChanges
|
|
13188
|
+
captureRouteChanges: options.captureRouteChanges,
|
|
13189
|
+
parameterizeRoute: options.parameterizeRoute
|
|
13112
13190
|
};
|
|
13113
13191
|
async function initAndRecord(forceRecord) {
|
|
13114
13192
|
try {
|
|
@@ -13126,6 +13204,8 @@ function initSessionRecorder(options) {
|
|
|
13126
13204
|
debugLogger,
|
|
13127
13205
|
onTerminate: ({ reason }) => {
|
|
13128
13206
|
debugLogger?.(`Recording terminated: ${reason}`);
|
|
13207
|
+
stopObservingFlags?.();
|
|
13208
|
+
stopObservingFlags = null;
|
|
13129
13209
|
stopRecorder?.();
|
|
13130
13210
|
stopRecorder = null;
|
|
13131
13211
|
stopped = true;
|
|
@@ -13148,6 +13228,20 @@ function initSessionRecorder(options) {
|
|
|
13148
13228
|
}
|
|
13149
13229
|
};
|
|
13150
13230
|
stopRecorder = record(sendEvent, recordingConfig);
|
|
13231
|
+
stopObservingFlags = observeFlags(({ flagKey, variant }) => {
|
|
13232
|
+
const data = {
|
|
13233
|
+
plugin: "csr:flagEvaluation",
|
|
13234
|
+
payload: {
|
|
13235
|
+
flagKey,
|
|
13236
|
+
variant
|
|
13237
|
+
}
|
|
13238
|
+
};
|
|
13239
|
+
sendEvent?.({
|
|
13240
|
+
type: 6,
|
|
13241
|
+
timestamp: Date.now(),
|
|
13242
|
+
data
|
|
13243
|
+
});
|
|
13244
|
+
});
|
|
13151
13245
|
} catch (err) {
|
|
13152
13246
|
debugLogger?.(`Recording disabled: ${err instanceof Error ? err.message : String(err)}`);
|
|
13153
13247
|
}
|
|
@@ -13165,6 +13259,8 @@ function initSessionRecorder(options) {
|
|
|
13165
13259
|
stop() {
|
|
13166
13260
|
if (stopped) return;
|
|
13167
13261
|
stopped = true;
|
|
13262
|
+
stopObservingFlags?.();
|
|
13263
|
+
stopObservingFlags = null;
|
|
13168
13264
|
stopRecorder?.();
|
|
13169
13265
|
stopRecorder = null;
|
|
13170
13266
|
sendEvent = null;
|
package/dist/index.d.cts
CHANGED
|
@@ -53,6 +53,16 @@ interface InitSessionRecorderOptions {
|
|
|
53
53
|
captureNetworkRequests?: boolean;
|
|
54
54
|
/** Capture client-side route changes (pathname only). Defaults to `true`. */
|
|
55
55
|
captureRouteChanges?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Transform a raw pathname into a route pattern before it is emitted in
|
|
58
|
+
* route-change and Meta events. For example, `/users/123/profile` becomes
|
|
59
|
+
* `/users/:id/profile`. This ensures per-page metrics are grouped by route
|
|
60
|
+
* rather than by individual page visit.
|
|
61
|
+
*
|
|
62
|
+
* Import `defaultParameterizeRoute` from `@spotify-confidence/csr-recorder`
|
|
63
|
+
* to compose with the built-in rules.
|
|
64
|
+
*/
|
|
65
|
+
parameterizeRoute?: (route: string) => string;
|
|
56
66
|
/** Backend base URL. Defaults to the Confidence production endpoint. */
|
|
57
67
|
apiUrl?: string;
|
|
58
68
|
/** WebSocket ingest URL. Defaults to the Confidence production endpoint. */
|
package/dist/index.d.ts
CHANGED
|
@@ -53,6 +53,16 @@ interface InitSessionRecorderOptions {
|
|
|
53
53
|
captureNetworkRequests?: boolean;
|
|
54
54
|
/** Capture client-side route changes (pathname only). Defaults to `true`. */
|
|
55
55
|
captureRouteChanges?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Transform a raw pathname into a route pattern before it is emitted in
|
|
58
|
+
* route-change and Meta events. For example, `/users/123/profile` becomes
|
|
59
|
+
* `/users/:id/profile`. This ensures per-page metrics are grouped by route
|
|
60
|
+
* rather than by individual page visit.
|
|
61
|
+
*
|
|
62
|
+
* Import `defaultParameterizeRoute` from `@spotify-confidence/csr-recorder`
|
|
63
|
+
* to compose with the built-in rules.
|
|
64
|
+
*/
|
|
65
|
+
parameterizeRoute?: (route: string) => string;
|
|
56
66
|
/** Backend base URL. Defaults to the Confidence production endpoint. */
|
|
57
67
|
apiUrl?: string;
|
|
58
68
|
/** WebSocket ingest URL. Defaults to the Confidence production endpoint. */
|
package/dist/index.js
CHANGED
|
@@ -43,6 +43,33 @@ function validateMeasureValue(value) {
|
|
|
43
43
|
const DEFAULT_MASK_SELECTORS = ["[data-csr-mask]"];
|
|
44
44
|
const DEFAULT_BLOCK_SELECTORS = ["[data-csr-block]"];
|
|
45
45
|
//#endregion
|
|
46
|
+
//#region ../csr-recorder/src/route-parameterizer.ts
|
|
47
|
+
const SEGMENT_PATTERNS = [
|
|
48
|
+
{
|
|
49
|
+
pattern: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
|
50
|
+
replacement: ":uuid"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
pattern: /^\d+$/,
|
|
54
|
+
replacement: ":id"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
pattern: /^[a-z][a-z0-9]{19}$/,
|
|
58
|
+
replacement: ":id"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
pattern: /[0-9a-f]{20}/i,
|
|
62
|
+
replacement: ":id"
|
|
63
|
+
}
|
|
64
|
+
];
|
|
65
|
+
function defaultParameterizeRoute(route) {
|
|
66
|
+
return route.split("/").map((segment) => {
|
|
67
|
+
if (!segment) return segment;
|
|
68
|
+
for (const { pattern, replacement } of SEGMENT_PATTERNS) if (pattern.test(segment)) return replacement;
|
|
69
|
+
return segment;
|
|
70
|
+
}).join("/");
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
46
73
|
//#region ../csr-recorder/src/recorder.ts
|
|
47
74
|
var Recorder = class Recorder {
|
|
48
75
|
engine;
|
|
@@ -55,6 +82,7 @@ var Recorder = class Recorder {
|
|
|
55
82
|
originalPushState = null;
|
|
56
83
|
originalReplaceState = null;
|
|
57
84
|
popstateHandler = null;
|
|
85
|
+
parameterizeRoute;
|
|
58
86
|
constructor(options) {
|
|
59
87
|
this.engine = options.engine;
|
|
60
88
|
this.onEvent = options.onEvent;
|
|
@@ -65,7 +93,18 @@ var Recorder = class Recorder {
|
|
|
65
93
|
start(config) {
|
|
66
94
|
if (this.state === "recording") return;
|
|
67
95
|
this.state = "recording";
|
|
96
|
+
this.parameterizeRoute = config?.parameterizeRoute ?? defaultParameterizeRoute;
|
|
68
97
|
this.engine.start(config ?? {}, (event) => {
|
|
98
|
+
if (event.type === 4) {
|
|
99
|
+
const data = event.data;
|
|
100
|
+
if (typeof data?.href === "string") event = {
|
|
101
|
+
...event,
|
|
102
|
+
data: {
|
|
103
|
+
...data,
|
|
104
|
+
href: this.parameterizeHref(data.href)
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
69
108
|
this.onEvent(event);
|
|
70
109
|
});
|
|
71
110
|
if (typeof document !== "undefined") {
|
|
@@ -175,13 +214,25 @@ var Recorder = class Recorder {
|
|
|
175
214
|
return originalSend.call(this, body);
|
|
176
215
|
};
|
|
177
216
|
}
|
|
217
|
+
parameterizeHref(href) {
|
|
218
|
+
try {
|
|
219
|
+
const url = new URL(href);
|
|
220
|
+
url.pathname = this.parameterizeRoute(url.pathname);
|
|
221
|
+
return url.toString();
|
|
222
|
+
} catch (_e) {
|
|
223
|
+
return this.parameterizeRoute(href);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
178
226
|
emitRouteChange(from, to, trigger) {
|
|
179
227
|
if (from === to) return;
|
|
228
|
+
const paramFrom = this.parameterizeRoute(from);
|
|
229
|
+
const paramTo = this.parameterizeRoute(to);
|
|
230
|
+
if (paramFrom === paramTo) return;
|
|
180
231
|
const data = {
|
|
181
232
|
plugin: "csr:routeChange",
|
|
182
233
|
payload: {
|
|
183
|
-
from,
|
|
184
|
-
to,
|
|
234
|
+
from: paramFrom,
|
|
235
|
+
to: paramTo,
|
|
185
236
|
trigger
|
|
186
237
|
}
|
|
187
238
|
};
|
|
@@ -11097,6 +11148,31 @@ function record(onEvent, config) {
|
|
|
11097
11148
|
return () => recorder.stop();
|
|
11098
11149
|
}
|
|
11099
11150
|
//#endregion
|
|
11151
|
+
//#region src/flag-observer.ts
|
|
11152
|
+
function observeFlags(onFlagWrite) {
|
|
11153
|
+
if (typeof window === "undefined") return () => {};
|
|
11154
|
+
const confidence = window.__confidence ??= {};
|
|
11155
|
+
const existing = confidence.flags ?? {};
|
|
11156
|
+
const target = { ...existing };
|
|
11157
|
+
confidence.flags = new Proxy(target, { set(_target, prop, value) {
|
|
11158
|
+
if (typeof prop === "string" && value && typeof value.variant === "string") {
|
|
11159
|
+
_target[prop] = value;
|
|
11160
|
+
onFlagWrite({
|
|
11161
|
+
flagKey: prop,
|
|
11162
|
+
variant: value.variant
|
|
11163
|
+
});
|
|
11164
|
+
}
|
|
11165
|
+
return true;
|
|
11166
|
+
} });
|
|
11167
|
+
for (const [name, data] of Object.entries(existing)) if (data && typeof data.variant === "string") onFlagWrite({
|
|
11168
|
+
flagKey: name,
|
|
11169
|
+
variant: data.variant
|
|
11170
|
+
});
|
|
11171
|
+
return () => {
|
|
11172
|
+
confidence.flags = { ...target };
|
|
11173
|
+
};
|
|
11174
|
+
}
|
|
11175
|
+
//#endregion
|
|
11100
11176
|
//#region ../csr-common/src/uploader/client-context.ts
|
|
11101
11177
|
var import_es5 = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
11102
11178
|
(function(e, t) {
|
|
@@ -13058,7 +13134,7 @@ function writeCounter(counter) {
|
|
|
13058
13134
|
}
|
|
13059
13135
|
//#endregion
|
|
13060
13136
|
//#region src/version.ts
|
|
13061
|
-
const SDK_VERSION = "0.17.
|
|
13137
|
+
const SDK_VERSION = "0.17.3";
|
|
13062
13138
|
//#endregion
|
|
13063
13139
|
//#region src/index.ts
|
|
13064
13140
|
const DEFAULT_API_URL = "https://recording.confidence.dev";
|
|
@@ -13080,6 +13156,7 @@ function initSessionRecorder(options) {
|
|
|
13080
13156
|
const debugLogger = userLogger ? (msg) => userLogger(`[CSR] ${msg}`) : void 0;
|
|
13081
13157
|
let stopRecorder = null;
|
|
13082
13158
|
let closeUploader = null;
|
|
13159
|
+
let stopObservingFlags = null;
|
|
13083
13160
|
let sendEvent = null;
|
|
13084
13161
|
let started = false;
|
|
13085
13162
|
let stopped = false;
|
|
@@ -13089,7 +13166,8 @@ function initSessionRecorder(options) {
|
|
|
13089
13166
|
maskInputs: options.maskInputs,
|
|
13090
13167
|
captureConsoleLogs: options.captureConsoleLogs,
|
|
13091
13168
|
captureNetworkRequests: options.captureNetworkRequests,
|
|
13092
|
-
captureRouteChanges: options.captureRouteChanges
|
|
13169
|
+
captureRouteChanges: options.captureRouteChanges,
|
|
13170
|
+
parameterizeRoute: options.parameterizeRoute
|
|
13093
13171
|
};
|
|
13094
13172
|
async function initAndRecord(forceRecord) {
|
|
13095
13173
|
try {
|
|
@@ -13107,6 +13185,8 @@ function initSessionRecorder(options) {
|
|
|
13107
13185
|
debugLogger,
|
|
13108
13186
|
onTerminate: ({ reason }) => {
|
|
13109
13187
|
debugLogger?.(`Recording terminated: ${reason}`);
|
|
13188
|
+
stopObservingFlags?.();
|
|
13189
|
+
stopObservingFlags = null;
|
|
13110
13190
|
stopRecorder?.();
|
|
13111
13191
|
stopRecorder = null;
|
|
13112
13192
|
stopped = true;
|
|
@@ -13129,6 +13209,20 @@ function initSessionRecorder(options) {
|
|
|
13129
13209
|
}
|
|
13130
13210
|
};
|
|
13131
13211
|
stopRecorder = record(sendEvent, recordingConfig);
|
|
13212
|
+
stopObservingFlags = observeFlags(({ flagKey, variant }) => {
|
|
13213
|
+
const data = {
|
|
13214
|
+
plugin: "csr:flagEvaluation",
|
|
13215
|
+
payload: {
|
|
13216
|
+
flagKey,
|
|
13217
|
+
variant
|
|
13218
|
+
}
|
|
13219
|
+
};
|
|
13220
|
+
sendEvent?.({
|
|
13221
|
+
type: 6,
|
|
13222
|
+
timestamp: Date.now(),
|
|
13223
|
+
data
|
|
13224
|
+
});
|
|
13225
|
+
});
|
|
13132
13226
|
} catch (err) {
|
|
13133
13227
|
debugLogger?.(`Recording disabled: ${err instanceof Error ? err.message : String(err)}`);
|
|
13134
13228
|
}
|
|
@@ -13146,6 +13240,8 @@ function initSessionRecorder(options) {
|
|
|
13146
13240
|
stop() {
|
|
13147
13241
|
if (stopped) return;
|
|
13148
13242
|
stopped = true;
|
|
13243
|
+
stopObservingFlags?.();
|
|
13244
|
+
stopObservingFlags = null;
|
|
13149
13245
|
stopRecorder?.();
|
|
13150
13246
|
stopRecorder = null;
|
|
13151
13247
|
sendEvent = null;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spotify-confidence/session-recording",
|
|
3
3
|
"license": "Apache-2.0",
|
|
4
|
-
"version": "0.17.
|
|
4
|
+
"version": "0.17.3",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/spotify/confidence-sdk-js.git",
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
}
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@spotify-confidence/csr-common": "^0.17.
|
|
39
|
-
"@spotify-confidence/csr-recorder": "^0.17.
|
|
38
|
+
"@spotify-confidence/csr-common": "^0.17.3",
|
|
39
|
+
"@spotify-confidence/csr-recorder": "^0.17.3"
|
|
40
40
|
},
|
|
41
41
|
"module": "./dist/index.js",
|
|
42
42
|
"exports": {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { observeFlags, type FlagWrite } from './flag-observer';
|
|
4
|
+
|
|
5
|
+
describe('observeFlags', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
delete (window as any).__confidence;
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('calls back when a flag is written after observation starts', () => {
|
|
11
|
+
const writes: FlagWrite[] = [];
|
|
12
|
+
observeFlags(w => writes.push(w));
|
|
13
|
+
|
|
14
|
+
(window as any).__confidence.flags['my-flag'] = { variant: 'treatment-a' };
|
|
15
|
+
|
|
16
|
+
expect(writes).toEqual([{ flagKey: 'my-flag', variant: 'treatment-a' }]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('emits snapshot entries for pre-existing flags', () => {
|
|
20
|
+
(window as any).__confidence = {
|
|
21
|
+
flags: {
|
|
22
|
+
'flag-a': { variant: 'v1' },
|
|
23
|
+
'flag-b': { variant: 'v2' },
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const writes: FlagWrite[] = [];
|
|
28
|
+
observeFlags(w => writes.push(w));
|
|
29
|
+
|
|
30
|
+
expect(writes).toContainEqual({ flagKey: 'flag-a', variant: 'v1' });
|
|
31
|
+
expect(writes).toContainEqual({ flagKey: 'flag-b', variant: 'v2' });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('observes new writes after reading the snapshot', () => {
|
|
35
|
+
(window as any).__confidence = {
|
|
36
|
+
flags: { existing: { variant: 'old' } },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const writes: FlagWrite[] = [];
|
|
40
|
+
observeFlags(w => writes.push(w));
|
|
41
|
+
|
|
42
|
+
(window as any).__confidence.flags['new-flag'] = { variant: 'new' };
|
|
43
|
+
|
|
44
|
+
expect(writes).toHaveLength(2);
|
|
45
|
+
expect(writes[0]).toEqual({ flagKey: 'existing', variant: 'old' });
|
|
46
|
+
expect(writes[1]).toEqual({ flagKey: 'new-flag', variant: 'new' });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('cleanup replaces proxy with plain copy', () => {
|
|
50
|
+
const writes: FlagWrite[] = [];
|
|
51
|
+
const cleanup = observeFlags(w => writes.push(w));
|
|
52
|
+
|
|
53
|
+
(window as any).__confidence.flags['flag-a'] = { variant: 'v1' };
|
|
54
|
+
expect(writes).toHaveLength(1);
|
|
55
|
+
|
|
56
|
+
cleanup();
|
|
57
|
+
|
|
58
|
+
(window as any).__confidence.flags['flag-b'] = { variant: 'v2' };
|
|
59
|
+
expect(writes).toHaveLength(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('preserves data after cleanup', () => {
|
|
63
|
+
observeFlags(() => {});
|
|
64
|
+
(window as any).__confidence.flags['my-flag'] = { variant: 'treatment' };
|
|
65
|
+
|
|
66
|
+
const cleanup = observeFlags(() => {});
|
|
67
|
+
cleanup();
|
|
68
|
+
|
|
69
|
+
expect((window as any).__confidence.flags['my-flag']).toEqual({ variant: 'treatment' });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('ignores writes with missing variant', () => {
|
|
73
|
+
const writes: FlagWrite[] = [];
|
|
74
|
+
observeFlags(w => writes.push(w));
|
|
75
|
+
|
|
76
|
+
(window as any).__confidence.flags.bad = { noVariant: true };
|
|
77
|
+
|
|
78
|
+
expect(writes).toHaveLength(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('ignores writes with non-string variant', () => {
|
|
82
|
+
const writes: FlagWrite[] = [];
|
|
83
|
+
observeFlags(w => writes.push(w));
|
|
84
|
+
|
|
85
|
+
(window as any).__confidence.flags.bad = { variant: 42 };
|
|
86
|
+
|
|
87
|
+
expect(writes).toHaveLength(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('initializes window.__confidence if not present', () => {
|
|
91
|
+
observeFlags(() => {});
|
|
92
|
+
|
|
93
|
+
expect((window as any).__confidence).toBeDefined();
|
|
94
|
+
expect((window as any).__confidence.flags).toBeDefined();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type FlagWrite = { flagKey: string; variant: string };
|
|
2
|
+
export type FlagWriteCallback = (write: FlagWrite) => void;
|
|
3
|
+
|
|
4
|
+
export function observeFlags(onFlagWrite: FlagWriteCallback): () => void {
|
|
5
|
+
if (typeof window === 'undefined') return () => {};
|
|
6
|
+
|
|
7
|
+
const confidence = ((window as any).__confidence ??= {});
|
|
8
|
+
const existing: Record<string, { variant: string }> = confidence.flags ?? {};
|
|
9
|
+
const target: Record<string, { variant: string }> = { ...existing };
|
|
10
|
+
|
|
11
|
+
const proxy = new Proxy(target, {
|
|
12
|
+
set(_target, prop, value) {
|
|
13
|
+
if (typeof prop === 'string' && value && typeof value.variant === 'string') {
|
|
14
|
+
_target[prop] = value;
|
|
15
|
+
onFlagWrite({ flagKey: prop, variant: value.variant });
|
|
16
|
+
}
|
|
17
|
+
return true;
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
confidence.flags = proxy;
|
|
22
|
+
|
|
23
|
+
for (const [name, data] of Object.entries(existing)) {
|
|
24
|
+
if (data && typeof (data as any).variant === 'string') {
|
|
25
|
+
onFlagWrite({ flagKey: name, variant: (data as any).variant });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return () => {
|
|
30
|
+
confidence.flags = { ...target };
|
|
31
|
+
};
|
|
32
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,10 +4,12 @@ import {
|
|
|
4
4
|
RecordingEventType,
|
|
5
5
|
type TagPluginData,
|
|
6
6
|
type MeasurePluginData,
|
|
7
|
+
type FlagEvaluationPluginData,
|
|
7
8
|
validateKey,
|
|
8
9
|
validateTagValue,
|
|
9
10
|
validateMeasureValue,
|
|
10
11
|
} from '@spotify-confidence/csr-common';
|
|
12
|
+
import { observeFlags } from './flag-observer';
|
|
11
13
|
import { createUploader, type ClientContext } from '@spotify-confidence/csr-common/uploader';
|
|
12
14
|
import { SDK_VERSION } from './version';
|
|
13
15
|
|
|
@@ -30,6 +32,16 @@ export interface InitSessionRecorderOptions {
|
|
|
30
32
|
captureNetworkRequests?: boolean;
|
|
31
33
|
/** Capture client-side route changes (pathname only). Defaults to `true`. */
|
|
32
34
|
captureRouteChanges?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Transform a raw pathname into a route pattern before it is emitted in
|
|
37
|
+
* route-change and Meta events. For example, `/users/123/profile` becomes
|
|
38
|
+
* `/users/:id/profile`. This ensures per-page metrics are grouped by route
|
|
39
|
+
* rather than by individual page visit.
|
|
40
|
+
*
|
|
41
|
+
* Import `defaultParameterizeRoute` from `@spotify-confidence/csr-recorder`
|
|
42
|
+
* to compose with the built-in rules.
|
|
43
|
+
*/
|
|
44
|
+
parameterizeRoute?: (route: string) => string;
|
|
33
45
|
/** Backend base URL. Defaults to the Confidence production endpoint. */
|
|
34
46
|
apiUrl?: string;
|
|
35
47
|
/** WebSocket ingest URL. Defaults to the Confidence production endpoint. */
|
|
@@ -86,6 +98,7 @@ export function initSessionRecorder(options: InitSessionRecorderOptions): Sessio
|
|
|
86
98
|
|
|
87
99
|
let stopRecorder: (() => void) | null = null;
|
|
88
100
|
let closeUploader: (() => void) | null = null;
|
|
101
|
+
let stopObservingFlags: (() => void) | null = null;
|
|
89
102
|
let sendEvent: ((event: unknown) => void) | null = null;
|
|
90
103
|
let started = false;
|
|
91
104
|
let stopped = false;
|
|
@@ -97,6 +110,7 @@ export function initSessionRecorder(options: InitSessionRecorderOptions): Sessio
|
|
|
97
110
|
captureConsoleLogs: options.captureConsoleLogs,
|
|
98
111
|
captureNetworkRequests: options.captureNetworkRequests,
|
|
99
112
|
captureRouteChanges: options.captureRouteChanges,
|
|
113
|
+
parameterizeRoute: options.parameterizeRoute,
|
|
100
114
|
};
|
|
101
115
|
|
|
102
116
|
async function initAndRecord(forceRecord: boolean) {
|
|
@@ -115,6 +129,8 @@ export function initSessionRecorder(options: InitSessionRecorderOptions): Sessio
|
|
|
115
129
|
debugLogger,
|
|
116
130
|
onTerminate: ({ reason }) => {
|
|
117
131
|
debugLogger?.(`Recording terminated: ${reason}`);
|
|
132
|
+
stopObservingFlags?.();
|
|
133
|
+
stopObservingFlags = null;
|
|
118
134
|
stopRecorder?.();
|
|
119
135
|
stopRecorder = null;
|
|
120
136
|
stopped = true;
|
|
@@ -142,6 +158,18 @@ export function initSessionRecorder(options: InitSessionRecorderOptions): Sessio
|
|
|
142
158
|
};
|
|
143
159
|
|
|
144
160
|
stopRecorder = record(sendEvent, recordingConfig);
|
|
161
|
+
|
|
162
|
+
stopObservingFlags = observeFlags(({ flagKey, variant }) => {
|
|
163
|
+
const data: FlagEvaluationPluginData = {
|
|
164
|
+
plugin: 'csr:flagEvaluation',
|
|
165
|
+
payload: { flagKey, variant },
|
|
166
|
+
};
|
|
167
|
+
sendEvent?.({
|
|
168
|
+
type: RecordingEventType.Plugin,
|
|
169
|
+
timestamp: Date.now(),
|
|
170
|
+
data,
|
|
171
|
+
});
|
|
172
|
+
});
|
|
145
173
|
} catch (err) {
|
|
146
174
|
debugLogger?.(`Recording disabled: ${err instanceof Error ? err.message : String(err)}`);
|
|
147
175
|
}
|
|
@@ -163,6 +191,8 @@ export function initSessionRecorder(options: InitSessionRecorderOptions): Sessio
|
|
|
163
191
|
stop() {
|
|
164
192
|
if (stopped) return;
|
|
165
193
|
stopped = true;
|
|
194
|
+
stopObservingFlags?.();
|
|
195
|
+
stopObservingFlags = null;
|
|
166
196
|
stopRecorder?.();
|
|
167
197
|
stopRecorder = null;
|
|
168
198
|
sendEvent = null;
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '0.17.
|
|
1
|
+
export const SDK_VERSION = '0.17.3';
|