@spotify-confidence/csr-common 0.18.8 → 0.18.9

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/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: 'csr:rageClick';
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: 'csr:formFieldReEdit';
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: 'csr:scrollBack';
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: 'csr:click';
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: 'csr:input';
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: 'csr/clipboard@1';
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: 'csr:deadClick';
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: 'csr:tabUnfocus';
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: 'csr:tabRefocus';
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: 'csr:routeChange';
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: 'csr:tabVisibility';
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: 'rrweb/console@1';
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: 'csr:networkRequest';
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: 'csr:routeChange';
306
+ plugin: typeof RecordingPluginName.RouteChange;
278
307
  payload: RouteChangePayload;
279
308
  };
280
309
 
281
310
  export type TagPluginData = {
282
- plugin: 'csr:tag';
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: 'csr:measure';
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: 'csr:flagEvaluation';
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: 'csr:errorMessage';
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: 'csr:dialogOpened';
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: 'csr:idleGap';
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: 'csr:awayGap';
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: Exclude<RecordingEventType, RecordingEventType.Custom | RecordingEventType.IncrementalSnapshot>;
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,
@@ -42,6 +45,10 @@ export {
42
45
 
43
46
  export { stripUrl } from './url';
44
47
 
48
+ export { RecordingMetricKey } from './metrics';
49
+
50
+ export { isSessionActivityEvent, isUserInteractionEvent, isUserInteractionMetric } from './session-activity';
51
+
45
52
  export {
46
53
  MAX_KEY_LENGTH,
47
54
  MAX_TAG_VALUE_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);
@@ -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;
@@ -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. */