@spotify-confidence/session-recording 0.17.2 → 0.17.4

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,30 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.17.4](https://github.com/spotify/confidence-sdk-js/compare/session-recording-v0.17.3...session-recording-v0.17.4) (2026-06-24)
4
+
5
+
6
+ ### Dependencies
7
+
8
+ * The following workspace dependencies were updated
9
+ * dependencies
10
+ * @spotify-confidence/csr-recorder bumped to 0.17.4
11
+
12
+ ## [0.17.3](https://github.com/spotify/confidence-sdk-js/compare/session-recording-v0.17.2...session-recording-v0.17.3) (2026-06-16)
13
+
14
+
15
+ ### ✨ New Features
16
+
17
+ * 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))
18
+ * 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))
19
+
20
+
21
+ ### Dependencies
22
+
23
+ * The following workspace dependencies were updated
24
+ * dependencies
25
+ * @spotify-confidence/csr-common bumped to 0.17.3
26
+ * @spotify-confidence/csr-recorder bumped to 0.17.3
27
+
3
28
  ## [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
29
 
5
30
 
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.2";
13156
+ const SDK_VERSION = "0.17.4";
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.2";
13137
+ const SDK_VERSION = "0.17.4";
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.2",
4
+ "version": "0.17.4",
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.2",
39
- "@spotify-confidence/csr-recorder": "^0.17.2"
38
+ "@spotify-confidence/csr-common": "^0.17.3",
39
+ "@spotify-confidence/csr-recorder": "^0.17.4"
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.2';
1
+ export const SDK_VERSION = '0.17.4';