@dash0/sdk-web 0.24.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/README.md +5 -0
- package/dist/dash0-session-recording.iife.js +2 -0
- package/dist/dash0-session-recording.iife.js.map +1 -0
- package/dist/dash0-session-recording.js +2 -0
- package/dist/dash0-session-recording.js.map +1 -0
- package/dist/dash0-session-recording.umd.cjs +2 -0
- package/dist/dash0-session-recording.umd.cjs.map +1 -0
- 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/init.js +18 -1
- package/dist/modules/api/init_test.js +27 -0
- package/dist/modules/api/session-recording.js +38 -0
- package/dist/modules/api/session-recording_test.js +36 -0
- package/dist/modules/entrypoint/npm-package.js +1 -0
- package/dist/modules/entrypoint/script.js +3 -0
- package/dist/modules/entrypoint/session-recording-script.js +19 -0
- package/dist/modules/entrypoint/session-recording.js +11 -0
- package/dist/modules/instrumentations/session-recording/chunker.js +70 -0
- package/dist/modules/instrumentations/session-recording/chunker_test.js +125 -0
- package/dist/modules/instrumentations/session-recording/index.js +235 -0
- package/dist/modules/instrumentations/session-recording/index_test.js +293 -0
- package/dist/modules/instrumentations/session-recording/log.js +23 -0
- package/dist/modules/instrumentations/session-recording/log_test.js +46 -0
- package/dist/modules/semantic-conventions.js +7 -0
- package/dist/modules/transport/fetch.js +2 -2
- package/dist/modules/transport/fetch_test.js +25 -0
- package/dist/modules/transport/index.js +44 -11
- package/dist/modules/transport/index_test.js +73 -0
- package/dist/modules/types/session-recording.js +1 -0
- package/dist/modules/vars.js +11 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/api/session-recording.d.ts +25 -0
- package/dist/types/api/session-recording_test.d.ts +1 -0
- package/dist/types/entrypoint/npm-package.d.ts +2 -0
- package/dist/types/entrypoint/session-recording-script.d.ts +1 -0
- package/dist/types/entrypoint/session-recording.d.ts +5 -0
- package/dist/types/instrumentations/session-recording/chunker.d.ts +38 -0
- package/dist/types/instrumentations/session-recording/chunker_test.d.ts +1 -0
- package/dist/types/instrumentations/session-recording/index.d.ts +23 -0
- package/dist/types/instrumentations/session-recording/index_test.d.ts +1 -0
- package/dist/types/instrumentations/session-recording/log.d.ts +14 -0
- package/dist/types/instrumentations/session-recording/log_test.d.ts +1 -0
- package/dist/types/semantic-conventions.d.ts +6 -0
- package/dist/types/transport/fetch.d.ts +8 -1
- package/dist/types/transport/index.d.ts +10 -0
- package/dist/types/transport/index_test.d.ts +1 -0
- package/dist/types/types/options.d.ts +2 -2
- package/dist/types/types/session-recording.d.ts +128 -0
- package/dist/types/vars.d.ts +6 -0
- package/package.json +10 -2
- package/src/api/init.ts +19 -1
- package/src/api/init_test.ts +33 -1
- package/src/api/session-recording.ts +48 -0
- package/src/api/session-recording_test.ts +44 -0
- package/src/entrypoint/npm-package.ts +2 -0
- package/src/entrypoint/script.ts +3 -0
- package/src/entrypoint/session-recording-script.ts +23 -0
- package/src/entrypoint/session-recording.ts +13 -0
- package/src/instrumentations/session-recording/chunker.ts +118 -0
- package/src/instrumentations/session-recording/chunker_test.ts +147 -0
- package/src/instrumentations/session-recording/index.ts +248 -0
- package/src/instrumentations/session-recording/index_test.ts +358 -0
- package/src/instrumentations/session-recording/log.ts +48 -0
- package/src/instrumentations/session-recording/log_test.ts +60 -0
- package/src/semantic-conventions.ts +8 -0
- package/src/transport/fetch.ts +10 -2
- package/src/transport/fetch_test.ts +30 -0
- package/src/transport/index.ts +64 -25
- package/src/transport/index_test.ts +90 -0
- package/src/types/options.ts +3 -1
- package/src/types/session-recording.ts +144 -0
- package/src/vars.ts +18 -0
package/dist/modules/api/init.js
CHANGED
|
@@ -11,6 +11,7 @@ import { startNavigationInstrumentation } from "../instrumentations/navigation";
|
|
|
11
11
|
import { initializeTabId } from "../utils/tab-id";
|
|
12
12
|
import { pickFirstString } from "./browser-env";
|
|
13
13
|
import { applyVcsResourceAttributes } from "./vcs";
|
|
14
|
+
import { armSessionRecording } from "../instrumentations/session-recording";
|
|
14
15
|
let hasBeenInitialised = false;
|
|
15
16
|
export function init(opts) {
|
|
16
17
|
if (hasBeenInitialised) {
|
|
@@ -50,6 +51,7 @@ export function init(opts) {
|
|
|
50
51
|
"headersToCapture",
|
|
51
52
|
"urlAttributeScrubber",
|
|
52
53
|
"pageViewInstrumentation",
|
|
54
|
+
"sessionRecording",
|
|
53
55
|
"enableTransportCompression",
|
|
54
56
|
])));
|
|
55
57
|
initializePropagators(opts);
|
|
@@ -81,6 +83,9 @@ export function init(opts) {
|
|
|
81
83
|
if (isInstrumentationEnabled("@dash0/xhr", opts)) {
|
|
82
84
|
instrumentXhr();
|
|
83
85
|
}
|
|
86
|
+
if (isInstrumentationEnabled("@dash0/session-recording", opts)) {
|
|
87
|
+
armSessionRecording();
|
|
88
|
+
}
|
|
84
89
|
hasBeenInitialised = true;
|
|
85
90
|
}
|
|
86
91
|
function initializeResourceAttributes(opts) {
|
|
@@ -206,7 +211,10 @@ function merge(target, source) {
|
|
|
206
211
|
typeof dstVal === "object" &&
|
|
207
212
|
dstVal !== null &&
|
|
208
213
|
!Array.isArray(dstVal)) {
|
|
209
|
-
|
|
214
|
+
// Like the top-level rule above, an explicit `undefined` inside a nested object means "not provided" and
|
|
215
|
+
// must not erase the default. Otherwise `sessionRecording: { maskAllInputs: someUnsetFlag }` would
|
|
216
|
+
// silently turn input masking off.
|
|
217
|
+
result[key] = { ...dstVal, ...withoutUndefined(srcVal) };
|
|
210
218
|
}
|
|
211
219
|
else {
|
|
212
220
|
result[key] = srcVal;
|
|
@@ -215,3 +223,12 @@ function merge(target, source) {
|
|
|
215
223
|
}
|
|
216
224
|
return result;
|
|
217
225
|
}
|
|
226
|
+
function withoutUndefined(obj) {
|
|
227
|
+
const result = {};
|
|
228
|
+
for (const key of Object.keys(obj)) {
|
|
229
|
+
if (obj[key] !== undefined) {
|
|
230
|
+
result[key] = obj[key];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
@@ -16,6 +16,9 @@ vi.mock("../instrumentations/http/xhr", () => ({
|
|
|
16
16
|
vi.mock("../instrumentations/navigation", () => ({
|
|
17
17
|
startNavigationInstrumentation: vi.fn(),
|
|
18
18
|
}));
|
|
19
|
+
vi.mock("../instrumentations/session-recording", () => ({
|
|
20
|
+
armSessionRecording: vi.fn(),
|
|
21
|
+
}));
|
|
19
22
|
// Mock the utils module to control loc.hostname
|
|
20
23
|
vi.mock("../utils", async () => {
|
|
21
24
|
const actual = await vi.importActual("../utils");
|
|
@@ -29,6 +32,7 @@ import { instrumentFetch } from "../instrumentations/http/fetch";
|
|
|
29
32
|
import { instrumentXhr } from "../instrumentations/http/xhr";
|
|
30
33
|
import { startNavigationInstrumentation } from "../instrumentations/navigation";
|
|
31
34
|
import { startWebVitalsInstrumentation } from "../instrumentations/web-vitals";
|
|
35
|
+
import { armSessionRecording } from "../instrumentations/session-recording";
|
|
32
36
|
describe("init", () => {
|
|
33
37
|
const baseOptions = {
|
|
34
38
|
serviceName: "test-service",
|
|
@@ -51,6 +55,27 @@ describe("init", () => {
|
|
|
51
55
|
afterEach(() => {
|
|
52
56
|
vi.clearAllMocks();
|
|
53
57
|
});
|
|
58
|
+
describe("nested option merging", () => {
|
|
59
|
+
it("keeps nested defaults when an override is explicitly undefined", () => {
|
|
60
|
+
init({
|
|
61
|
+
...baseOptions,
|
|
62
|
+
sessionRecording: { maskAllInputs: undefined, chunkMaxMillis: 1000 },
|
|
63
|
+
});
|
|
64
|
+
expect(vars.sessionRecording.maskAllInputs).toBe(true);
|
|
65
|
+
expect(vars.sessionRecording.blockClass).toBe("dash0-block");
|
|
66
|
+
expect(vars.sessionRecording.chunkMaxMillis).toBe(1000);
|
|
67
|
+
});
|
|
68
|
+
it("still lets an explicit false override a nested default", () => {
|
|
69
|
+
init({
|
|
70
|
+
...baseOptions,
|
|
71
|
+
sessionRecording: { maskAllInputs: false },
|
|
72
|
+
pageViewInstrumentation: { trackVirtualPageViews: false },
|
|
73
|
+
});
|
|
74
|
+
expect(vars.sessionRecording.maskAllInputs).toBe(false);
|
|
75
|
+
expect(vars.pageViewInstrumentation.trackVirtualPageViews).toBe(false);
|
|
76
|
+
expect(vars.pageViewInstrumentation.includeParts).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
54
79
|
describe("instrumentation enablement", () => {
|
|
55
80
|
it("should enable all instrumentations when enabledInstrumentations is undefined", async () => {
|
|
56
81
|
init({
|
|
@@ -69,6 +94,7 @@ describe("init", () => {
|
|
|
69
94
|
"@dash0/error",
|
|
70
95
|
"@dash0/fetch",
|
|
71
96
|
"@dash0/xhr",
|
|
97
|
+
"@dash0/session-recording",
|
|
72
98
|
];
|
|
73
99
|
const instrumentationMocks = {
|
|
74
100
|
"@dash0/navigation": startNavigationInstrumentation,
|
|
@@ -76,6 +102,7 @@ describe("init", () => {
|
|
|
76
102
|
"@dash0/error": startErrorInstrumentation,
|
|
77
103
|
"@dash0/fetch": instrumentFetch,
|
|
78
104
|
"@dash0/xhr": instrumentXhr,
|
|
105
|
+
"@dash0/session-recording": armSessionRecording,
|
|
79
106
|
};
|
|
80
107
|
instrumentations.forEach((instrumentation) => {
|
|
81
108
|
it(`should enable ${instrumentation} instrumentation when present in enabledInstrumentations array`, async () => {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { debug, win } from "../utils";
|
|
2
|
+
import { GLOBAL_RECORDER_KEY, registerSessionRecorder, stopSessionRecording as stopRecording, } from "../instrumentations/session-recording";
|
|
3
|
+
/**
|
|
4
|
+
* Starts session recording with the given recorder. Pass `recorder` from `@dash0/sdk-web/session-recording`.
|
|
5
|
+
* When `recorder` is omitted, the SDK looks for `window.dash0Recorder`, which the
|
|
6
|
+
* `dash0-session-recording.iife.js` script sets. Calling this is not required when that script is used: `init()`
|
|
7
|
+
* picks up `window.dash0Recorder` on its own, regardless of the order in which the scripts execute.
|
|
8
|
+
*
|
|
9
|
+
* Recording only starts once `init()` has run with a sampled session. It is safe to call this before `init()`;
|
|
10
|
+
* the recorder is kept and recording starts as soon as the SDK is initialized. The recording is transmitted
|
|
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.
|
|
17
|
+
*/
|
|
18
|
+
export function startSessionRecording(recorder) {
|
|
19
|
+
// The script entrypoint forwards dash0("startSessionRecording", ...) arguments without type checking,
|
|
20
|
+
// so malformed calls must degrade to a logged no-op instead of throwing. An uncaught throw here would
|
|
21
|
+
// abort the command-queue drain and drop all subsequently queued api calls.
|
|
22
|
+
const r = recorder ?? win?.[GLOBAL_RECORDER_KEY];
|
|
23
|
+
if (typeof r !== "function") {
|
|
24
|
+
debug("startSessionRecording requires a recorder. Import `recorder` from `@dash0/sdk-web/session-recording` or load dash0-session-recording.iife.js. Ignoring call.");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
registerSessionRecorder(r);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Stops the running session recording and transmits any buffered events. Calling this when no recording is
|
|
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.
|
|
35
|
+
*/
|
|
36
|
+
export function stopSessionRecording() {
|
|
37
|
+
stopRecording();
|
|
38
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
vi.mock("../instrumentations/session-recording", () => ({
|
|
3
|
+
GLOBAL_RECORDER_KEY: "dash0Recorder",
|
|
4
|
+
registerSessionRecorder: vi.fn(),
|
|
5
|
+
stopSessionRecording: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
import { registerSessionRecorder, stopSessionRecording as stopImpl } from "../instrumentations/session-recording";
|
|
8
|
+
import { startSessionRecording, stopSessionRecording } from "./session-recording";
|
|
9
|
+
import { win } from "../utils";
|
|
10
|
+
const globalObject = win;
|
|
11
|
+
describe("startSessionRecording api", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.clearAllMocks();
|
|
14
|
+
delete globalObject.dash0Recorder;
|
|
15
|
+
});
|
|
16
|
+
it("registers an explicitly passed recorder", () => {
|
|
17
|
+
const recorder = vi.fn();
|
|
18
|
+
startSessionRecording(recorder);
|
|
19
|
+
expect(registerSessionRecorder).toHaveBeenCalledWith(recorder);
|
|
20
|
+
});
|
|
21
|
+
it("falls back to window.dash0Recorder", () => {
|
|
22
|
+
const recorder = vi.fn();
|
|
23
|
+
globalObject.dash0Recorder = recorder;
|
|
24
|
+
startSessionRecording();
|
|
25
|
+
expect(registerSessionRecorder).toHaveBeenCalledWith(recorder);
|
|
26
|
+
});
|
|
27
|
+
it("ignores calls without a usable recorder instead of throwing", () => {
|
|
28
|
+
expect(() => startSessionRecording()).not.toThrow();
|
|
29
|
+
expect(() => startSessionRecording("nope")).not.toThrow();
|
|
30
|
+
expect(registerSessionRecorder).not.toHaveBeenCalled();
|
|
31
|
+
});
|
|
32
|
+
it("delegates stop", () => {
|
|
33
|
+
stopSessionRecording();
|
|
34
|
+
expect(stopImpl).toHaveBeenCalledTimes(1);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -8,6 +8,7 @@ export * from "../api/log-level";
|
|
|
8
8
|
export { terminateSession } from "../api/session";
|
|
9
9
|
export { reportError } from "../api/report-error";
|
|
10
10
|
export { startView } from "../api/start-view";
|
|
11
|
+
export { startSessionRecording, stopSessionRecording } from "../api/session-recording";
|
|
11
12
|
export function init(opts) {
|
|
12
13
|
debug(`${INIT_MESSAGE} (via package)`);
|
|
13
14
|
initApi(opts);
|
|
@@ -8,6 +8,7 @@ import { addSignalAttribute, removeSignalAttribute } from "../api/attributes";
|
|
|
8
8
|
import { sendEvent } from "../api/events";
|
|
9
9
|
import { setActiveLogLevel } from "../api/log-level";
|
|
10
10
|
import { startView } from "../api/start-view";
|
|
11
|
+
import { startSessionRecording, stopSessionRecording } from "../api/session-recording";
|
|
11
12
|
/**
|
|
12
13
|
* All the APIs exposed through the script tag via `dash0('{{api name}}')`
|
|
13
14
|
*/
|
|
@@ -22,6 +23,8 @@ const scriptApis = {
|
|
|
22
23
|
setActiveLogLevel,
|
|
23
24
|
sendEvent,
|
|
24
25
|
startView,
|
|
26
|
+
startSessionRecording,
|
|
27
|
+
stopSessionRecording,
|
|
25
28
|
};
|
|
26
29
|
init();
|
|
27
30
|
function init() {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Script-tag entrypoint, built to `dist/dash0-session-recording.iife.js`.
|
|
3
|
+
*
|
|
4
|
+
* This bundle contains only rrweb's recorder. It must not import any SDK module (see eslint.config.js), so the
|
|
5
|
+
* main SDK bundle stays the single owner of configuration, session and transport state. It hands the recorder
|
|
6
|
+
* to the SDK in two ways, so it can be loaded in any order relative to the initializer snippet and `dash0.iife.js`:
|
|
7
|
+
*
|
|
8
|
+
* - `window.dash0Recorder`: picked up by the SDK when `init()` runs. Covers the case where this bundle executes
|
|
9
|
+
* before the initializer snippet has defined the `dash0` command queue.
|
|
10
|
+
* - `dash0("startSessionRecording", record)`: covers the case where the SDK is already initialized, or the
|
|
11
|
+
* command queue exists and will be drained on `init()`.
|
|
12
|
+
*/
|
|
13
|
+
/* eslint-disable no-restricted-globals */
|
|
14
|
+
import { record } from "@rrweb/record";
|
|
15
|
+
window.dash0Recorder = record;
|
|
16
|
+
const dash0 = window.dash0;
|
|
17
|
+
if (typeof dash0 === "function") {
|
|
18
|
+
dash0("startSessionRecording", record);
|
|
19
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm entrypoint of `@dash0/sdk-web/session-recording`.
|
|
3
|
+
*
|
|
4
|
+
* This bundle contains only rrweb's recorder. It must not import any SDK module (see eslint.config.js), so the
|
|
5
|
+
* main SDK bundle stays the single owner of configuration, session and transport state.
|
|
6
|
+
*/
|
|
7
|
+
import { record } from "@rrweb/record";
|
|
8
|
+
/**
|
|
9
|
+
* The rrweb recorder. Pass it to `init({ sessionRecording: { recorder } })` or to `startSessionRecording(recorder)`.
|
|
10
|
+
*/
|
|
11
|
+
export const recorder = record;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { setTimeout, clearTimeout } from "../../utils/timers";
|
|
2
|
+
const RRWEB_EVENT_TYPE_FULL_SNAPSHOT = 2;
|
|
3
|
+
const RRWEB_EVENT_TYPE_META = 4;
|
|
4
|
+
/**
|
|
5
|
+
* Buffers rrweb events and hands them out as chunks. A chunk closes when its serialized size reaches
|
|
6
|
+
* `maxBytes`, when `maxMillis` have passed since its first event, when a new full snapshot begins, or when
|
|
7
|
+
* `flush()` is called. Events are serialized once, on arrival, so a flush is a join and not a second stringify.
|
|
8
|
+
*/
|
|
9
|
+
export function newChunker(opts) {
|
|
10
|
+
let serialized = [];
|
|
11
|
+
let byteSize = 0;
|
|
12
|
+
let hasSnapshot = false;
|
|
13
|
+
let startTime = 0;
|
|
14
|
+
let endTime = 0;
|
|
15
|
+
let seq = 0;
|
|
16
|
+
let pendingFlushTimeout = null;
|
|
17
|
+
return { add, flush, discard };
|
|
18
|
+
function add(event) {
|
|
19
|
+
// A Meta event announces a new full snapshot. Close the current chunk first so the snapshot starts a fresh
|
|
20
|
+
// one and a replay can begin at that chunk.
|
|
21
|
+
if (event.type === RRWEB_EVENT_TYPE_META && serialized.length > 0) {
|
|
22
|
+
flush();
|
|
23
|
+
}
|
|
24
|
+
const json = JSON.stringify(event);
|
|
25
|
+
if (json == null)
|
|
26
|
+
return;
|
|
27
|
+
if (serialized.length === 0) {
|
|
28
|
+
startTime = event.timestamp;
|
|
29
|
+
pendingFlushTimeout = setTimeout(flush, opts.maxMillis);
|
|
30
|
+
}
|
|
31
|
+
serialized.push(json);
|
|
32
|
+
byteSize += json.length;
|
|
33
|
+
endTime = event.timestamp;
|
|
34
|
+
if (event.type === RRWEB_EVENT_TYPE_FULL_SNAPSHOT) {
|
|
35
|
+
hasSnapshot = true;
|
|
36
|
+
}
|
|
37
|
+
if (byteSize >= opts.maxBytes) {
|
|
38
|
+
flush();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function discard() {
|
|
42
|
+
clearPendingFlush();
|
|
43
|
+
serialized = [];
|
|
44
|
+
byteSize = 0;
|
|
45
|
+
hasSnapshot = false;
|
|
46
|
+
}
|
|
47
|
+
function clearPendingFlush() {
|
|
48
|
+
if (pendingFlushTimeout != null) {
|
|
49
|
+
clearTimeout(pendingFlushTimeout);
|
|
50
|
+
pendingFlushTimeout = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function flush() {
|
|
54
|
+
clearPendingFlush();
|
|
55
|
+
if (serialized.length === 0)
|
|
56
|
+
return;
|
|
57
|
+
const chunk = {
|
|
58
|
+
seq: seq++,
|
|
59
|
+
body: "[" + serialized.join(",") + "]",
|
|
60
|
+
eventCount: serialized.length,
|
|
61
|
+
hasSnapshot,
|
|
62
|
+
startTime,
|
|
63
|
+
endTime,
|
|
64
|
+
};
|
|
65
|
+
serialized = [];
|
|
66
|
+
byteSize = 0;
|
|
67
|
+
hasSnapshot = false;
|
|
68
|
+
opts.onChunk(chunk);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { newChunker } from "./chunker";
|
|
3
|
+
// The SDK's timer wrapper captures window.setTimeout at import time, before vitest installs fake timers.
|
|
4
|
+
// Resolve the globals lazily so vi.useFakeTimers() takes effect.
|
|
5
|
+
vi.mock("../../utils/timers", () => ({
|
|
6
|
+
setTimeout: (...args) => globalThis.setTimeout(...args),
|
|
7
|
+
clearTimeout: (...args) => globalThis.clearTimeout(...args),
|
|
8
|
+
}));
|
|
9
|
+
const META = 4;
|
|
10
|
+
const FULL_SNAPSHOT = 2;
|
|
11
|
+
const INCREMENTAL = 3;
|
|
12
|
+
function event(type, timestamp, data = {}) {
|
|
13
|
+
return { type, timestamp, data };
|
|
14
|
+
}
|
|
15
|
+
describe("session recording chunker", () => {
|
|
16
|
+
let chunks;
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.useFakeTimers();
|
|
19
|
+
chunks = [];
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.useRealTimers();
|
|
23
|
+
});
|
|
24
|
+
function create(maxBytes = 10_000, maxMillis = 5000) {
|
|
25
|
+
return newChunker({ maxBytes, maxMillis, onChunk: (c) => chunks.push(c) });
|
|
26
|
+
}
|
|
27
|
+
it("does nothing on flush when empty", () => {
|
|
28
|
+
const chunker = create();
|
|
29
|
+
chunker.flush();
|
|
30
|
+
expect(chunks).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
it("discards buffered events and the pending time-based flush", () => {
|
|
33
|
+
const chunker = create();
|
|
34
|
+
chunker.add(event(META, 1000));
|
|
35
|
+
chunker.add(event(FULL_SNAPSHOT, 1001));
|
|
36
|
+
chunker.discard();
|
|
37
|
+
vi.advanceTimersByTime(10_000);
|
|
38
|
+
expect(chunks).toEqual([]);
|
|
39
|
+
// The chunker stays usable, and the dropped events do not leak into the next chunk.
|
|
40
|
+
chunker.add(event(INCREMENTAL, 2000));
|
|
41
|
+
chunker.flush();
|
|
42
|
+
expect(chunks).toHaveLength(1);
|
|
43
|
+
expect(chunks[0].seq).toBe(0);
|
|
44
|
+
expect(chunks[0].eventCount).toBe(1);
|
|
45
|
+
expect(chunks[0].hasSnapshot).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
it("flushes on explicit flush with a serialized JSON array body", () => {
|
|
48
|
+
const chunker = create();
|
|
49
|
+
chunker.add(event(META, 1000, { href: "http://x" }));
|
|
50
|
+
chunker.add(event(FULL_SNAPSHOT, 1001));
|
|
51
|
+
chunker.add(event(INCREMENTAL, 1002));
|
|
52
|
+
chunker.flush();
|
|
53
|
+
expect(chunks).toHaveLength(1);
|
|
54
|
+
const chunk = chunks[0];
|
|
55
|
+
expect(chunk.seq).toBe(0);
|
|
56
|
+
expect(chunk.eventCount).toBe(3);
|
|
57
|
+
expect(chunk.hasSnapshot).toBe(true);
|
|
58
|
+
expect(chunk.startTime).toBe(1000);
|
|
59
|
+
expect(chunk.endTime).toBe(1002);
|
|
60
|
+
expect(JSON.parse(chunk.body)).toEqual([
|
|
61
|
+
{ type: META, timestamp: 1000, data: { href: "http://x" } },
|
|
62
|
+
{ type: FULL_SNAPSHOT, timestamp: 1001, data: {} },
|
|
63
|
+
{ type: INCREMENTAL, timestamp: 1002, data: {} },
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
it("flushes when maxMillis elapse after the first event", () => {
|
|
67
|
+
const chunker = create(10_000, 5000);
|
|
68
|
+
chunker.add(event(INCREMENTAL, 1));
|
|
69
|
+
vi.advanceTimersByTime(4999);
|
|
70
|
+
expect(chunks).toHaveLength(0);
|
|
71
|
+
chunker.add(event(INCREMENTAL, 2));
|
|
72
|
+
vi.advanceTimersByTime(1);
|
|
73
|
+
expect(chunks).toHaveLength(1);
|
|
74
|
+
expect(chunks[0].eventCount).toBe(2);
|
|
75
|
+
});
|
|
76
|
+
it("flushes when the serialized size reaches maxBytes", () => {
|
|
77
|
+
const chunker = create(100, 60_000);
|
|
78
|
+
const payload = "x".repeat(40);
|
|
79
|
+
chunker.add(event(INCREMENTAL, 1, payload)); // ~70 bytes
|
|
80
|
+
expect(chunks).toHaveLength(0);
|
|
81
|
+
chunker.add(event(INCREMENTAL, 2, payload)); // crosses 100
|
|
82
|
+
expect(chunks).toHaveLength(1);
|
|
83
|
+
expect(chunks[0].eventCount).toBe(2);
|
|
84
|
+
});
|
|
85
|
+
it("emits an oversized single event as its own chunk", () => {
|
|
86
|
+
const chunker = create(50, 60_000);
|
|
87
|
+
chunker.add(event(FULL_SNAPSHOT, 1, "y".repeat(500)));
|
|
88
|
+
expect(chunks).toHaveLength(1);
|
|
89
|
+
expect(chunks[0].eventCount).toBe(1);
|
|
90
|
+
expect(chunks[0].hasSnapshot).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
it("closes the current chunk when a Meta event starts a new snapshot", () => {
|
|
93
|
+
const chunker = create();
|
|
94
|
+
chunker.add(event(META, 1));
|
|
95
|
+
chunker.add(event(FULL_SNAPSHOT, 2));
|
|
96
|
+
chunker.add(event(INCREMENTAL, 3));
|
|
97
|
+
expect(chunks).toHaveLength(0);
|
|
98
|
+
chunker.add(event(META, 4));
|
|
99
|
+
expect(chunks).toHaveLength(1);
|
|
100
|
+
expect(chunks[0].eventCount).toBe(3);
|
|
101
|
+
chunker.add(event(FULL_SNAPSHOT, 5));
|
|
102
|
+
chunker.flush();
|
|
103
|
+
expect(chunks).toHaveLength(2);
|
|
104
|
+
expect(chunks[1].eventCount).toBe(2);
|
|
105
|
+
expect(chunks[1].hasSnapshot).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
it("increments seq across chunks and resets hasSnapshot", () => {
|
|
108
|
+
const chunker = create();
|
|
109
|
+
chunker.add(event(FULL_SNAPSHOT, 1));
|
|
110
|
+
chunker.flush();
|
|
111
|
+
chunker.add(event(INCREMENTAL, 2));
|
|
112
|
+
chunker.flush();
|
|
113
|
+
chunker.add(event(INCREMENTAL, 3));
|
|
114
|
+
chunker.flush();
|
|
115
|
+
expect(chunks.map((c) => c.seq)).toEqual([0, 1, 2]);
|
|
116
|
+
expect(chunks.map((c) => c.hasSnapshot)).toEqual([true, false, false]);
|
|
117
|
+
});
|
|
118
|
+
it("cancels the pending timer on flush so it does not fire twice", () => {
|
|
119
|
+
const chunker = create(10_000, 1000);
|
|
120
|
+
chunker.add(event(INCREMENTAL, 1));
|
|
121
|
+
chunker.flush();
|
|
122
|
+
vi.advanceTimersByTime(5000);
|
|
123
|
+
expect(chunks).toHaveLength(1);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { vars } from "../../vars";
|
|
2
|
+
import { sessionId } from "../../api/session";
|
|
3
|
+
import { debug, doc, generateUniqueId, isSessionSampledIn, TRACE_ID_BYTES, warn, win } from "../../utils";
|
|
4
|
+
import { generateTraceId } from "../../utils/trace-id";
|
|
5
|
+
import { generateSpanId } from "../../utils/span-id";
|
|
6
|
+
import { isUrlIgnored } from "../../utils/ignore-rules";
|
|
7
|
+
import { addEventListener } from "../../utils/listeners";
|
|
8
|
+
import { onLastChance } from "../../utils/on-last-chance";
|
|
9
|
+
import { sendSessionRecordingChunk } from "../../transport";
|
|
10
|
+
import { newChunker } from "./chunker";
|
|
11
|
+
import { buildSessionRecordingLog } from "./log";
|
|
12
|
+
/**
|
|
13
|
+
* Global set by `dash0-session-recording.iife.js`. Read by `armSessionRecording()` and by
|
|
14
|
+
* `startSessionRecording()` when called without a recorder.
|
|
15
|
+
*/
|
|
16
|
+
export const GLOBAL_RECORDER_KEY = "dash0Recorder";
|
|
17
|
+
let recorder;
|
|
18
|
+
let armed = false;
|
|
19
|
+
let stopRecorder;
|
|
20
|
+
let chunker;
|
|
21
|
+
let lastChanceRegistered = false;
|
|
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;
|
|
30
|
+
/**
|
|
31
|
+
* Makes a recorder available. Called from the public `startSessionRecording` API, which the
|
|
32
|
+
* `dash0-session-recording.iife.js` script and npm consumers use. Recording starts as soon as both a recorder
|
|
33
|
+
* is registered and `init()` has armed session recording, in either order.
|
|
34
|
+
*/
|
|
35
|
+
export function registerSessionRecorder(r) {
|
|
36
|
+
recorder = r;
|
|
37
|
+
stoppedByConsumer = false;
|
|
38
|
+
if (armed) {
|
|
39
|
+
start();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Called from `init()` once configuration is in place and the session is sampled.
|
|
44
|
+
*
|
|
45
|
+
* Recorder precedence: `sessionRecording.recorder` from the init options, then a recorder registered through
|
|
46
|
+
* `startSessionRecording(recorder)`, then `window.dash0Recorder`. The last one is set by
|
|
47
|
+
* `dash0-session-recording.iife.js`, and is the only handover that works when that script executes before the
|
|
48
|
+
* initializer snippet has defined the `dash0` command queue.
|
|
49
|
+
*/
|
|
50
|
+
export function armSessionRecording() {
|
|
51
|
+
armed = true;
|
|
52
|
+
stoppedByConsumer = false;
|
|
53
|
+
if (vars.sessionRecording.recorder) {
|
|
54
|
+
recorder = vars.sessionRecording.recorder;
|
|
55
|
+
}
|
|
56
|
+
else if (!recorder) {
|
|
57
|
+
const globalRecorder = win?.[GLOBAL_RECORDER_KEY];
|
|
58
|
+
if (typeof globalRecorder === "function") {
|
|
59
|
+
recorder = globalRecorder;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (recorder) {
|
|
63
|
+
start();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
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() {
|
|
78
|
+
if (stopRecorder) {
|
|
79
|
+
try {
|
|
80
|
+
stopRecorder();
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
debug("Failed to stop session recorder", e);
|
|
84
|
+
}
|
|
85
|
+
stopRecorder = undefined;
|
|
86
|
+
}
|
|
87
|
+
chunker?.flush();
|
|
88
|
+
chunker = undefined;
|
|
89
|
+
}
|
|
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
|
+
});
|
|
134
|
+
}
|
|
135
|
+
function start() {
|
|
136
|
+
if (stopRecorder) {
|
|
137
|
+
debug("Session recording already running. Ignoring start.");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (!recorder || !win)
|
|
141
|
+
return;
|
|
142
|
+
const settings = vars.sessionRecording;
|
|
143
|
+
if (!vars.isSessionSampled) {
|
|
144
|
+
debug("Session is not sampled. Session recording will not start.");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (!isSessionSampledIn(sessionId ?? "", settings.samplingRate ?? 100)) {
|
|
148
|
+
debug("Session is not sampled for recording. Session recording will not start.");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (isUrlIgnored(win.location.href)) {
|
|
152
|
+
debug("Page URL is ignored. Session recording will not start.");
|
|
153
|
+
return;
|
|
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
|
+
}
|
|
162
|
+
const traceId = generateTraceId(sessionId);
|
|
163
|
+
const stream = {
|
|
164
|
+
recordingId: generateUniqueId(TRACE_ID_BYTES),
|
|
165
|
+
traceId,
|
|
166
|
+
spanId: generateSpanId(traceId),
|
|
167
|
+
};
|
|
168
|
+
const c = newChunker({
|
|
169
|
+
maxBytes: settings.chunkMaxBytes ?? 48000,
|
|
170
|
+
maxMillis: settings.chunkMaxMillis ?? 5000,
|
|
171
|
+
onChunk: (chunk) => {
|
|
172
|
+
try {
|
|
173
|
+
sendSessionRecordingChunk(buildSessionRecordingLog(stream, chunk), {
|
|
174
|
+
compress: !flushingWhileDocumentMayEnd,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
warn("Failed to transmit session recording chunk", e);
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
chunker = c;
|
|
183
|
+
try {
|
|
184
|
+
stopRecorder = recorder({
|
|
185
|
+
// rrweb can still emit after its stop function ran (trailing throttle timers), and a recorder that failed
|
|
186
|
+
// to start may have emitted already. Only accept events while this chunker is the active one.
|
|
187
|
+
emit: (event) => {
|
|
188
|
+
if (chunker === c)
|
|
189
|
+
c.add(event);
|
|
190
|
+
},
|
|
191
|
+
checkoutEveryNms: settings.checkoutEveryNms,
|
|
192
|
+
maskAllInputs: settings.maskAllInputs,
|
|
193
|
+
maskTextClass: settings.maskTextClass,
|
|
194
|
+
maskTextSelector: settings.maskTextSelector,
|
|
195
|
+
maskInputFn: settings.maskInputFn,
|
|
196
|
+
maskTextFn: settings.maskTextFn,
|
|
197
|
+
blockClass: settings.blockClass,
|
|
198
|
+
blockSelector: settings.blockSelector,
|
|
199
|
+
recordCanvas: settings.recordCanvas,
|
|
200
|
+
collectFonts: settings.collectFonts,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
warn("Failed to start session recorder", e);
|
|
205
|
+
abandonChunker(c);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (!stopRecorder) {
|
|
209
|
+
// rrweb returns undefined when it refuses to record, e.g. in an unsupported environment.
|
|
210
|
+
warn("Session recorder did not start.");
|
|
211
|
+
abandonChunker(c);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (!lastChanceRegistered) {
|
|
215
|
+
lastChanceRegistered = true;
|
|
216
|
+
onLastChance(() => {
|
|
217
|
+
flushingWhileDocumentMayEnd = true;
|
|
218
|
+
try {
|
|
219
|
+
chunker?.flush();
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
flushingWhileDocumentMayEnd = false;
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
debug("Session recording started", stream);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The recorder did not start, but it may already have emitted events into `c` and armed its flush timer. Drop
|
|
230
|
+
* them so no chunk of a stream that never started is transmitted later.
|
|
231
|
+
*/
|
|
232
|
+
function abandonChunker(c) {
|
|
233
|
+
c.discard();
|
|
234
|
+
chunker = undefined;
|
|
235
|
+
}
|