@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 +14 -0
- package/README.md +28 -0
- package/dist/index.cjs +54 -2
- package/dist/index.d.cts +22 -1
- package/dist/index.d.ts +22 -1
- package/dist/index.js +54 -3
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/recorder-routing.test.ts +116 -0
- package/src/recorder.ts +24 -1
- package/src/route-parameterizer.test.ts +75 -0
- package/src/route-parameterizer.ts +20 -0
- package/src/types.ts +16 -0
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
@@ -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 {
|