@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
|
@@ -9,6 +9,11 @@ import { GLOBAL_RECORDER_KEY, registerSessionRecorder, stopSessionRecording as s
|
|
|
9
9
|
* Recording only starts once `init()` has run with a sampled session. It is safe to call this before `init()`;
|
|
10
10
|
* the recorder is kept and recording starts as soon as the SDK is initialized. The recording is transmitted
|
|
11
11
|
* as `browser.session_recording` log records that share one trace ID, which embeds the session ID.
|
|
12
|
+
*
|
|
13
|
+
* Only the visible document is recorded. A tab that is hidden — switched away from, or opened in the
|
|
14
|
+
* background — stops recording and flushes what it buffered, and starts a fresh recording when it is shown
|
|
15
|
+
* again. So a session is a sequence of recordings that do not overlap in time, which is what lets the whole
|
|
16
|
+
* session, tab switches included, be replayed as one.
|
|
12
17
|
*/
|
|
13
18
|
export function startSessionRecording(recorder) {
|
|
14
19
|
// The script entrypoint forwards dash0("startSessionRecording", ...) arguments without type checking,
|
|
@@ -24,6 +29,9 @@ export function startSessionRecording(recorder) {
|
|
|
24
29
|
/**
|
|
25
30
|
* Stops the running session recording and transmits any buffered events. Calling this when no recording is
|
|
26
31
|
* running is a no-op.
|
|
32
|
+
*
|
|
33
|
+
* Unlike the automatic pause while a tab is hidden, this is final: recording does not resume when the tab
|
|
34
|
+
* becomes visible again. Call `startSessionRecording()` to record again.
|
|
27
35
|
*/
|
|
28
36
|
export function stopSessionRecording() {
|
|
29
37
|
stopRecording();
|
|
@@ -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 { newChunker } from "./chunker";
|
|
@@ -18,9 +19,14 @@ let armed = false;
|
|
|
18
19
|
let stopRecorder;
|
|
19
20
|
let chunker;
|
|
20
21
|
let lastChanceRegistered = false;
|
|
21
|
-
|
|
22
|
-
//
|
|
23
|
-
|
|
22
|
+
let visibilityRegistered = false;
|
|
23
|
+
// Set by the public `stopSessionRecording()`, so a visibility change does not resurrect a recording the
|
|
24
|
+
// consumer deliberately ended. Cleared by an explicit start: registering a recorder or arming.
|
|
25
|
+
let stoppedByConsumer = false;
|
|
26
|
+
// True while flushing a document that may not live much longer. The chunk emitted then must be sent
|
|
27
|
+
// uncompressed: gzip is asynchronous, and a document that is being unloaded — or that has just been hidden,
|
|
28
|
+
// and may be unloaded or throttled at any moment — may never get to the `fetch()` behind the await.
|
|
29
|
+
let flushingWhileDocumentMayEnd = false;
|
|
24
30
|
/**
|
|
25
31
|
* Makes a recorder available. Called from the public `startSessionRecording` API, which the
|
|
26
32
|
* `dash0-session-recording.iife.js` script and npm consumers use. Recording starts as soon as both a recorder
|
|
@@ -28,6 +34,7 @@ let flushingOnLastChance = false;
|
|
|
28
34
|
*/
|
|
29
35
|
export function registerSessionRecorder(r) {
|
|
30
36
|
recorder = r;
|
|
37
|
+
stoppedByConsumer = false;
|
|
31
38
|
if (armed) {
|
|
32
39
|
start();
|
|
33
40
|
}
|
|
@@ -42,6 +49,7 @@ export function registerSessionRecorder(r) {
|
|
|
42
49
|
*/
|
|
43
50
|
export function armSessionRecording() {
|
|
44
51
|
armed = true;
|
|
52
|
+
stoppedByConsumer = false;
|
|
45
53
|
if (vars.sessionRecording.recorder) {
|
|
46
54
|
recorder = vars.sessionRecording.recorder;
|
|
47
55
|
}
|
|
@@ -56,6 +64,17 @@ export function armSessionRecording() {
|
|
|
56
64
|
}
|
|
57
65
|
}
|
|
58
66
|
export function stopSessionRecording() {
|
|
67
|
+
stoppedByConsumer = true;
|
|
68
|
+
teardown();
|
|
69
|
+
}
|
|
70
|
+
export function isSessionRecording() {
|
|
71
|
+
return stopRecorder != null;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Stops the recorder and flushes what it buffered. Shared by the public stop and by the visibility handler,
|
|
75
|
+
* which differ only in whether the consumer asked for it.
|
|
76
|
+
*/
|
|
77
|
+
function teardown() {
|
|
59
78
|
if (stopRecorder) {
|
|
60
79
|
try {
|
|
61
80
|
stopRecorder();
|
|
@@ -68,8 +87,50 @@ export function stopSessionRecording() {
|
|
|
68
87
|
chunker?.flush();
|
|
69
88
|
chunker = undefined;
|
|
70
89
|
}
|
|
71
|
-
|
|
72
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Whether the document is not on screen. Treats an absent `visibilityState` as visible, so an environment
|
|
92
|
+
* without the API records exactly as it did before rather than never recording at all.
|
|
93
|
+
*
|
|
94
|
+
* `prerender` counts as hidden: nobody is looking at a prerendered page.
|
|
95
|
+
*/
|
|
96
|
+
function isDocumentHidden() {
|
|
97
|
+
const state = doc?.visibilityState;
|
|
98
|
+
return state != null && state !== "visible";
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Recording follows visibility: a hidden tab is torn down and a shown tab starts a new recorder run.
|
|
102
|
+
*
|
|
103
|
+
* This is what lets one replayer play a whole session. rrweb's Replayer rebuilds from any full snapshot it
|
|
104
|
+
* plays through, but each rebuild resets its node-id mirror, so events from a document other than the one
|
|
105
|
+
* that produced the newest snapshot would address the wrong nodes. Recording only the visible document keeps
|
|
106
|
+
* the runs of a session from overlapping, so they concatenate into a single coherent stream. `record()` takes
|
|
107
|
+
* a full snapshot when it starts, so every run opens with one and needs no separate snapshot call.
|
|
108
|
+
*
|
|
109
|
+
* Not recording hidden tabs is also why a session stays small: a background tab left open for hours used to
|
|
110
|
+
* record mutations nobody ever saw.
|
|
111
|
+
*/
|
|
112
|
+
function registerVisibilityHandling() {
|
|
113
|
+
if (visibilityRegistered || !doc) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
visibilityRegistered = true;
|
|
117
|
+
addEventListener(doc, "visibilitychange", () => {
|
|
118
|
+
if (isDocumentHidden()) {
|
|
119
|
+
// Flushed the same way as an unload: a hidden document can be discarded or throttled before an
|
|
120
|
+
// asynchronous gzip completes.
|
|
121
|
+
flushingWhileDocumentMayEnd = true;
|
|
122
|
+
try {
|
|
123
|
+
teardown();
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
flushingWhileDocumentMayEnd = false;
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!stoppedByConsumer) {
|
|
131
|
+
start();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
73
134
|
}
|
|
74
135
|
function start() {
|
|
75
136
|
if (stopRecorder) {
|
|
@@ -91,6 +152,13 @@ function start() {
|
|
|
91
152
|
debug("Page URL is ignored. Session recording will not start.");
|
|
92
153
|
return;
|
|
93
154
|
}
|
|
155
|
+
registerVisibilityHandling();
|
|
156
|
+
if (isDocumentHidden()) {
|
|
157
|
+
// A tab opened in the background — ctrl+click, `target=_blank`, a restored session — must not record
|
|
158
|
+
// until it is first shown. The listener above starts it then.
|
|
159
|
+
debug("Document is hidden. Session recording will start once it becomes visible.");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
94
162
|
const traceId = generateTraceId(sessionId);
|
|
95
163
|
const stream = {
|
|
96
164
|
recordingId: generateUniqueId(TRACE_ID_BYTES),
|
|
@@ -102,7 +170,9 @@ function start() {
|
|
|
102
170
|
maxMillis: settings.chunkMaxMillis ?? 5000,
|
|
103
171
|
onChunk: (chunk) => {
|
|
104
172
|
try {
|
|
105
|
-
sendSessionRecordingChunk(buildSessionRecordingLog(stream, chunk), {
|
|
173
|
+
sendSessionRecordingChunk(buildSessionRecordingLog(stream, chunk), {
|
|
174
|
+
compress: !flushingWhileDocumentMayEnd,
|
|
175
|
+
});
|
|
106
176
|
}
|
|
107
177
|
catch (e) {
|
|
108
178
|
warn("Failed to transmit session recording chunk", e);
|
|
@@ -144,12 +214,12 @@ function start() {
|
|
|
144
214
|
if (!lastChanceRegistered) {
|
|
145
215
|
lastChanceRegistered = true;
|
|
146
216
|
onLastChance(() => {
|
|
147
|
-
|
|
217
|
+
flushingWhileDocumentMayEnd = true;
|
|
148
218
|
try {
|
|
149
219
|
chunker?.flush();
|
|
150
220
|
}
|
|
151
221
|
finally {
|
|
152
|
-
|
|
222
|
+
flushingWhileDocumentMayEnd = false;
|
|
153
223
|
}
|
|
154
224
|
});
|
|
155
225
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { win } from "../../utils";
|
|
2
|
+
import { doc, win } from "../../utils";
|
|
3
3
|
vi.mock("../../transport", () => ({
|
|
4
4
|
sendSessionRecordingChunk: vi.fn(),
|
|
5
5
|
sendLog: vi.fn(),
|
|
@@ -227,4 +227,67 @@ describe("session recording lifecycle", () => {
|
|
|
227
227
|
expect(sendSessionRecordingChunk).toHaveBeenCalledTimes(1);
|
|
228
228
|
expect(mod.isSessionRecording()).toBe(false);
|
|
229
229
|
});
|
|
230
|
+
describe("visibility", () => {
|
|
231
|
+
/**
|
|
232
|
+
* jsdom's `visibilityState` is a getter on the Document prototype, so it can only be changed by
|
|
233
|
+
* redefining it. Restored by the `afterEach` below.
|
|
234
|
+
*/
|
|
235
|
+
function setVisibilityState(state) {
|
|
236
|
+
Object.defineProperty(doc, "visibilityState", { value: state, configurable: true });
|
|
237
|
+
}
|
|
238
|
+
function setVisibility(state) {
|
|
239
|
+
setVisibilityState(state);
|
|
240
|
+
doc.dispatchEvent(new Event("visibilitychange"));
|
|
241
|
+
}
|
|
242
|
+
afterEach(() => {
|
|
243
|
+
setVisibilityState("visible");
|
|
244
|
+
});
|
|
245
|
+
it("defers the start of a tab that is hidden when recording is armed", () => {
|
|
246
|
+
setVisibilityState("hidden");
|
|
247
|
+
mod.armSessionRecording();
|
|
248
|
+
mod.registerSessionRecorder(recorder);
|
|
249
|
+
expect(recorder).not.toHaveBeenCalled();
|
|
250
|
+
expect(mod.isSessionRecording()).toBe(false);
|
|
251
|
+
setVisibility("visible");
|
|
252
|
+
expect(recorder).toHaveBeenCalledTimes(1);
|
|
253
|
+
expect(mod.isSessionRecording()).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
it("stops recording while hidden and starts a new run, with a new recording id, when shown again", () => {
|
|
256
|
+
mod.armSessionRecording();
|
|
257
|
+
mod.registerSessionRecorder(recorder);
|
|
258
|
+
capturedOptions.emit({ type: 3, timestamp: 1, data: {} });
|
|
259
|
+
setVisibility("hidden");
|
|
260
|
+
expect(stopFn).toHaveBeenCalledTimes(1);
|
|
261
|
+
expect(mod.isSessionRecording()).toBe(false);
|
|
262
|
+
// Buffered events are flushed uncompressed: a hidden document may be discarded before an
|
|
263
|
+
// asynchronous gzip completes.
|
|
264
|
+
expect(sendSessionRecordingChunk).toHaveBeenCalledTimes(1);
|
|
265
|
+
expect(sendSessionRecordingChunk.mock.calls[0][1]).toEqual({ compress: false });
|
|
266
|
+
setVisibility("visible");
|
|
267
|
+
expect(recorder).toHaveBeenCalledTimes(2);
|
|
268
|
+
capturedOptions.emit({ type: 3, timestamp: 2, data: {} });
|
|
269
|
+
vi.advanceTimersByTime(5000);
|
|
270
|
+
expect(sendSessionRecordingChunk).toHaveBeenCalledTimes(2);
|
|
271
|
+
// A separate run, so the replay rebuilds from the full snapshot rrweb takes when it starts.
|
|
272
|
+
const recordingIds = sendSessionRecordingChunk.mock.calls.map((c) => c[0].attributes.find((a) => a.key === "dash0.session_recording.id").value.stringValue);
|
|
273
|
+
expect(recordingIds[0]).not.toBe(recordingIds[1]);
|
|
274
|
+
});
|
|
275
|
+
it("does not resurrect a recording the consumer stopped", () => {
|
|
276
|
+
mod.armSessionRecording();
|
|
277
|
+
mod.registerSessionRecorder(recorder);
|
|
278
|
+
mod.stopSessionRecording();
|
|
279
|
+
setVisibility("hidden");
|
|
280
|
+
setVisibility("visible");
|
|
281
|
+
expect(recorder).toHaveBeenCalledTimes(1);
|
|
282
|
+
expect(mod.isSessionRecording()).toBe(false);
|
|
283
|
+
});
|
|
284
|
+
it("records again after an explicit restart that follows a consumer stop", () => {
|
|
285
|
+
mod.armSessionRecording();
|
|
286
|
+
mod.registerSessionRecorder(recorder);
|
|
287
|
+
mod.stopSessionRecording();
|
|
288
|
+
mod.registerSessionRecorder(recorder);
|
|
289
|
+
expect(recorder).toHaveBeenCalledTimes(2);
|
|
290
|
+
expect(mod.isSessionRecording()).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
230
293
|
});
|