@spotify-confidence/csr-recorder 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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.17.3](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.2...csr-recorder-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
+
10
+
11
+ ### Dependencies
12
+
13
+ * The following workspace dependencies were updated
14
+ * dependencies
15
+ * @spotify-confidence/csr-common bumped to 0.17.3
16
+
3
17
  ## [0.17.2](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.1...csr-recorder-v0.17.2) (2026-06-15)
4
18
 
5
19
 
package/README.md CHANGED
@@ -27,6 +27,34 @@ const stop = record(
27
27
  stop();
28
28
  ```
29
29
 
30
+ ## Route parameterization
31
+
32
+ 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.
33
+
34
+ Default replacements:
35
+
36
+ | Pattern | Example | Replacement |
37
+ | ---------------------- | -------------------------------------- | ----------- |
38
+ | UUID | `550e8400-e29b-41d4-a716-446655440000` | `:uuid` |
39
+ | Numeric ID | `123` | `:id` |
40
+ | AIP-122 ID | `cmvkznnjmbkc9rw2oxws` | `:id` |
41
+ | Hex string (20+ chars) | `507f1f77bcf86cd799439011` | `:id` |
42
+
43
+ If your app uses URL patterns that aren't automatically detected, you can provide a custom `parameterizeRoute` function to control how routes are grouped:
44
+
45
+ ```typescript
46
+ import { record, defaultParameterizeRoute } from '@spotify-confidence/csr-recorder';
47
+
48
+ const stop = record(event => {}, {
49
+ parameterizeRoute: route => {
50
+ // Apply defaults first, then handle your own patterns
51
+ return defaultParameterizeRoute(route).replace(/\/teams\/[^/]+/, '/teams/:slug');
52
+ },
53
+ });
54
+ ```
55
+
56
+ The same parameterization is also applied to the `href` in rrweb Meta events.
57
+
30
58
  ## rrweb version
31
59
 
32
60
  This package pins `rrweb@^2.0.0-alpha.20`. The rrweb 2.x line is in alpha but is the version we've validated against. The recording engine is bundled — consumers do not need rrweb as a peer dependency.
package/dist/index.cjs CHANGED
@@ -10,6 +10,33 @@ let RecorderState = /* @__PURE__ */ function(RecorderState) {
10
10
  return RecorderState;
11
11
  }({});
12
12
  //#endregion
13
+ //#region src/route-parameterizer.ts
14
+ const SEGMENT_PATTERNS = [
15
+ {
16
+ pattern: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
17
+ replacement: ":uuid"
18
+ },
19
+ {
20
+ pattern: /^\d+$/,
21
+ replacement: ":id"
22
+ },
23
+ {
24
+ pattern: /^[a-z][a-z0-9]{19}$/,
25
+ replacement: ":id"
26
+ },
27
+ {
28
+ pattern: /[0-9a-f]{20}/i,
29
+ replacement: ":id"
30
+ }
31
+ ];
32
+ function defaultParameterizeRoute(route) {
33
+ return route.split("/").map((segment) => {
34
+ if (!segment) return segment;
35
+ for (const { pattern, replacement } of SEGMENT_PATTERNS) if (pattern.test(segment)) return replacement;
36
+ return segment;
37
+ }).join("/");
38
+ }
39
+ //#endregion
13
40
  //#region src/recorder.ts
14
41
  var Recorder = class Recorder {
15
42
  engine;
@@ -22,6 +49,7 @@ var Recorder = class Recorder {
22
49
  originalPushState = null;
23
50
  originalReplaceState = null;
24
51
  popstateHandler = null;
52
+ parameterizeRoute;
25
53
  constructor(options) {
26
54
  this.engine = options.engine;
27
55
  this.onEvent = options.onEvent;
@@ -32,7 +60,18 @@ var Recorder = class Recorder {
32
60
  start(config) {
33
61
  if (this.state === "recording") return;
34
62
  this.state = "recording";
63
+ this.parameterizeRoute = config?.parameterizeRoute ?? defaultParameterizeRoute;
35
64
  this.engine.start(config ?? {}, (event) => {
65
+ if (event.type === _spotify_confidence_csr_common.RecordingEventType.Meta) {
66
+ const data = event.data;
67
+ if (typeof data?.href === "string") event = {
68
+ ...event,
69
+ data: {
70
+ ...data,
71
+ href: this.parameterizeHref(data.href)
72
+ }
73
+ };
74
+ }
36
75
  this.onEvent(event);
37
76
  });
38
77
  if (typeof document !== "undefined") {
@@ -142,13 +181,25 @@ var Recorder = class Recorder {
142
181
  return originalSend.call(this, body);
143
182
  };
144
183
  }
184
+ parameterizeHref(href) {
185
+ try {
186
+ const url = new URL(href);
187
+ url.pathname = this.parameterizeRoute(url.pathname);
188
+ return url.toString();
189
+ } catch (_e) {
190
+ return this.parameterizeRoute(href);
191
+ }
192
+ }
145
193
  emitRouteChange(from, to, trigger) {
146
194
  if (from === to) return;
195
+ const paramFrom = this.parameterizeRoute(from);
196
+ const paramTo = this.parameterizeRoute(to);
197
+ if (paramFrom === paramTo) return;
147
198
  const data = {
148
199
  plugin: "csr:routeChange",
149
200
  payload: {
150
- from,
151
- to,
201
+ from: paramFrom,
202
+ to: paramTo,
152
203
  trigger
153
204
  }
154
205
  };
@@ -11087,4 +11138,5 @@ exports.DEFAULT_MASK_SELECTORS = DEFAULT_MASK_SELECTORS;
11087
11138
  exports.Recorder = Recorder;
11088
11139
  exports.RecorderState = RecorderState;
11089
11140
  exports.RrwebEngine = RrwebEngine;
11141
+ exports.defaultParameterizeRoute = defaultParameterizeRoute;
11090
11142
  exports.record = record;
package/dist/index.d.cts CHANGED
@@ -68,6 +68,22 @@ interface RecordingConfig {
68
68
  * hashes are stripped.
69
69
  */
70
70
  captureRouteChanges?: boolean;
71
+ /**
72
+ * Transform a raw pathname into a route pattern before it is emitted in
73
+ * route-change and Meta events. For example, `/users/123/profile` becomes
74
+ * `/users/:id/profile`. This ensures per-page metrics are grouped by route
75
+ * rather than by individual page visit.
76
+ *
77
+ * The default implementation replaces common dynamic segments:
78
+ * - UUIDs → `:uuid`
79
+ * - Numeric IDs → `:id`
80
+ * - AIP-122 IDs → `:id`
81
+ * - Long hex strings (20+ chars, e.g. MongoDB ObjectIDs) → `:id`
82
+ *
83
+ * Provide a custom function to handle application-specific patterns.
84
+ * Import `defaultParameterizeRoute` to compose with the built-in rules.
85
+ */
86
+ parameterizeRoute?: (route: string) => string;
71
87
  }
72
88
  declare enum RecorderState {
73
89
  Idle = "idle",
@@ -87,6 +103,7 @@ declare class Recorder {
87
103
  private originalPushState;
88
104
  private originalReplaceState;
89
105
  private popstateHandler;
106
+ private parameterizeRoute;
90
107
  constructor(options: RecorderOptions);
91
108
  get currentState(): RecorderState;
92
109
  start(config?: RecordingConfig): void;
@@ -94,6 +111,7 @@ declare class Recorder {
94
111
  private patchNetwork;
95
112
  private patchFetch;
96
113
  private patchXhr;
114
+ private parameterizeHref;
97
115
  private emitRouteChange;
98
116
  private static currentPathname;
99
117
  private patchRouting;
@@ -121,4 +139,7 @@ declare class RrwebEngine implements RecordingEngine {
121
139
  */
122
140
  declare function record(onEvent: (event: RecordingEvent) => void, config?: RecordingConfig): () => void;
123
141
  //#endregion
124
- export { DEFAULT_BLOCK_SELECTORS, DEFAULT_MASK_SELECTORS, Recorder, type RecorderOptions, RecorderState, type RecordingConfig, type RecordingEngine, RrwebEngine, record };
142
+ //#region src/route-parameterizer.d.ts
143
+ declare function defaultParameterizeRoute(route: string): string;
144
+ //#endregion
145
+ export { DEFAULT_BLOCK_SELECTORS, DEFAULT_MASK_SELECTORS, Recorder, type RecorderOptions, RecorderState, type RecordingConfig, type RecordingEngine, RrwebEngine, defaultParameterizeRoute, record };
package/dist/index.d.ts CHANGED
@@ -68,6 +68,22 @@ interface RecordingConfig {
68
68
  * hashes are stripped.
69
69
  */
70
70
  captureRouteChanges?: boolean;
71
+ /**
72
+ * Transform a raw pathname into a route pattern before it is emitted in
73
+ * route-change and Meta events. For example, `/users/123/profile` becomes
74
+ * `/users/:id/profile`. This ensures per-page metrics are grouped by route
75
+ * rather than by individual page visit.
76
+ *
77
+ * The default implementation replaces common dynamic segments:
78
+ * - UUIDs → `:uuid`
79
+ * - Numeric IDs → `:id`
80
+ * - AIP-122 IDs → `:id`
81
+ * - Long hex strings (20+ chars, e.g. MongoDB ObjectIDs) → `:id`
82
+ *
83
+ * Provide a custom function to handle application-specific patterns.
84
+ * Import `defaultParameterizeRoute` to compose with the built-in rules.
85
+ */
86
+ parameterizeRoute?: (route: string) => string;
71
87
  }
72
88
  declare enum RecorderState {
73
89
  Idle = "idle",
@@ -87,6 +103,7 @@ declare class Recorder {
87
103
  private originalPushState;
88
104
  private originalReplaceState;
89
105
  private popstateHandler;
106
+ private parameterizeRoute;
90
107
  constructor(options: RecorderOptions);
91
108
  get currentState(): RecorderState;
92
109
  start(config?: RecordingConfig): void;
@@ -94,6 +111,7 @@ declare class Recorder {
94
111
  private patchNetwork;
95
112
  private patchFetch;
96
113
  private patchXhr;
114
+ private parameterizeHref;
97
115
  private emitRouteChange;
98
116
  private static currentPathname;
99
117
  private patchRouting;
@@ -121,4 +139,7 @@ declare class RrwebEngine implements RecordingEngine {
121
139
  */
122
140
  declare function record(onEvent: (event: RecordingEvent) => void, config?: RecordingConfig): () => void;
123
141
  //#endregion
124
- export { DEFAULT_BLOCK_SELECTORS, DEFAULT_MASK_SELECTORS, Recorder, type RecorderOptions, RecorderState, type RecordingConfig, type RecordingEngine, RrwebEngine, record };
142
+ //#region src/route-parameterizer.d.ts
143
+ declare function defaultParameterizeRoute(route: string): string;
144
+ //#endregion
145
+ export { DEFAULT_BLOCK_SELECTORS, DEFAULT_MASK_SELECTORS, Recorder, type RecorderOptions, RecorderState, type RecordingConfig, type RecordingEngine, RrwebEngine, defaultParameterizeRoute, record };
package/dist/index.js CHANGED
@@ -9,6 +9,33 @@ let RecorderState = /* @__PURE__ */ function(RecorderState) {
9
9
  return RecorderState;
10
10
  }({});
11
11
  //#endregion
12
+ //#region src/route-parameterizer.ts
13
+ const SEGMENT_PATTERNS = [
14
+ {
15
+ pattern: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
16
+ replacement: ":uuid"
17
+ },
18
+ {
19
+ pattern: /^\d+$/,
20
+ replacement: ":id"
21
+ },
22
+ {
23
+ pattern: /^[a-z][a-z0-9]{19}$/,
24
+ replacement: ":id"
25
+ },
26
+ {
27
+ pattern: /[0-9a-f]{20}/i,
28
+ replacement: ":id"
29
+ }
30
+ ];
31
+ function defaultParameterizeRoute(route) {
32
+ return route.split("/").map((segment) => {
33
+ if (!segment) return segment;
34
+ for (const { pattern, replacement } of SEGMENT_PATTERNS) if (pattern.test(segment)) return replacement;
35
+ return segment;
36
+ }).join("/");
37
+ }
38
+ //#endregion
12
39
  //#region src/recorder.ts
13
40
  var Recorder = class Recorder {
14
41
  engine;
@@ -21,6 +48,7 @@ var Recorder = class Recorder {
21
48
  originalPushState = null;
22
49
  originalReplaceState = null;
23
50
  popstateHandler = null;
51
+ parameterizeRoute;
24
52
  constructor(options) {
25
53
  this.engine = options.engine;
26
54
  this.onEvent = options.onEvent;
@@ -31,7 +59,18 @@ var Recorder = class Recorder {
31
59
  start(config) {
32
60
  if (this.state === "recording") return;
33
61
  this.state = "recording";
62
+ this.parameterizeRoute = config?.parameterizeRoute ?? defaultParameterizeRoute;
34
63
  this.engine.start(config ?? {}, (event) => {
64
+ if (event.type === RecordingEventType.Meta) {
65
+ const data = event.data;
66
+ if (typeof data?.href === "string") event = {
67
+ ...event,
68
+ data: {
69
+ ...data,
70
+ href: this.parameterizeHref(data.href)
71
+ }
72
+ };
73
+ }
35
74
  this.onEvent(event);
36
75
  });
37
76
  if (typeof document !== "undefined") {
@@ -141,13 +180,25 @@ var Recorder = class Recorder {
141
180
  return originalSend.call(this, body);
142
181
  };
143
182
  }
183
+ parameterizeHref(href) {
184
+ try {
185
+ const url = new URL(href);
186
+ url.pathname = this.parameterizeRoute(url.pathname);
187
+ return url.toString();
188
+ } catch (_e) {
189
+ return this.parameterizeRoute(href);
190
+ }
191
+ }
144
192
  emitRouteChange(from, to, trigger) {
145
193
  if (from === to) return;
194
+ const paramFrom = this.parameterizeRoute(from);
195
+ const paramTo = this.parameterizeRoute(to);
196
+ if (paramFrom === paramTo) return;
146
197
  const data = {
147
198
  plugin: "csr:routeChange",
148
199
  payload: {
149
- from,
150
- to,
200
+ from: paramFrom,
201
+ to: paramTo,
151
202
  trigger
152
203
  }
153
204
  };
@@ -11081,4 +11132,4 @@ function record(onEvent, config) {
11081
11132
  return () => recorder.stop();
11082
11133
  }
11083
11134
  //#endregion
11084
- export { DEFAULT_BLOCK_SELECTORS, DEFAULT_MASK_SELECTORS, Recorder, RecorderState, RrwebEngine, record };
11135
+ export { DEFAULT_BLOCK_SELECTORS, DEFAULT_MASK_SELECTORS, Recorder, RecorderState, RrwebEngine, defaultParameterizeRoute, record };
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.2",
4
+ "version": "0.17.3",
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.0-alpha.20",
38
- "@spotify-confidence/csr-common": "^0.17.2",
38
+ "@spotify-confidence/csr-common": "^0.17.3",
39
39
  "rrweb": "^2.0.0-alpha.20"
40
40
  },
41
41
  "module": "./dist/index.js",
package/src/index.ts CHANGED
@@ -9,3 +9,4 @@ export {
9
9
  DEFAULT_BLOCK_SELECTORS,
10
10
  } from './types';
11
11
  export { record } from './start-recording';
12
+ export { defaultParameterizeRoute } from './route-parameterizer';
@@ -8,10 +8,15 @@ class MockEngine implements RecordingEngine {
8
8
  private onEvent: ((event: RecordingEvent) => void) | null = null;
9
9
  startCalled = false;
10
10
  stopCalled = false;
11
+ /** Events emitted synchronously during start(), simulating rrweb behaviour. */
12
+ eventsOnStart: RecordingEvent[] = [];
11
13
 
12
14
  start(_config: unknown, onEvent: (event: RecordingEvent) => void): void {
13
15
  this.startCalled = true;
14
16
  this.onEvent = onEvent;
17
+ for (const event of this.eventsOnStart) {
18
+ onEvent(event);
19
+ }
15
20
  }
16
21
 
17
22
  stop(): void {
@@ -117,6 +122,22 @@ describe('Recorder route change capture', () => {
117
122
  recorder.stop();
118
123
  });
119
124
 
125
+ it('does not emit when parameterized routes are identical', () => {
126
+ const engine = new MockEngine();
127
+ const onEvent = vi.fn();
128
+ const recorder = new Recorder({ engine, onEvent });
129
+ recorder.start();
130
+
131
+ history.pushState({}, '', '/users/123');
132
+ history.pushState({}, '', '/users/456');
133
+
134
+ const events = routeChangeEvents(onEvent);
135
+ expect(events).toHaveLength(1);
136
+ expect(events[0].to).toBe('/users/:id');
137
+
138
+ recorder.stop();
139
+ });
140
+
120
141
  it('emits for popstate events', () => {
121
142
  const engine = new MockEngine();
122
143
  const onEvent = vi.fn();
@@ -140,6 +161,101 @@ describe('Recorder route change capture', () => {
140
161
  recorder.stop();
141
162
  });
142
163
 
164
+ it('parameterizes routes by default', () => {
165
+ const engine = new MockEngine();
166
+ const onEvent = vi.fn();
167
+ const recorder = new Recorder({ engine, onEvent });
168
+ recorder.start();
169
+
170
+ history.pushState({}, '', '/users/123');
171
+
172
+ const events = routeChangeEvents(onEvent);
173
+ expect(events).toHaveLength(1);
174
+ expect(events[0].to).toBe('/users/:id');
175
+
176
+ recorder.stop();
177
+ });
178
+
179
+ it('parameterizes UUIDs by default', () => {
180
+ const engine = new MockEngine();
181
+ const onEvent = vi.fn();
182
+ const recorder = new Recorder({ engine, onEvent });
183
+ recorder.start();
184
+
185
+ history.pushState({}, '', '/items/550e8400-e29b-41d4-a716-446655440000');
186
+
187
+ const events = routeChangeEvents(onEvent);
188
+ expect(events).toHaveLength(1);
189
+ expect(events[0].to).toBe('/items/:uuid');
190
+
191
+ recorder.stop();
192
+ });
193
+
194
+ it('uses a custom parameterizeRoute when provided', () => {
195
+ const engine = new MockEngine();
196
+ const onEvent = vi.fn();
197
+ const recorder = new Recorder({ engine, onEvent });
198
+ recorder.start({
199
+ parameterizeRoute: route => route.replace(/\/users\/[^/]+/, '/users/:userId'),
200
+ });
201
+
202
+ history.pushState({}, '', '/users/alice');
203
+
204
+ const events = routeChangeEvents(onEvent);
205
+ expect(events).toHaveLength(1);
206
+ expect(events[0].to).toBe('/users/:userId');
207
+
208
+ recorder.stop();
209
+ });
210
+
211
+ it('parameterizes href in meta events emitted by the engine', () => {
212
+ const engine = new MockEngine();
213
+ engine.eventsOnStart = [
214
+ {
215
+ type: RecordingEventType.Meta,
216
+ timestamp: 1,
217
+ data: { href: 'https://example.com/users/123/settings', width: 1920, height: 1080 },
218
+ },
219
+ ];
220
+ const onEvent = vi.fn();
221
+ const recorder = new Recorder({ engine, onEvent });
222
+ recorder.start();
223
+
224
+ const metaEvents = (onEvent.mock.calls as [RecordingEvent][])
225
+ .map(([e]) => e)
226
+ .filter(e => e.type === RecordingEventType.Meta);
227
+
228
+ expect(metaEvents).toHaveLength(1);
229
+ expect((metaEvents[0].data as { href: string }).href).toBe('https://example.com/users/:id/settings');
230
+
231
+ recorder.stop();
232
+ });
233
+
234
+ it('parameterizes meta href using custom parameterizeRoute', () => {
235
+ const engine = new MockEngine();
236
+ engine.eventsOnStart = [
237
+ {
238
+ type: RecordingEventType.Meta,
239
+ timestamp: 1,
240
+ data: { href: 'https://example.com/teams/acme-corp/dashboard', width: 1920, height: 1080 },
241
+ },
242
+ ];
243
+ const onEvent = vi.fn();
244
+ const recorder = new Recorder({ engine, onEvent });
245
+ recorder.start({
246
+ parameterizeRoute: route => route.replace(/\/teams\/[^/]+/, '/teams/:slug'),
247
+ });
248
+
249
+ const metaEvents = (onEvent.mock.calls as [RecordingEvent][])
250
+ .map(([e]) => e)
251
+ .filter(e => e.type === RecordingEventType.Meta);
252
+
253
+ expect(metaEvents).toHaveLength(1);
254
+ expect((metaEvents[0].data as { href: string }).href).toBe('https://example.com/teams/:slug/dashboard');
255
+
256
+ recorder.stop();
257
+ });
258
+
143
259
  it('removes popstate listener on stop', () => {
144
260
  const addSpy = vi.spyOn(window, 'addEventListener');
145
261
  const removeSpy = vi.spyOn(window, 'removeEventListener');
package/src/recorder.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  } from '@spotify-confidence/csr-common';
9
9
  import { RecorderOptions, RecorderState, RecordingConfig } from './types';
10
10
  import { RecordingEngine } from './engine';
11
+ import { defaultParameterizeRoute } from './route-parameterizer';
11
12
 
12
13
  export class Recorder {
13
14
  private readonly engine: RecordingEngine;
@@ -20,6 +21,7 @@ export class Recorder {
20
21
  private originalPushState: typeof history.pushState | null = null;
21
22
  private originalReplaceState: typeof history.replaceState | null = null;
22
23
  private popstateHandler: (() => void) | null = null;
24
+ private parameterizeRoute!: (route: string) => string;
23
25
 
24
26
  constructor(options: RecorderOptions) {
25
27
  this.engine = options.engine;
@@ -35,7 +37,15 @@ export class Recorder {
35
37
  return;
36
38
  }
37
39
  this.state = RecorderState.Recording;
40
+ this.parameterizeRoute = config?.parameterizeRoute ?? defaultParameterizeRoute;
41
+
38
42
  this.engine.start(config ?? {}, event => {
43
+ if (event.type === RecordingEventType.Meta) {
44
+ const data = event.data as { href?: string };
45
+ if (typeof data?.href === 'string') {
46
+ event = { ...event, data: { ...data, href: this.parameterizeHref(data.href) } };
47
+ }
48
+ }
39
49
  this.onEvent(event);
40
50
  });
41
51
 
@@ -174,11 +184,24 @@ export class Recorder {
174
184
  };
175
185
  }
176
186
 
187
+ private parameterizeHref(href: string): string {
188
+ try {
189
+ const url = new URL(href);
190
+ url.pathname = this.parameterizeRoute(url.pathname);
191
+ return url.toString();
192
+ } catch (_e) {
193
+ return this.parameterizeRoute(href);
194
+ }
195
+ }
196
+
177
197
  private emitRouteChange(from: string, to: string, trigger: RouteChangeTrigger): void {
178
198
  if (from === to) return;
199
+ const paramFrom = this.parameterizeRoute(from);
200
+ const paramTo = this.parameterizeRoute(to);
201
+ if (paramFrom === paramTo) return;
179
202
  const data: RouteChangePluginData = {
180
203
  plugin: 'csr:routeChange',
181
- payload: { from, to, trigger },
204
+ payload: { from: paramFrom, to: paramTo, trigger },
182
205
  };
183
206
  this.onEvent({
184
207
  type: RecordingEventType.Plugin,
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { defaultParameterizeRoute } from './route-parameterizer';
3
+
4
+ describe('defaultParameterizeRoute', () => {
5
+ it('leaves static routes unchanged', () => {
6
+ expect(defaultParameterizeRoute('/users/settings')).toBe('/users/settings');
7
+ });
8
+
9
+ it('replaces numeric IDs', () => {
10
+ expect(defaultParameterizeRoute('/users/123')).toBe('/users/:id');
11
+ expect(defaultParameterizeRoute('/users/123/posts/456')).toBe('/users/:id/posts/:id');
12
+ });
13
+
14
+ it('replaces UUIDs', () => {
15
+ expect(defaultParameterizeRoute('/users/550e8400-e29b-41d4-a716-446655440000')).toBe('/users/:uuid');
16
+ });
17
+
18
+ it('replaces uppercase UUIDs', () => {
19
+ expect(defaultParameterizeRoute('/users/550E8400-E29B-41D4-A716-446655440000')).toBe('/users/:uuid');
20
+ });
21
+
22
+ it('replaces long hex strings (MongoDB ObjectIDs)', () => {
23
+ expect(defaultParameterizeRoute('/items/507f1f77bcf86cd799439011')).toBe('/items/:id');
24
+ });
25
+
26
+ it('handles mixed segments', () => {
27
+ expect(defaultParameterizeRoute('/org/550e8400-e29b-41d4-a716-446655440000/users/42/profile')).toBe(
28
+ '/org/:uuid/users/:id/profile',
29
+ );
30
+ });
31
+
32
+ it('preserves root path', () => {
33
+ expect(defaultParameterizeRoute('/')).toBe('/');
34
+ });
35
+
36
+ it('preserves empty string', () => {
37
+ expect(defaultParameterizeRoute('')).toBe('');
38
+ });
39
+
40
+ it('does not replace short hex strings', () => {
41
+ expect(defaultParameterizeRoute('/features/abcdef')).toBe('/features/abcdef');
42
+ });
43
+
44
+ it('does not replace words that look vaguely hex-ish', () => {
45
+ expect(defaultParameterizeRoute('/dashboard/feed')).toBe('/dashboard/feed');
46
+ });
47
+
48
+ it('handles trailing slash', () => {
49
+ expect(defaultParameterizeRoute('/users/123/')).toBe('/users/:id/');
50
+ });
51
+
52
+ it('replaces AIP-122 generated IDs', () => {
53
+ expect(defaultParameterizeRoute('/workflows/abtest/instances/cmvkznnjmbkc9rw2oxws/report')).toBe(
54
+ '/workflows/abtest/instances/:id/report',
55
+ );
56
+ });
57
+
58
+ it('replaces multiple AIP-122 IDs', () => {
59
+ expect(defaultParameterizeRoute('/admin/workflows/a0smva5nxuhv4yts6pax/instances/cmvkznnjmbkc9rw2oxws')).toBe(
60
+ '/admin/workflows/:id/instances/:id',
61
+ );
62
+ });
63
+
64
+ it('does not replace short lowercase strings as AIP-122', () => {
65
+ expect(defaultParameterizeRoute('/workflows/abtest')).toBe('/workflows/abtest');
66
+ });
67
+
68
+ it('replaces segments containing embedded hex IDs', () => {
69
+ expect(defaultParameterizeRoute('/items/prefix507f1f77bcf86cd799439011suffix')).toBe('/items/:id');
70
+ });
71
+
72
+ it('replaces MD5 hashes', () => {
73
+ expect(defaultParameterizeRoute('/cache/d41d8cd98f00b204e9800998ecf8427e')).toBe('/cache/:id');
74
+ });
75
+ });
@@ -0,0 +1,20 @@
1
+ const SEGMENT_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
2
+ { pattern: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, replacement: ':uuid' },
3
+ { pattern: /^\d+$/, replacement: ':id' },
4
+ // AIP-122 generated IDs: 20-char lowercase alphanumeric starting with a letter
5
+ { pattern: /^[a-z][a-z0-9]{19}$/, replacement: ':id' },
6
+ { pattern: /[0-9a-f]{20}/i, replacement: ':id' },
7
+ ];
8
+
9
+ export function defaultParameterizeRoute(route: string): string {
10
+ return route
11
+ .split('/')
12
+ .map(segment => {
13
+ if (!segment) return segment;
14
+ for (const { pattern, replacement } of SEGMENT_PATTERNS) {
15
+ if (pattern.test(segment)) return replacement;
16
+ }
17
+ return segment;
18
+ })
19
+ .join('/');
20
+ }
package/src/types.ts CHANGED
@@ -55,6 +55,22 @@ export interface RecordingConfig {
55
55
  * hashes are stripped.
56
56
  */
57
57
  captureRouteChanges?: boolean;
58
+ /**
59
+ * Transform a raw pathname into a route pattern before it is emitted in
60
+ * route-change and Meta events. For example, `/users/123/profile` becomes
61
+ * `/users/:id/profile`. This ensures per-page metrics are grouped by route
62
+ * rather than by individual page visit.
63
+ *
64
+ * The default implementation replaces common dynamic segments:
65
+ * - UUIDs → `:uuid`
66
+ * - Numeric IDs → `:id`
67
+ * - AIP-122 IDs → `:id`
68
+ * - Long hex strings (20+ chars, e.g. MongoDB ObjectIDs) → `:id`
69
+ *
70
+ * Provide a custom function to handle application-specific patterns.
71
+ * Import `defaultParameterizeRoute` to compose with the built-in rules.
72
+ */
73
+ parameterizeRoute?: (route: string) => string;
58
74
  }
59
75
 
60
76
  export enum RecorderState {