@dash0/sdk-web 0.25.0 → 0.26.0

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.
@@ -1,9 +1,10 @@
1
1
  import { vars } from "../../vars";
2
2
  import { sessionId } from "../../api/session";
3
- import { debug, generateUniqueId, isSessionSampledIn, TRACE_ID_BYTES, warn, win } from "../../utils";
3
+ import { debug, doc, generateUniqueId, isSessionSampledIn, TRACE_ID_BYTES, warn, win } from "../../utils";
4
4
  import { generateTraceId } from "../../utils/trace-id";
5
5
  import { generateSpanId } from "../../utils/span-id";
6
6
  import { isUrlIgnored } from "../../utils/ignore-rules";
7
+ import { addEventListener } from "../../utils/listeners";
7
8
  import { onLastChance } from "../../utils/on-last-chance";
8
9
  import { sendSessionRecordingChunk } from "../../transport";
9
10
  import { SessionRecorder, SessionRecordingEvent } from "../../types/session-recording";
@@ -21,9 +22,14 @@ let armed = false;
21
22
  let stopRecorder: (() => void) | undefined;
22
23
  let chunker: Chunker | undefined;
23
24
  let lastChanceRegistered = false;
24
- // True while the last-chance handler flushes. The chunk emitted then must be sent uncompressed: gzip is
25
- // asynchronous, and a document that is being unloaded may never get to the `fetch()` behind the await.
26
- let flushingOnLastChance = false;
25
+ let visibilityRegistered = false;
26
+ // Set by the public `stopSessionRecording()`, so a visibility change does not resurrect a recording the
27
+ // consumer deliberately ended. Cleared by an explicit start: registering a recorder or arming.
28
+ let stoppedByConsumer = false;
29
+ // True while flushing a document that may not live much longer. The chunk emitted then must be sent
30
+ // uncompressed: gzip is asynchronous, and a document that is being unloaded — or that has just been hidden,
31
+ // and may be unloaded or throttled at any moment — may never get to the `fetch()` behind the await.
32
+ let flushingWhileDocumentMayEnd = false;
27
33
 
28
34
  /**
29
35
  * Makes a recorder available. Called from the public `startSessionRecording` API, which the
@@ -32,6 +38,7 @@ let flushingOnLastChance = false;
32
38
  */
33
39
  export function registerSessionRecorder(r: SessionRecorder): void {
34
40
  recorder = r;
41
+ stoppedByConsumer = false;
35
42
  if (armed) {
36
43
  start();
37
44
  }
@@ -47,6 +54,7 @@ export function registerSessionRecorder(r: SessionRecorder): void {
47
54
  */
48
55
  export function armSessionRecording(): void {
49
56
  armed = true;
57
+ stoppedByConsumer = false;
50
58
  if (vars.sessionRecording.recorder) {
51
59
  recorder = vars.sessionRecording.recorder;
52
60
  } else if (!recorder) {
@@ -61,6 +69,19 @@ export function armSessionRecording(): void {
61
69
  }
62
70
 
63
71
  export function stopSessionRecording(): void {
72
+ stoppedByConsumer = true;
73
+ teardown();
74
+ }
75
+
76
+ export function isSessionRecording(): boolean {
77
+ return stopRecorder != null;
78
+ }
79
+
80
+ /**
81
+ * Stops the recorder and flushes what it buffered. Shared by the public stop and by the visibility handler,
82
+ * which differ only in whether the consumer asked for it.
83
+ */
84
+ function teardown(): void {
64
85
  if (stopRecorder) {
65
86
  try {
66
87
  stopRecorder();
@@ -73,8 +94,50 @@ export function stopSessionRecording(): void {
73
94
  chunker = undefined;
74
95
  }
75
96
 
76
- export function isSessionRecording(): boolean {
77
- return stopRecorder != null;
97
+ /**
98
+ * Whether the document is not on screen. Treats an absent `visibilityState` as visible, so an environment
99
+ * without the API records exactly as it did before rather than never recording at all.
100
+ *
101
+ * `prerender` counts as hidden: nobody is looking at a prerendered page.
102
+ */
103
+ function isDocumentHidden(): boolean {
104
+ const state = doc?.visibilityState;
105
+ return state != null && state !== "visible";
106
+ }
107
+
108
+ /**
109
+ * Recording follows visibility: a hidden tab is torn down and a shown tab starts a new recorder run.
110
+ *
111
+ * This is what lets one replayer play a whole session. rrweb's Replayer rebuilds from any full snapshot it
112
+ * plays through, but each rebuild resets its node-id mirror, so events from a document other than the one
113
+ * that produced the newest snapshot would address the wrong nodes. Recording only the visible document keeps
114
+ * the runs of a session from overlapping, so they concatenate into a single coherent stream. `record()` takes
115
+ * a full snapshot when it starts, so every run opens with one and needs no separate snapshot call.
116
+ *
117
+ * Not recording hidden tabs is also why a session stays small: a background tab left open for hours used to
118
+ * record mutations nobody ever saw.
119
+ */
120
+ function registerVisibilityHandling(): void {
121
+ if (visibilityRegistered || !doc) {
122
+ return;
123
+ }
124
+ visibilityRegistered = true;
125
+ addEventListener(doc, "visibilitychange", () => {
126
+ if (isDocumentHidden()) {
127
+ // Flushed the same way as an unload: a hidden document can be discarded or throttled before an
128
+ // asynchronous gzip completes.
129
+ flushingWhileDocumentMayEnd = true;
130
+ try {
131
+ teardown();
132
+ } finally {
133
+ flushingWhileDocumentMayEnd = false;
134
+ }
135
+ return;
136
+ }
137
+ if (!stoppedByConsumer) {
138
+ start();
139
+ }
140
+ });
78
141
  }
79
142
 
80
143
  function start(): void {
@@ -99,6 +162,14 @@ function start(): void {
99
162
  return;
100
163
  }
101
164
 
165
+ registerVisibilityHandling();
166
+ if (isDocumentHidden()) {
167
+ // A tab opened in the background — ctrl+click, `target=_blank`, a restored session — must not record
168
+ // until it is first shown. The listener above starts it then.
169
+ debug("Document is hidden. Session recording will start once it becomes visible.");
170
+ return;
171
+ }
172
+
102
173
  const traceId = generateTraceId(sessionId);
103
174
  const stream: RecordingStream = {
104
175
  recordingId: generateUniqueId(TRACE_ID_BYTES),
@@ -111,7 +182,9 @@ function start(): void {
111
182
  maxMillis: settings.chunkMaxMillis ?? 5000,
112
183
  onChunk: (chunk) => {
113
184
  try {
114
- sendSessionRecordingChunk(buildSessionRecordingLog(stream, chunk), { compress: !flushingOnLastChance });
185
+ sendSessionRecordingChunk(buildSessionRecordingLog(stream, chunk), {
186
+ compress: !flushingWhileDocumentMayEnd,
187
+ });
115
188
  } catch (e) {
116
189
  warn("Failed to transmit session recording chunk", e);
117
190
  }
@@ -153,11 +226,11 @@ function start(): void {
153
226
  if (!lastChanceRegistered) {
154
227
  lastChanceRegistered = true;
155
228
  onLastChance(() => {
156
- flushingOnLastChance = true;
229
+ flushingWhileDocumentMayEnd = true;
157
230
  try {
158
231
  chunker?.flush();
159
232
  } finally {
160
- flushingOnLastChance = false;
233
+ flushingWhileDocumentMayEnd = false;
161
234
  }
162
235
  });
163
236
  }
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import type { SessionRecorder, SessionRecorderOptions } from "../../types/session-recording";
3
- import { win } from "../../utils";
3
+ import { doc, win } from "../../utils";
4
4
 
5
5
  vi.mock("../../transport", () => ({
6
6
  sendSessionRecordingChunk: vi.fn(),
@@ -270,4 +270,89 @@ describe("session recording lifecycle", () => {
270
270
  expect(sendSessionRecordingChunk).toHaveBeenCalledTimes(1);
271
271
  expect(mod.isSessionRecording()).toBe(false);
272
272
  });
273
+
274
+ describe("visibility", () => {
275
+ /**
276
+ * jsdom's `visibilityState` is a getter on the Document prototype, so it can only be changed by
277
+ * redefining it. Restored by the `afterEach` below.
278
+ */
279
+ function setVisibilityState(state: DocumentVisibilityState): void {
280
+ Object.defineProperty(doc!, "visibilityState", { value: state, configurable: true });
281
+ }
282
+
283
+ function setVisibility(state: DocumentVisibilityState): void {
284
+ setVisibilityState(state);
285
+ doc!.dispatchEvent(new Event("visibilitychange"));
286
+ }
287
+
288
+ afterEach(() => {
289
+ setVisibilityState("visible");
290
+ });
291
+
292
+ it("defers the start of a tab that is hidden when recording is armed", () => {
293
+ setVisibilityState("hidden");
294
+
295
+ mod.armSessionRecording();
296
+ mod.registerSessionRecorder(recorder);
297
+
298
+ expect(recorder).not.toHaveBeenCalled();
299
+ expect(mod.isSessionRecording()).toBe(false);
300
+
301
+ setVisibility("visible");
302
+
303
+ expect(recorder).toHaveBeenCalledTimes(1);
304
+ expect(mod.isSessionRecording()).toBe(true);
305
+ });
306
+
307
+ it("stops recording while hidden and starts a new run, with a new recording id, when shown again", () => {
308
+ mod.armSessionRecording();
309
+ mod.registerSessionRecorder(recorder);
310
+ capturedOptions!.emit({ type: 3, timestamp: 1, data: {} });
311
+
312
+ setVisibility("hidden");
313
+
314
+ expect(stopFn).toHaveBeenCalledTimes(1);
315
+ expect(mod.isSessionRecording()).toBe(false);
316
+ // Buffered events are flushed uncompressed: a hidden document may be discarded before an
317
+ // asynchronous gzip completes.
318
+ expect(sendSessionRecordingChunk).toHaveBeenCalledTimes(1);
319
+ expect(sendSessionRecordingChunk.mock.calls[0]![1]).toEqual({ compress: false });
320
+
321
+ setVisibility("visible");
322
+
323
+ expect(recorder).toHaveBeenCalledTimes(2);
324
+ capturedOptions!.emit({ type: 3, timestamp: 2, data: {} });
325
+ vi.advanceTimersByTime(5000);
326
+
327
+ expect(sendSessionRecordingChunk).toHaveBeenCalledTimes(2);
328
+ // A separate run, so the replay rebuilds from the full snapshot rrweb takes when it starts.
329
+ const recordingIds = sendSessionRecordingChunk.mock.calls.map(
330
+ (c) => c[0].attributes.find((a: any) => a.key === "dash0.session_recording.id").value.stringValue
331
+ );
332
+ expect(recordingIds[0]).not.toBe(recordingIds[1]);
333
+ });
334
+
335
+ it("does not resurrect a recording the consumer stopped", () => {
336
+ mod.armSessionRecording();
337
+ mod.registerSessionRecorder(recorder);
338
+ mod.stopSessionRecording();
339
+
340
+ setVisibility("hidden");
341
+ setVisibility("visible");
342
+
343
+ expect(recorder).toHaveBeenCalledTimes(1);
344
+ expect(mod.isSessionRecording()).toBe(false);
345
+ });
346
+
347
+ it("records again after an explicit restart that follows a consumer stop", () => {
348
+ mod.armSessionRecording();
349
+ mod.registerSessionRecorder(recorder);
350
+ mod.stopSessionRecording();
351
+
352
+ mod.registerSessionRecorder(recorder);
353
+
354
+ expect(recorder).toHaveBeenCalledTimes(2);
355
+ expect(mod.isSessionRecording()).toBe(true);
356
+ });
357
+ });
273
358
  });