@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.
- package/dist/dash0.iife.js +1 -1
- package/dist/dash0.iife.js.map +1 -1
- package/dist/dash0.js +1 -1
- package/dist/dash0.js.map +1 -1
- package/dist/dash0.umd.cjs +1 -1
- package/dist/dash0.umd.cjs.map +1 -1
- package/dist/modules/api/session-recording.js +8 -0
- package/dist/modules/instrumentations/session-recording/index.js +79 -9
- package/dist/modules/instrumentations/session-recording/index_test.js +64 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/api/session-recording.d.ts +8 -0
- package/package.json +1 -1
- package/src/api/session-recording.ts +8 -0
- package/src/instrumentations/session-recording/index.ts +82 -9
- package/src/instrumentations/session-recording/index_test.ts +86 -1
|
@@ -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
|
-
|
|
25
|
-
//
|
|
26
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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), {
|
|
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
|
-
|
|
229
|
+
flushingWhileDocumentMayEnd = true;
|
|
157
230
|
try {
|
|
158
231
|
chunker?.flush();
|
|
159
232
|
} finally {
|
|
160
|
-
|
|
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
|
});
|