@spotify-confidence/csr-common 0.18.8 → 0.18.10
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/dist/index.cjs +13 -84
- package/dist/index.d.cts +80 -24
- package/dist/index.d.ts +80 -24
- package/dist/index.js +2 -80
- package/dist/session-activity-B8swR5Wg.cjs +241 -0
- package/dist/session-activity-tshS9Hua.js +170 -0
- package/dist/{types-CtBIVgp2.d.cts → types-C3mO_2YH.d.cts} +3 -1
- package/dist/{types-CtBIVgp2.d.ts → types-C3mO_2YH.d.ts} +3 -1
- package/dist/uploader/index.cjs +3 -1
- package/dist/uploader/index.d.cts +1 -1
- package/dist/uploader/index.d.ts +1 -1
- package/dist/uploader/index.js +3 -1
- package/package.json +1 -1
- package/src/events.ts +69 -22
- package/src/index.ts +8 -1
- package/src/metrics.ts +15 -0
- package/src/session-activity.test.ts +72 -0
- package/src/session-activity.ts +64 -0
- package/src/uploader/client-context.test.ts +27 -0
- package/src/uploader/client-context.ts +3 -1
- package/src/uploader/create-uploader.test.ts +72 -0
- package/src/uploader/create-uploader.ts +2 -0
- package/src/uploader/types.ts +2 -0
- package/src/url.ts +5 -0
package/src/events.ts
CHANGED
|
@@ -23,6 +23,35 @@ export enum RecordingEventType {
|
|
|
23
23
|
Plugin = 6,
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export const RecordingCustomEventTag = {
|
|
27
|
+
RageClick: 'csr:rageClick',
|
|
28
|
+
FormFieldReEdit: 'csr:formFieldReEdit',
|
|
29
|
+
ScrollBack: 'csr:scrollBack',
|
|
30
|
+
Click: 'csr:click',
|
|
31
|
+
Input: 'csr:input',
|
|
32
|
+
DeadClick: 'csr:deadClick',
|
|
33
|
+
TabUnfocus: 'csr:tabUnfocus',
|
|
34
|
+
TabRefocus: 'csr:tabRefocus',
|
|
35
|
+
RouteChange: 'csr:routeChange',
|
|
36
|
+
ErrorMessage: 'csr:errorMessage',
|
|
37
|
+
DialogOpened: 'csr:dialogOpened',
|
|
38
|
+
IdleGap: 'csr:idleGap',
|
|
39
|
+
AwayGap: 'csr:awayGap',
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
export const RecordingPluginName = {
|
|
43
|
+
BlockedElementLabels: 'csr/blocked-element-labels@1',
|
|
44
|
+
Clipboard: 'csr/clipboard@1',
|
|
45
|
+
ClickModifiers: 'csr/click-modifiers@1',
|
|
46
|
+
TabVisibility: 'csr:tabVisibility',
|
|
47
|
+
ConsoleLog: 'rrweb/console@1',
|
|
48
|
+
NetworkRequest: 'csr:networkRequest',
|
|
49
|
+
RouteChange: 'csr:routeChange',
|
|
50
|
+
Tag: 'csr:tag',
|
|
51
|
+
Measure: 'csr:measure',
|
|
52
|
+
FlagEvaluation: 'csr:flagEvaluation',
|
|
53
|
+
} as const;
|
|
54
|
+
|
|
26
55
|
/**
|
|
27
56
|
* Incremental snapshot sub-types.
|
|
28
57
|
*/
|
|
@@ -107,7 +136,7 @@ export type OpaqueIncrementalData = {
|
|
|
107
136
|
export type IncrementalSnapshotData = MouseInteractionData | SelectionData | OpaqueIncrementalData;
|
|
108
137
|
|
|
109
138
|
export type RageClickCustomData = {
|
|
110
|
-
tag:
|
|
139
|
+
tag: typeof RecordingCustomEventTag.RageClick;
|
|
111
140
|
payload: {
|
|
112
141
|
eventId?: string;
|
|
113
142
|
targetId: number;
|
|
@@ -119,7 +148,7 @@ export type RageClickCustomData = {
|
|
|
119
148
|
};
|
|
120
149
|
|
|
121
150
|
export type FormFieldReEditCustomData = {
|
|
122
|
-
tag:
|
|
151
|
+
tag: typeof RecordingCustomEventTag.FormFieldReEdit;
|
|
123
152
|
payload: {
|
|
124
153
|
eventId?: string;
|
|
125
154
|
targetId: number;
|
|
@@ -130,7 +159,7 @@ export type FormFieldReEditCustomData = {
|
|
|
130
159
|
};
|
|
131
160
|
|
|
132
161
|
export type ScrollBackCustomData = {
|
|
133
|
-
tag:
|
|
162
|
+
tag: typeof RecordingCustomEventTag.ScrollBack;
|
|
134
163
|
payload: {
|
|
135
164
|
eventId?: string;
|
|
136
165
|
scrollBackPx: number;
|
|
@@ -148,7 +177,7 @@ export type ElementDescriptor = {
|
|
|
148
177
|
};
|
|
149
178
|
|
|
150
179
|
export type ClickCustomData = {
|
|
151
|
-
tag:
|
|
180
|
+
tag: typeof RecordingCustomEventTag.Click;
|
|
152
181
|
payload: {
|
|
153
182
|
eventId?: string;
|
|
154
183
|
targetId: number;
|
|
@@ -163,7 +192,7 @@ export type ClickCustomData = {
|
|
|
163
192
|
};
|
|
164
193
|
|
|
165
194
|
export type InputCustomData = {
|
|
166
|
-
tag:
|
|
195
|
+
tag: typeof RecordingCustomEventTag.Input;
|
|
167
196
|
payload: {
|
|
168
197
|
eventId?: string;
|
|
169
198
|
targetId: number;
|
|
@@ -178,7 +207,7 @@ export type InputCustomData = {
|
|
|
178
207
|
export type ClipboardAction = 'copy' | 'cut' | 'paste';
|
|
179
208
|
|
|
180
209
|
export type ClipboardPluginData = {
|
|
181
|
-
plugin:
|
|
210
|
+
plugin: typeof RecordingPluginName.Clipboard;
|
|
182
211
|
payload: {
|
|
183
212
|
action: ClipboardAction;
|
|
184
213
|
targetId: number;
|
|
@@ -186,7 +215,7 @@ export type ClipboardPluginData = {
|
|
|
186
215
|
};
|
|
187
216
|
|
|
188
217
|
export type DeadClickCustomData = {
|
|
189
|
-
tag:
|
|
218
|
+
tag: typeof RecordingCustomEventTag.DeadClick;
|
|
190
219
|
payload: {
|
|
191
220
|
eventId?: string;
|
|
192
221
|
targetId: number;
|
|
@@ -196,7 +225,7 @@ export type DeadClickCustomData = {
|
|
|
196
225
|
};
|
|
197
226
|
|
|
198
227
|
export type TabUnfocusCustomData = {
|
|
199
|
-
tag:
|
|
228
|
+
tag: typeof RecordingCustomEventTag.TabUnfocus;
|
|
200
229
|
payload: {
|
|
201
230
|
eventId?: string;
|
|
202
231
|
pathname?: string;
|
|
@@ -204,7 +233,7 @@ export type TabUnfocusCustomData = {
|
|
|
204
233
|
};
|
|
205
234
|
|
|
206
235
|
export type TabRefocusCustomData = {
|
|
207
|
-
tag:
|
|
236
|
+
tag: typeof RecordingCustomEventTag.TabRefocus;
|
|
208
237
|
payload: {
|
|
209
238
|
eventId?: string;
|
|
210
239
|
awayDurationMs: number;
|
|
@@ -221,7 +250,7 @@ export type RouteChangePayload = {
|
|
|
221
250
|
};
|
|
222
251
|
|
|
223
252
|
export type RouteChangeCustomData = {
|
|
224
|
-
tag:
|
|
253
|
+
tag: typeof RecordingCustomEventTag.RouteChange;
|
|
225
254
|
// Intersected rather than added to RouteChangePayload so the plugin-event
|
|
226
255
|
// shape (RouteChangePluginData) stays free of analyzer identity.
|
|
227
256
|
payload: RouteChangePayload & { eventId?: string };
|
|
@@ -231,7 +260,7 @@ export type RouteChangeCustomData = {
|
|
|
231
260
|
* Plugin event data emitted by the recorder for tab visibility changes.
|
|
232
261
|
*/
|
|
233
262
|
export type TabVisibilityPluginData = {
|
|
234
|
-
plugin:
|
|
263
|
+
plugin: typeof RecordingPluginName.TabVisibility;
|
|
235
264
|
payload: { hidden: boolean };
|
|
236
265
|
};
|
|
237
266
|
|
|
@@ -242,7 +271,7 @@ export type ConsoleLogLevel = 'log' | 'warn' | 'error' | 'debug' | 'info';
|
|
|
242
271
|
* Shape matches `@rrweb/rrweb-plugin-console-record` LogData.
|
|
243
272
|
*/
|
|
244
273
|
export type ConsoleLogPluginData = {
|
|
245
|
-
plugin:
|
|
274
|
+
plugin: typeof RecordingPluginName.ConsoleLog;
|
|
246
275
|
payload: {
|
|
247
276
|
level: ConsoleLogLevel;
|
|
248
277
|
payload: string[];
|
|
@@ -260,7 +289,7 @@ export type GraphQLRequestMetadata = {
|
|
|
260
289
|
* Plugin event data emitted by the recorder for network requests.
|
|
261
290
|
*/
|
|
262
291
|
export type NetworkRequestPluginData = {
|
|
263
|
-
plugin:
|
|
292
|
+
plugin: typeof RecordingPluginName.NetworkRequest;
|
|
264
293
|
payload: {
|
|
265
294
|
initiator: NetworkRequestInitiator;
|
|
266
295
|
method: string;
|
|
@@ -274,12 +303,12 @@ export type NetworkRequestPluginData = {
|
|
|
274
303
|
};
|
|
275
304
|
|
|
276
305
|
export type RouteChangePluginData = {
|
|
277
|
-
plugin:
|
|
306
|
+
plugin: typeof RecordingPluginName.RouteChange;
|
|
278
307
|
payload: RouteChangePayload;
|
|
279
308
|
};
|
|
280
309
|
|
|
281
310
|
export type TagPluginData = {
|
|
282
|
-
plugin:
|
|
311
|
+
plugin: typeof RecordingPluginName.Tag;
|
|
283
312
|
payload: {
|
|
284
313
|
key: string;
|
|
285
314
|
value?: string;
|
|
@@ -287,7 +316,7 @@ export type TagPluginData = {
|
|
|
287
316
|
};
|
|
288
317
|
|
|
289
318
|
export type MeasurePluginData = {
|
|
290
|
-
plugin:
|
|
319
|
+
plugin: typeof RecordingPluginName.Measure;
|
|
291
320
|
payload: {
|
|
292
321
|
key: string;
|
|
293
322
|
value?: number;
|
|
@@ -295,7 +324,7 @@ export type MeasurePluginData = {
|
|
|
295
324
|
};
|
|
296
325
|
|
|
297
326
|
export type FlagEvaluationPluginData = {
|
|
298
|
-
plugin:
|
|
327
|
+
plugin: typeof RecordingPluginName.FlagEvaluation;
|
|
299
328
|
payload: {
|
|
300
329
|
flagKey: string;
|
|
301
330
|
variant: string;
|
|
@@ -303,8 +332,18 @@ export type FlagEvaluationPluginData = {
|
|
|
303
332
|
};
|
|
304
333
|
};
|
|
305
334
|
|
|
335
|
+
export type PluginEventData =
|
|
336
|
+
| ClipboardPluginData
|
|
337
|
+
| TabVisibilityPluginData
|
|
338
|
+
| ConsoleLogPluginData
|
|
339
|
+
| NetworkRequestPluginData
|
|
340
|
+
| RouteChangePluginData
|
|
341
|
+
| TagPluginData
|
|
342
|
+
| MeasurePluginData
|
|
343
|
+
| FlagEvaluationPluginData;
|
|
344
|
+
|
|
306
345
|
export type ErrorMessageCustomData = {
|
|
307
|
-
tag:
|
|
346
|
+
tag: typeof RecordingCustomEventTag.ErrorMessage;
|
|
308
347
|
payload: {
|
|
309
348
|
eventId?: string;
|
|
310
349
|
text: string;
|
|
@@ -312,7 +351,7 @@ export type ErrorMessageCustomData = {
|
|
|
312
351
|
};
|
|
313
352
|
|
|
314
353
|
export type DialogOpenedCustomData = {
|
|
315
|
-
tag:
|
|
354
|
+
tag: typeof RecordingCustomEventTag.DialogOpened;
|
|
316
355
|
payload: {
|
|
317
356
|
eventId?: string;
|
|
318
357
|
content: string[];
|
|
@@ -320,7 +359,7 @@ export type DialogOpenedCustomData = {
|
|
|
320
359
|
};
|
|
321
360
|
|
|
322
361
|
export type IdleGapCustomData = {
|
|
323
|
-
tag:
|
|
362
|
+
tag: typeof RecordingCustomEventTag.IdleGap;
|
|
324
363
|
payload: {
|
|
325
364
|
eventId?: string;
|
|
326
365
|
visibleGapS: number;
|
|
@@ -331,7 +370,7 @@ export type IdleGapCustomData = {
|
|
|
331
370
|
};
|
|
332
371
|
|
|
333
372
|
export type AwayGapCustomData = {
|
|
334
|
-
tag:
|
|
373
|
+
tag: typeof RecordingCustomEventTag.AwayGap;
|
|
335
374
|
payload: {
|
|
336
375
|
eventId?: string;
|
|
337
376
|
totalGapS: number;
|
|
@@ -382,7 +421,15 @@ export type RecordingEvent =
|
|
|
382
421
|
data: IncrementalSnapshotData;
|
|
383
422
|
}
|
|
384
423
|
| {
|
|
385
|
-
type:
|
|
424
|
+
type: RecordingEventType.Plugin;
|
|
425
|
+
timestamp: number;
|
|
426
|
+
data: PluginEventData;
|
|
427
|
+
}
|
|
428
|
+
| {
|
|
429
|
+
type: Exclude<
|
|
430
|
+
RecordingEventType,
|
|
431
|
+
RecordingEventType.Custom | RecordingEventType.IncrementalSnapshot | RecordingEventType.Plugin
|
|
432
|
+
>;
|
|
386
433
|
timestamp: number;
|
|
387
434
|
data: unknown;
|
|
388
435
|
};
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
export {
|
|
2
2
|
SerializedNodeType,
|
|
3
3
|
RecordingEventType,
|
|
4
|
+
RecordingCustomEventTag,
|
|
5
|
+
RecordingPluginName,
|
|
4
6
|
IncrementalSource,
|
|
5
7
|
MouseInteractions,
|
|
6
8
|
type RecordingEvent,
|
|
9
|
+
type PluginEventData,
|
|
7
10
|
type IncrementalSnapshotData,
|
|
8
11
|
type MouseInteractionData,
|
|
9
12
|
type OpaqueIncrementalData,
|
|
@@ -40,7 +43,11 @@ export {
|
|
|
40
43
|
type FlagEvaluationPluginData,
|
|
41
44
|
} from './events';
|
|
42
45
|
|
|
43
|
-
export { stripUrl } from './url';
|
|
46
|
+
export { stripUrl, stripUrlQueryAndHash } from './url';
|
|
47
|
+
|
|
48
|
+
export { RecordingMetricKey } from './metrics';
|
|
49
|
+
|
|
50
|
+
export { isSessionActivityEvent, isUserInteractionEvent, isUserInteractionMetric } from './session-activity';
|
|
44
51
|
|
|
45
52
|
export {
|
|
46
53
|
MAX_KEY_LENGTH,
|
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const RecordingMetricKey = {
|
|
2
|
+
Click: 'clicks',
|
|
3
|
+
Input: 'inputs',
|
|
4
|
+
RageClick: 'rageClicks',
|
|
5
|
+
DeadClick: 'deadClicks',
|
|
6
|
+
ScrollBack: 'scrollBacks',
|
|
7
|
+
TabUnfocus: 'tabUnfocuses',
|
|
8
|
+
ErrorMessage: 'errorMessages',
|
|
9
|
+
FailedNetworkRequest: 'networkRequestsFailed',
|
|
10
|
+
NetworkRequest: 'networkRequests',
|
|
11
|
+
ConsoleLog: 'consoleLogs',
|
|
12
|
+
ConsoleWarning: 'consoleWarnings',
|
|
13
|
+
ConsoleError: 'consoleErrors',
|
|
14
|
+
RouteChange: 'routeChanges',
|
|
15
|
+
} as const;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { IncrementalSource, RecordingCustomEventTag, RecordingEventType, RecordingPluginName } from './events';
|
|
3
|
+
import { RecordingMetricKey } from './metrics';
|
|
4
|
+
import { isSessionActivityEvent, isUserInteractionEvent, isUserInteractionMetric } from './session-activity';
|
|
5
|
+
|
|
6
|
+
describe('session activity', () => {
|
|
7
|
+
it.each([
|
|
8
|
+
{
|
|
9
|
+
type: RecordingEventType.IncrementalSnapshot,
|
|
10
|
+
data: { source: IncrementalSource.MouseMove },
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
type: RecordingEventType.IncrementalSnapshot,
|
|
14
|
+
data: { source: IncrementalSource.MouseInteraction },
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
type: RecordingEventType.Custom,
|
|
18
|
+
data: { tag: RecordingCustomEventTag.Input },
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
type: RecordingEventType.Plugin,
|
|
22
|
+
data: { plugin: RecordingPluginName.RouteChange },
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
type: RecordingEventType.Custom,
|
|
26
|
+
data: { tag: RecordingCustomEventTag.TabUnfocus },
|
|
27
|
+
},
|
|
28
|
+
])('identifies user interaction events', event => {
|
|
29
|
+
expect(isUserInteractionEvent(event)).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it.each([
|
|
33
|
+
{
|
|
34
|
+
type: RecordingEventType.IncrementalSnapshot,
|
|
35
|
+
data: { source: IncrementalSource.Mutation },
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
type: RecordingEventType.Plugin,
|
|
39
|
+
data: { plugin: RecordingPluginName.NetworkRequest },
|
|
40
|
+
},
|
|
41
|
+
null,
|
|
42
|
+
{},
|
|
43
|
+
])('ignores passive and malformed events', event => {
|
|
44
|
+
expect(isUserInteractionEvent(event)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('treats page metadata as session activity without treating it as an interaction', () => {
|
|
48
|
+
const event = { type: RecordingEventType.Meta, data: {} };
|
|
49
|
+
|
|
50
|
+
expect(isUserInteractionEvent(event)).toBe(false);
|
|
51
|
+
expect(isSessionActivityEvent(event)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it.each([
|
|
55
|
+
RecordingMetricKey.Click,
|
|
56
|
+
RecordingMetricKey.Input,
|
|
57
|
+
RecordingMetricKey.RageClick,
|
|
58
|
+
RecordingMetricKey.DeadClick,
|
|
59
|
+
RecordingMetricKey.ScrollBack,
|
|
60
|
+
RecordingMetricKey.TabUnfocus,
|
|
61
|
+
RecordingMetricKey.RouteChange,
|
|
62
|
+
])('identifies user interaction metrics', metricKey => {
|
|
63
|
+
expect(isUserInteractionMetric(metricKey)).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it.each([RecordingMetricKey.NetworkRequest, RecordingMetricKey.ConsoleError, 'unknownMetric'])(
|
|
67
|
+
'ignores passive metrics',
|
|
68
|
+
metricKey => {
|
|
69
|
+
expect(isUserInteractionMetric(metricKey)).toBe(false);
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { IncrementalSource, RecordingCustomEventTag, RecordingEventType, RecordingPluginName } from './events';
|
|
2
|
+
import { RecordingMetricKey } from './metrics';
|
|
3
|
+
|
|
4
|
+
const USER_INTERACTION_SOURCES = new Set<number>([
|
|
5
|
+
IncrementalSource.MouseMove,
|
|
6
|
+
IncrementalSource.MouseInteraction,
|
|
7
|
+
IncrementalSource.Scroll,
|
|
8
|
+
IncrementalSource.Input,
|
|
9
|
+
IncrementalSource.TouchMove,
|
|
10
|
+
IncrementalSource.MediaInteraction,
|
|
11
|
+
IncrementalSource.Drag,
|
|
12
|
+
IncrementalSource.Selection,
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const USER_INTERACTION_TAGS = new Set<string>([
|
|
16
|
+
RecordingCustomEventTag.Click,
|
|
17
|
+
RecordingCustomEventTag.Input,
|
|
18
|
+
RecordingCustomEventTag.RageClick,
|
|
19
|
+
RecordingCustomEventTag.DeadClick,
|
|
20
|
+
RecordingCustomEventTag.ScrollBack,
|
|
21
|
+
RecordingCustomEventTag.TabUnfocus,
|
|
22
|
+
RecordingCustomEventTag.TabRefocus,
|
|
23
|
+
RecordingCustomEventTag.FormFieldReEdit,
|
|
24
|
+
RecordingCustomEventTag.RouteChange,
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const USER_INTERACTION_PLUGINS = new Set<string>([RecordingPluginName.RouteChange]);
|
|
28
|
+
|
|
29
|
+
const USER_INTERACTION_METRIC_KEYS = new Set<string>([
|
|
30
|
+
RecordingMetricKey.Click,
|
|
31
|
+
RecordingMetricKey.Input,
|
|
32
|
+
RecordingMetricKey.RageClick,
|
|
33
|
+
RecordingMetricKey.DeadClick,
|
|
34
|
+
RecordingMetricKey.ScrollBack,
|
|
35
|
+
RecordingMetricKey.TabUnfocus,
|
|
36
|
+
RecordingMetricKey.RouteChange,
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
|
|
40
|
+
|
|
41
|
+
export const isUserInteractionEvent = (event: unknown): boolean => {
|
|
42
|
+
if (!isObject(event) || !isObject(event.data)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (event.type === RecordingEventType.IncrementalSnapshot) {
|
|
47
|
+
return typeof event.data.source === 'number' && USER_INTERACTION_SOURCES.has(event.data.source);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (event.type === RecordingEventType.Custom) {
|
|
51
|
+
return typeof event.data.tag === 'string' && USER_INTERACTION_TAGS.has(event.data.tag);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
event.type === RecordingEventType.Plugin &&
|
|
56
|
+
typeof event.data.plugin === 'string' &&
|
|
57
|
+
USER_INTERACTION_PLUGINS.has(event.data.plugin)
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const isUserInteractionMetric = (metricKey: string): boolean => USER_INTERACTION_METRIC_KEYS.has(metricKey);
|
|
62
|
+
|
|
63
|
+
export const isSessionActivityEvent = (event: unknown): boolean =>
|
|
64
|
+
(isObject(event) && event.type === RecordingEventType.Meta) || isUserInteractionEvent(event);
|
|
@@ -84,6 +84,33 @@ describe('collectUserAgentContext', () => {
|
|
|
84
84
|
});
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
+
it.each([
|
|
88
|
+
['https://referrer.example/path?email=alice@example.com#token', 'https://referrer.example/path'],
|
|
89
|
+
['https://referrer.example/path#token?email=alice@example.com', 'https://referrer.example/path'],
|
|
90
|
+
['https://referrer.example/path?email=alice@example.com', 'https://referrer.example/path'],
|
|
91
|
+
['https://referrer.example/path#token', 'https://referrer.example/path'],
|
|
92
|
+
['', ''],
|
|
93
|
+
['invalid-url?email=alice@example.com#token', 'invalid-url'],
|
|
94
|
+
])('strips query/hash from referrer %s', (referrer, expected) => {
|
|
95
|
+
stubBrowser({ document: { referrer } });
|
|
96
|
+
expect(collectUserAgentContext()?.referrer).toBe(expected);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('excludes query/hash from the initial document URI', () => {
|
|
100
|
+
stubBrowser({
|
|
101
|
+
window: {
|
|
102
|
+
location: {
|
|
103
|
+
origin: 'https://example.com',
|
|
104
|
+
pathname: '/path',
|
|
105
|
+
search: '?email=alice@example.com',
|
|
106
|
+
hash: '#token',
|
|
107
|
+
href: 'https://example.com/path?email=alice@example.com#token',
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
expect(collectUserAgentContext()?.uri).toBe('https://example.com/path');
|
|
112
|
+
});
|
|
113
|
+
|
|
87
114
|
it('flags `mobile` platform type as mobile=true', () => {
|
|
88
115
|
parse.mockReturnValueOnce({
|
|
89
116
|
os: {},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Bowser from 'bowser';
|
|
2
|
+
import { stripUrlQueryAndHash } from '../url';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* JSON-shaped values accepted in a Context — matches `google.protobuf.Struct`.
|
|
@@ -31,6 +32,7 @@ export type UserAgentContext = {
|
|
|
31
32
|
devicePixelRatio?: number;
|
|
32
33
|
/** Initial document URI — without query/hash to avoid leaking PII. */
|
|
33
34
|
uri?: string;
|
|
35
|
+
/** Document referrer — without query/hash to avoid leaking PII. */
|
|
34
36
|
referrer?: string;
|
|
35
37
|
};
|
|
36
38
|
|
|
@@ -65,6 +67,6 @@ export function collectUserAgentContext(): UserAgentContext | undefined {
|
|
|
65
67
|
screenHeight: window.screen.height,
|
|
66
68
|
devicePixelRatio: window.devicePixelRatio,
|
|
67
69
|
uri: `${window.location.origin}${window.location.pathname}`,
|
|
68
|
-
referrer: document.referrer,
|
|
70
|
+
referrer: stripUrlQueryAndHash(document.referrer),
|
|
69
71
|
};
|
|
70
72
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { IncrementalSource, RecordingEventType, RecordingPluginName } from '../events';
|
|
2
3
|
import type { CreateUploaderOptions } from './types';
|
|
3
4
|
|
|
4
5
|
vi.mock('./worker/worker-script', () => ({
|
|
@@ -89,6 +90,7 @@ describe('createUploader', () => {
|
|
|
89
90
|
|
|
90
91
|
afterEach(() => {
|
|
91
92
|
vi.restoreAllMocks();
|
|
93
|
+
vi.unstubAllGlobals();
|
|
92
94
|
blobUrls.length = 0;
|
|
93
95
|
delete (globalThis as Record<string, unknown>).SharedWorker;
|
|
94
96
|
});
|
|
@@ -323,4 +325,74 @@ describe('createUploader', () => {
|
|
|
323
325
|
);
|
|
324
326
|
});
|
|
325
327
|
});
|
|
328
|
+
|
|
329
|
+
it('marks active and passive frames for the backend inactivity timeout', async () => {
|
|
330
|
+
const messages: unknown[] = [];
|
|
331
|
+
vi.stubGlobal('window', {
|
|
332
|
+
addEventListener() {},
|
|
333
|
+
devicePixelRatio: 1,
|
|
334
|
+
innerHeight: 800,
|
|
335
|
+
innerWidth: 1200,
|
|
336
|
+
location: { origin: 'https://example.com', pathname: '/' },
|
|
337
|
+
screen: { height: 800, width: 1200 },
|
|
338
|
+
});
|
|
339
|
+
vi.stubGlobal('document', {
|
|
340
|
+
addEventListener() {},
|
|
341
|
+
referrer: '',
|
|
342
|
+
visibilityState: 'visible',
|
|
343
|
+
});
|
|
344
|
+
vi.stubGlobal('navigator', {
|
|
345
|
+
language: 'en',
|
|
346
|
+
userAgent: 'test',
|
|
347
|
+
});
|
|
348
|
+
vi.stubGlobal(
|
|
349
|
+
'Worker',
|
|
350
|
+
class {
|
|
351
|
+
onerror: ((event: Event) => void) | null = null;
|
|
352
|
+
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
353
|
+
|
|
354
|
+
postMessage(message: unknown) {
|
|
355
|
+
messages.push(message);
|
|
356
|
+
if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'hello') {
|
|
357
|
+
queueMicrotask(() =>
|
|
358
|
+
this.onmessage?.(
|
|
359
|
+
new MessageEvent('message', {
|
|
360
|
+
data: {
|
|
361
|
+
type: 'welcome',
|
|
362
|
+
result: { sessionId: 'sessions/test', sessionToken: 'token' },
|
|
363
|
+
},
|
|
364
|
+
}),
|
|
365
|
+
),
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
);
|
|
371
|
+
vi.stubGlobal('SharedWorker', undefined);
|
|
372
|
+
|
|
373
|
+
const createUploader = await loadCreateUploader();
|
|
374
|
+
const uploader = await createUploader({ ...DEFAULTS, workerMode: 'dedicated' });
|
|
375
|
+
|
|
376
|
+
uploader?.({
|
|
377
|
+
type: RecordingEventType.Plugin,
|
|
378
|
+
data: { plugin: RecordingPluginName.NetworkRequest },
|
|
379
|
+
});
|
|
380
|
+
uploader?.({
|
|
381
|
+
type: RecordingEventType.IncrementalSnapshot,
|
|
382
|
+
data: { source: IncrementalSource.MouseInteraction },
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
expect(messages).toEqual(
|
|
386
|
+
expect.arrayContaining([
|
|
387
|
+
expect.objectContaining({
|
|
388
|
+
type: 'frame',
|
|
389
|
+
frame: expect.objectContaining({ userActivity: false }),
|
|
390
|
+
}),
|
|
391
|
+
expect.objectContaining({
|
|
392
|
+
type: 'frame',
|
|
393
|
+
frame: expect.objectContaining({ userActivity: true }),
|
|
394
|
+
}),
|
|
395
|
+
]),
|
|
396
|
+
);
|
|
397
|
+
});
|
|
326
398
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CreateUploaderOptions, Frame, Uploader } from './types';
|
|
2
2
|
import { ClientContext, collectUserAgentContext } from './client-context';
|
|
3
|
+
import { isSessionActivityEvent } from '../session-activity';
|
|
3
4
|
import { workerScript } from './worker/worker-script';
|
|
4
5
|
|
|
5
6
|
const STORAGE_TAB_ID = 'csr:tabId';
|
|
@@ -213,6 +214,7 @@ export async function createUploader(opts: CreateUploaderOptions): Promise<Uploa
|
|
|
213
214
|
tabId: effectiveTabId,
|
|
214
215
|
eventCounter: counter,
|
|
215
216
|
data: event,
|
|
217
|
+
userActivity: isSessionActivityEvent(event),
|
|
216
218
|
...(nextAdoptionMeta ?? {}),
|
|
217
219
|
};
|
|
218
220
|
counter += 1;
|
package/src/uploader/types.ts
CHANGED
|
@@ -94,6 +94,8 @@ export interface Frame {
|
|
|
94
94
|
eventCounter: number;
|
|
95
95
|
/** Opaque payload from the recorder. */
|
|
96
96
|
data: unknown;
|
|
97
|
+
/** Whether the frame should refresh the backend's session inactivity timeout. */
|
|
98
|
+
userActivity?: boolean;
|
|
97
99
|
/** Set only on the first frame emitted after the tab was adopted into a different session. */
|
|
98
100
|
adoptedFromSessionId?: string;
|
|
99
101
|
/** Epoch millis of the adoption event. */
|
package/src/url.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/** Remove query strings and fragments, including from relative or malformed URLs. */
|
|
2
|
+
export function stripUrlQueryAndHash(url: string): string {
|
|
3
|
+
return url.split(/[?#]/, 1)[0];
|
|
4
|
+
}
|
|
5
|
+
|
|
1
6
|
/**
|
|
2
7
|
* Extract only the pathname from a URL, stripping origin, query string, and
|
|
3
8
|
* hash. Used across recorder and analyzer to avoid capturing PII in route data.
|