@dash0/sdk-web 0.23.0 → 0.25.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 +30 -0
- package/dist/modules/api/session-recording_test.js +36 -0
- package/dist/modules/api/start-view.js +47 -0
- package/dist/modules/api/start-view_test.js +91 -0
- package/dist/modules/entrypoint/npm-package.js +2 -0
- package/dist/modules/entrypoint/npm-package_test.js +7 -0
- package/dist/modules/entrypoint/script.js +5 -0
- package/dist/modules/entrypoint/session-recording-script.js +19 -0
- package/dist/modules/entrypoint/session-recording.js +11 -0
- package/dist/modules/instrumentations/navigation/event.js +42 -10
- package/dist/modules/instrumentations/navigation/event_test.js +128 -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 +165 -0
- package/dist/modules/instrumentations/session-recording/index_test.js +230 -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 +17 -0
- package/dist/types/api/session-recording_test.d.ts +1 -0
- package/dist/types/api/start-view.d.ts +33 -0
- package/dist/types/api/start-view_test.d.ts +1 -0
- package/dist/types/entrypoint/npm-package.d.ts +4 -0
- package/dist/types/entrypoint/npm-package_test.d.ts +1 -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/navigation/event.d.ts +18 -0
- package/dist/types/instrumentations/navigation/event_test.d.ts +1 -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 +11 -3
- package/src/api/init.ts +19 -1
- package/src/api/init_test.ts +33 -1
- package/src/api/session-recording.ts +40 -0
- package/src/api/session-recording_test.ts +44 -0
- package/src/api/start-view.ts +68 -0
- package/src/api/start-view_test.ts +125 -0
- package/src/entrypoint/npm-package.ts +4 -0
- package/src/entrypoint/npm-package_test.ts +8 -0
- package/src/entrypoint/script.ts +5 -0
- package/src/entrypoint/session-recording-script.ts +23 -0
- package/src/entrypoint/session-recording.ts +13 -0
- package/src/instrumentations/navigation/event.ts +63 -15
- package/src/instrumentations/navigation/event_test.ts +171 -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 +175 -0
- package/src/instrumentations/session-recording/index_test.ts +273 -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
|
@@ -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,165 @@
|
|
|
1
|
+
import { vars } from "../../vars";
|
|
2
|
+
import { sessionId } from "../../api/session";
|
|
3
|
+
import { debug, 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 { onLastChance } from "../../utils/on-last-chance";
|
|
8
|
+
import { sendSessionRecordingChunk } from "../../transport";
|
|
9
|
+
import { newChunker } from "./chunker";
|
|
10
|
+
import { buildSessionRecordingLog } from "./log";
|
|
11
|
+
/**
|
|
12
|
+
* Global set by `dash0-session-recording.iife.js`. Read by `armSessionRecording()` and by
|
|
13
|
+
* `startSessionRecording()` when called without a recorder.
|
|
14
|
+
*/
|
|
15
|
+
export const GLOBAL_RECORDER_KEY = "dash0Recorder";
|
|
16
|
+
let recorder;
|
|
17
|
+
let armed = false;
|
|
18
|
+
let stopRecorder;
|
|
19
|
+
let chunker;
|
|
20
|
+
let lastChanceRegistered = false;
|
|
21
|
+
// True while the last-chance handler flushes. The chunk emitted then must be sent uncompressed: gzip is
|
|
22
|
+
// asynchronous, and a document that is being unloaded may never get to the `fetch()` behind the await.
|
|
23
|
+
let flushingOnLastChance = false;
|
|
24
|
+
/**
|
|
25
|
+
* Makes a recorder available. Called from the public `startSessionRecording` API, which the
|
|
26
|
+
* `dash0-session-recording.iife.js` script and npm consumers use. Recording starts as soon as both a recorder
|
|
27
|
+
* is registered and `init()` has armed session recording, in either order.
|
|
28
|
+
*/
|
|
29
|
+
export function registerSessionRecorder(r) {
|
|
30
|
+
recorder = r;
|
|
31
|
+
if (armed) {
|
|
32
|
+
start();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Called from `init()` once configuration is in place and the session is sampled.
|
|
37
|
+
*
|
|
38
|
+
* Recorder precedence: `sessionRecording.recorder` from the init options, then a recorder registered through
|
|
39
|
+
* `startSessionRecording(recorder)`, then `window.dash0Recorder`. The last one is set by
|
|
40
|
+
* `dash0-session-recording.iife.js`, and is the only handover that works when that script executes before the
|
|
41
|
+
* initializer snippet has defined the `dash0` command queue.
|
|
42
|
+
*/
|
|
43
|
+
export function armSessionRecording() {
|
|
44
|
+
armed = true;
|
|
45
|
+
if (vars.sessionRecording.recorder) {
|
|
46
|
+
recorder = vars.sessionRecording.recorder;
|
|
47
|
+
}
|
|
48
|
+
else if (!recorder) {
|
|
49
|
+
const globalRecorder = win?.[GLOBAL_RECORDER_KEY];
|
|
50
|
+
if (typeof globalRecorder === "function") {
|
|
51
|
+
recorder = globalRecorder;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (recorder) {
|
|
55
|
+
start();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function stopSessionRecording() {
|
|
59
|
+
if (stopRecorder) {
|
|
60
|
+
try {
|
|
61
|
+
stopRecorder();
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
debug("Failed to stop session recorder", e);
|
|
65
|
+
}
|
|
66
|
+
stopRecorder = undefined;
|
|
67
|
+
}
|
|
68
|
+
chunker?.flush();
|
|
69
|
+
chunker = undefined;
|
|
70
|
+
}
|
|
71
|
+
export function isSessionRecording() {
|
|
72
|
+
return stopRecorder != null;
|
|
73
|
+
}
|
|
74
|
+
function start() {
|
|
75
|
+
if (stopRecorder) {
|
|
76
|
+
debug("Session recording already running. Ignoring start.");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!recorder || !win)
|
|
80
|
+
return;
|
|
81
|
+
const settings = vars.sessionRecording;
|
|
82
|
+
if (!vars.isSessionSampled) {
|
|
83
|
+
debug("Session is not sampled. Session recording will not start.");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (!isSessionSampledIn(sessionId ?? "", settings.samplingRate ?? 100)) {
|
|
87
|
+
debug("Session is not sampled for recording. Session recording will not start.");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (isUrlIgnored(win.location.href)) {
|
|
91
|
+
debug("Page URL is ignored. Session recording will not start.");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const traceId = generateTraceId(sessionId);
|
|
95
|
+
const stream = {
|
|
96
|
+
recordingId: generateUniqueId(TRACE_ID_BYTES),
|
|
97
|
+
traceId,
|
|
98
|
+
spanId: generateSpanId(traceId),
|
|
99
|
+
};
|
|
100
|
+
const c = newChunker({
|
|
101
|
+
maxBytes: settings.chunkMaxBytes ?? 48000,
|
|
102
|
+
maxMillis: settings.chunkMaxMillis ?? 5000,
|
|
103
|
+
onChunk: (chunk) => {
|
|
104
|
+
try {
|
|
105
|
+
sendSessionRecordingChunk(buildSessionRecordingLog(stream, chunk), { compress: !flushingOnLastChance });
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
warn("Failed to transmit session recording chunk", e);
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
chunker = c;
|
|
113
|
+
try {
|
|
114
|
+
stopRecorder = recorder({
|
|
115
|
+
// rrweb can still emit after its stop function ran (trailing throttle timers), and a recorder that failed
|
|
116
|
+
// to start may have emitted already. Only accept events while this chunker is the active one.
|
|
117
|
+
emit: (event) => {
|
|
118
|
+
if (chunker === c)
|
|
119
|
+
c.add(event);
|
|
120
|
+
},
|
|
121
|
+
checkoutEveryNms: settings.checkoutEveryNms,
|
|
122
|
+
maskAllInputs: settings.maskAllInputs,
|
|
123
|
+
maskTextClass: settings.maskTextClass,
|
|
124
|
+
maskTextSelector: settings.maskTextSelector,
|
|
125
|
+
maskInputFn: settings.maskInputFn,
|
|
126
|
+
maskTextFn: settings.maskTextFn,
|
|
127
|
+
blockClass: settings.blockClass,
|
|
128
|
+
blockSelector: settings.blockSelector,
|
|
129
|
+
recordCanvas: settings.recordCanvas,
|
|
130
|
+
collectFonts: settings.collectFonts,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
warn("Failed to start session recorder", e);
|
|
135
|
+
abandonChunker(c);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (!stopRecorder) {
|
|
139
|
+
// rrweb returns undefined when it refuses to record, e.g. in an unsupported environment.
|
|
140
|
+
warn("Session recorder did not start.");
|
|
141
|
+
abandonChunker(c);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (!lastChanceRegistered) {
|
|
145
|
+
lastChanceRegistered = true;
|
|
146
|
+
onLastChance(() => {
|
|
147
|
+
flushingOnLastChance = true;
|
|
148
|
+
try {
|
|
149
|
+
chunker?.flush();
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
flushingOnLastChance = false;
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
debug("Session recording started", stream);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The recorder did not start, but it may already have emitted events into `c` and armed its flush timer. Drop
|
|
160
|
+
* them so no chunk of a stream that never started is transmitted later.
|
|
161
|
+
*/
|
|
162
|
+
function abandonChunker(c) {
|
|
163
|
+
c.discard();
|
|
164
|
+
chunker = undefined;
|
|
165
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { win } from "../../utils";
|
|
3
|
+
vi.mock("../../transport", () => ({
|
|
4
|
+
sendSessionRecordingChunk: vi.fn(),
|
|
5
|
+
sendLog: vi.fn(),
|
|
6
|
+
sendSpan: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
vi.mock("../../api/session", () => ({
|
|
9
|
+
sessionId: "aabbccdd11223344",
|
|
10
|
+
}));
|
|
11
|
+
describe("session recording lifecycle", () => {
|
|
12
|
+
let mod;
|
|
13
|
+
let vars;
|
|
14
|
+
let sendSessionRecordingChunk;
|
|
15
|
+
let stopFn;
|
|
16
|
+
let recorder;
|
|
17
|
+
let capturedOptions;
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
vi.resetModules();
|
|
20
|
+
vi.useFakeTimers();
|
|
21
|
+
mod = await import("./index");
|
|
22
|
+
vars = (await import("../../vars")).vars;
|
|
23
|
+
sendSessionRecordingChunk = (await import("../../transport")).sendSessionRecordingChunk;
|
|
24
|
+
sendSessionRecordingChunk.mockClear();
|
|
25
|
+
vars.isSessionSampled = true;
|
|
26
|
+
vars.ignoreUrls = [];
|
|
27
|
+
vars.endpoints = [{ url: "https://ingress.example.com", authToken: "x" }];
|
|
28
|
+
vars.sessionRecording = { ...vars.sessionRecording, samplingRate: 100, recorder: undefined };
|
|
29
|
+
stopFn = vi.fn();
|
|
30
|
+
capturedOptions = undefined;
|
|
31
|
+
recorder = vi.fn((opts) => {
|
|
32
|
+
capturedOptions = opts;
|
|
33
|
+
return stopFn;
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
mod.stopSessionRecording();
|
|
38
|
+
vi.useRealTimers();
|
|
39
|
+
});
|
|
40
|
+
it("does not start before init arms recording", () => {
|
|
41
|
+
mod.registerSessionRecorder(recorder);
|
|
42
|
+
expect(recorder).not.toHaveBeenCalled();
|
|
43
|
+
expect(mod.isSessionRecording()).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
it("starts when armed after the recorder was registered", () => {
|
|
46
|
+
mod.registerSessionRecorder(recorder);
|
|
47
|
+
mod.armSessionRecording();
|
|
48
|
+
expect(recorder).toHaveBeenCalledTimes(1);
|
|
49
|
+
expect(mod.isSessionRecording()).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
it("starts when the recorder is registered after arming", () => {
|
|
52
|
+
mod.armSessionRecording();
|
|
53
|
+
expect(recorder).not.toHaveBeenCalled();
|
|
54
|
+
mod.registerSessionRecorder(recorder);
|
|
55
|
+
expect(recorder).toHaveBeenCalledTimes(1);
|
|
56
|
+
});
|
|
57
|
+
it("picks up window.dash0Recorder when arming, so the recorder script may run before the initializer", () => {
|
|
58
|
+
win.dash0Recorder = recorder;
|
|
59
|
+
try {
|
|
60
|
+
mod.armSessionRecording();
|
|
61
|
+
expect(recorder).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(mod.isSessionRecording()).toBe(true);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
delete win.dash0Recorder;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
it("prefers an explicitly registered recorder over window.dash0Recorder", () => {
|
|
69
|
+
const globalRecorder = vi.fn(() => vi.fn());
|
|
70
|
+
win.dash0Recorder = globalRecorder;
|
|
71
|
+
try {
|
|
72
|
+
mod.registerSessionRecorder(recorder);
|
|
73
|
+
mod.armSessionRecording();
|
|
74
|
+
expect(recorder).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(globalRecorder).not.toHaveBeenCalled();
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
delete win.dash0Recorder;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
it("ignores a window.dash0Recorder that is not a function", () => {
|
|
82
|
+
win.dash0Recorder = "nope";
|
|
83
|
+
try {
|
|
84
|
+
mod.armSessionRecording();
|
|
85
|
+
expect(mod.isSessionRecording()).toBe(false);
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
delete win.dash0Recorder;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
it("uses the recorder from vars.sessionRecording.recorder", () => {
|
|
92
|
+
vars.sessionRecording.recorder = recorder;
|
|
93
|
+
mod.armSessionRecording();
|
|
94
|
+
expect(recorder).toHaveBeenCalledTimes(1);
|
|
95
|
+
});
|
|
96
|
+
it("does not start twice", () => {
|
|
97
|
+
mod.armSessionRecording();
|
|
98
|
+
mod.registerSessionRecorder(recorder);
|
|
99
|
+
mod.registerSessionRecorder(recorder);
|
|
100
|
+
mod.armSessionRecording();
|
|
101
|
+
expect(recorder).toHaveBeenCalledTimes(1);
|
|
102
|
+
});
|
|
103
|
+
it("forwards privacy settings to the recorder with masking on by default", () => {
|
|
104
|
+
mod.armSessionRecording();
|
|
105
|
+
mod.registerSessionRecorder(recorder);
|
|
106
|
+
expect(capturedOptions).toMatchObject({
|
|
107
|
+
maskAllInputs: true,
|
|
108
|
+
maskTextClass: "dash0-mask",
|
|
109
|
+
blockClass: "dash0-block",
|
|
110
|
+
recordCanvas: false,
|
|
111
|
+
collectFonts: false,
|
|
112
|
+
checkoutEveryNms: 300000,
|
|
113
|
+
});
|
|
114
|
+
expect(typeof capturedOptions.emit).toBe("function");
|
|
115
|
+
});
|
|
116
|
+
it("does not start when the session is not sampled", () => {
|
|
117
|
+
vars.isSessionSampled = false;
|
|
118
|
+
mod.armSessionRecording();
|
|
119
|
+
mod.registerSessionRecorder(recorder);
|
|
120
|
+
expect(recorder).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
it("does not start when the recording sampling rate excludes the session", () => {
|
|
123
|
+
vars.sessionRecording.samplingRate = 0;
|
|
124
|
+
mod.armSessionRecording();
|
|
125
|
+
mod.registerSessionRecorder(recorder);
|
|
126
|
+
expect(recorder).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
it("does not start when the page url is ignored", () => {
|
|
129
|
+
vars.ignoreUrls = [/localhost/];
|
|
130
|
+
mod.armSessionRecording();
|
|
131
|
+
mod.registerSessionRecorder(recorder);
|
|
132
|
+
expect(recorder).not.toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
it("survives a recorder that throws", () => {
|
|
135
|
+
const throwing = vi.fn(() => {
|
|
136
|
+
throw new Error("boom");
|
|
137
|
+
});
|
|
138
|
+
mod.armSessionRecording();
|
|
139
|
+
mod.registerSessionRecorder(throwing);
|
|
140
|
+
expect(mod.isSessionRecording()).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
it("discards events emitted by a recorder that then refuses to start", () => {
|
|
143
|
+
// rrweb emits Meta + FullSnapshot synchronously and returns undefined when it cannot record.
|
|
144
|
+
const refusing = vi.fn((opts) => {
|
|
145
|
+
opts.emit({ type: 4, timestamp: 1000, data: {} });
|
|
146
|
+
opts.emit({ type: 2, timestamp: 1001, data: {} });
|
|
147
|
+
return undefined;
|
|
148
|
+
});
|
|
149
|
+
mod.armSessionRecording();
|
|
150
|
+
mod.registerSessionRecorder(refusing);
|
|
151
|
+
expect(mod.isSessionRecording()).toBe(false);
|
|
152
|
+
vi.advanceTimersByTime(10_000);
|
|
153
|
+
expect(sendSessionRecordingChunk).not.toHaveBeenCalled();
|
|
154
|
+
// stop must be a harmless no-op afterwards
|
|
155
|
+
expect(() => mod.stopSessionRecording()).not.toThrow();
|
|
156
|
+
expect(sendSessionRecordingChunk).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
it("swallows a stop function that throws and still resets its state", () => {
|
|
159
|
+
stopFn.mockImplementation(() => {
|
|
160
|
+
throw new Error("stop failed");
|
|
161
|
+
});
|
|
162
|
+
mod.armSessionRecording();
|
|
163
|
+
mod.registerSessionRecorder(recorder);
|
|
164
|
+
capturedOptions.emit({ type: 3, timestamp: 1, data: {} });
|
|
165
|
+
expect(() => mod.stopSessionRecording()).not.toThrow();
|
|
166
|
+
expect(mod.isSessionRecording()).toBe(false);
|
|
167
|
+
// buffered events are still flushed
|
|
168
|
+
expect(sendSessionRecordingChunk).toHaveBeenCalledTimes(1);
|
|
169
|
+
});
|
|
170
|
+
it("ignores events the recorder emits after stop", () => {
|
|
171
|
+
mod.armSessionRecording();
|
|
172
|
+
mod.registerSessionRecorder(recorder);
|
|
173
|
+
mod.stopSessionRecording();
|
|
174
|
+
sendSessionRecordingChunk.mockClear();
|
|
175
|
+
// rrweb's trailing throttle timers can still call emit after its stop function ran.
|
|
176
|
+
capturedOptions.emit({ type: 3, timestamp: 1, data: {} });
|
|
177
|
+
vi.advanceTimersByTime(10_000);
|
|
178
|
+
expect(sendSessionRecordingChunk).not.toHaveBeenCalled();
|
|
179
|
+
});
|
|
180
|
+
it("compresses periodic chunks but sends the last-chance flush uncompressed", () => {
|
|
181
|
+
mod.armSessionRecording();
|
|
182
|
+
mod.registerSessionRecorder(recorder);
|
|
183
|
+
capturedOptions.emit({ type: 3, timestamp: 1, data: {} });
|
|
184
|
+
vi.advanceTimersByTime(5000);
|
|
185
|
+
expect(sendSessionRecordingChunk).toHaveBeenCalledTimes(1);
|
|
186
|
+
expect(sendSessionRecordingChunk.mock.calls[0][1]).toEqual({ compress: true });
|
|
187
|
+
capturedOptions.emit({ type: 3, timestamp: 2, data: {} });
|
|
188
|
+
globalThis.dispatchEvent(new Event("pagehide"));
|
|
189
|
+
expect(sendSessionRecordingChunk).toHaveBeenCalledTimes(2);
|
|
190
|
+
expect(sendSessionRecordingChunk.mock.calls[1][1]).toEqual({ compress: false });
|
|
191
|
+
// back to normal once the page keeps running
|
|
192
|
+
capturedOptions.emit({ type: 3, timestamp: 3, data: {} });
|
|
193
|
+
vi.advanceTimersByTime(5000);
|
|
194
|
+
expect(sendSessionRecordingChunk).toHaveBeenCalledTimes(3);
|
|
195
|
+
expect(sendSessionRecordingChunk.mock.calls[2][1]).toEqual({ compress: true });
|
|
196
|
+
});
|
|
197
|
+
it("transmits chunks that share one trace id embedding the session id", () => {
|
|
198
|
+
mod.armSessionRecording();
|
|
199
|
+
mod.registerSessionRecorder(recorder);
|
|
200
|
+
capturedOptions.emit({ type: 4, timestamp: 1000, data: {} });
|
|
201
|
+
capturedOptions.emit({ type: 2, timestamp: 1001, data: {} });
|
|
202
|
+
vi.advanceTimersByTime(5000);
|
|
203
|
+
capturedOptions.emit({ type: 3, timestamp: 7000, data: {} });
|
|
204
|
+
vi.advanceTimersByTime(5000);
|
|
205
|
+
expect(sendSessionRecordingChunk).toHaveBeenCalledTimes(2);
|
|
206
|
+
const [first, second] = sendSessionRecordingChunk.mock.calls.map((c) => c[0]);
|
|
207
|
+
expect(first.traceId).toMatch(/^d04200aabbccdd11223344[0-9a-f]{10}$/);
|
|
208
|
+
expect(second.traceId).toBe(first.traceId);
|
|
209
|
+
expect(second.spanId).toBe(first.spanId);
|
|
210
|
+
expect(first.attributes).toEqual(expect.arrayContaining([
|
|
211
|
+
{ key: "event.name", value: { stringValue: "browser.session_recording" } },
|
|
212
|
+
{ key: "dash0.session_recording.seq", value: { intValue: "0" } },
|
|
213
|
+
{ key: "dash0.session_recording.has_snapshot", value: { boolValue: true } },
|
|
214
|
+
]));
|
|
215
|
+
expect(second.attributes).toEqual(expect.arrayContaining([
|
|
216
|
+
{ key: "dash0.session_recording.seq", value: { intValue: "1" } },
|
|
217
|
+
{ key: "dash0.session_recording.has_snapshot", value: { boolValue: false } },
|
|
218
|
+
]));
|
|
219
|
+
});
|
|
220
|
+
it("stops the recorder and flushes buffered events on stop", () => {
|
|
221
|
+
mod.armSessionRecording();
|
|
222
|
+
mod.registerSessionRecorder(recorder);
|
|
223
|
+
capturedOptions.emit({ type: 3, timestamp: 1, data: {} });
|
|
224
|
+
expect(sendSessionRecordingChunk).not.toHaveBeenCalled();
|
|
225
|
+
mod.stopSessionRecording();
|
|
226
|
+
expect(stopFn).toHaveBeenCalledTimes(1);
|
|
227
|
+
expect(sendSessionRecordingChunk).toHaveBeenCalledTimes(1);
|
|
228
|
+
expect(mod.isSessionRecording()).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { addAttribute } from "../../utils/otel";
|
|
2
|
+
import { EVENT_NAME, EVENT_NAMES, LOG_SEVERITIES, SESSION_RECORDING_END_TIME_UNIX_NANO, SESSION_RECORDING_EVENT_COUNT, SESSION_RECORDING_HAS_SNAPSHOT, SESSION_RECORDING_ID, SESSION_RECORDING_SEQ, } from "../../semantic-conventions";
|
|
3
|
+
import { addCommonAttributes } from "../../attributes";
|
|
4
|
+
import { toNanosTs } from "../../utils";
|
|
5
|
+
export function buildSessionRecordingLog(stream, chunk) {
|
|
6
|
+
const attributes = [];
|
|
7
|
+
addAttribute(attributes, EVENT_NAME, EVENT_NAMES.SESSION_RECORDING);
|
|
8
|
+
addCommonAttributes(attributes);
|
|
9
|
+
addAttribute(attributes, SESSION_RECORDING_ID, stream.recordingId);
|
|
10
|
+
addAttribute(attributes, SESSION_RECORDING_SEQ, { intValue: String(chunk.seq) });
|
|
11
|
+
addAttribute(attributes, SESSION_RECORDING_EVENT_COUNT, { intValue: String(chunk.eventCount) });
|
|
12
|
+
addAttribute(attributes, SESSION_RECORDING_HAS_SNAPSHOT, chunk.hasSnapshot);
|
|
13
|
+
addAttribute(attributes, SESSION_RECORDING_END_TIME_UNIX_NANO, { intValue: toNanosTs(chunk.endTime) });
|
|
14
|
+
return {
|
|
15
|
+
timeUnixNano: toNanosTs(chunk.startTime),
|
|
16
|
+
severityNumber: LOG_SEVERITIES.INFO,
|
|
17
|
+
severityText: "INFO",
|
|
18
|
+
body: { stringValue: chunk.body },
|
|
19
|
+
attributes,
|
|
20
|
+
traceId: stream.traceId,
|
|
21
|
+
spanId: stream.spanId,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { buildSessionRecordingLog } from "./log";
|
|
3
|
+
vi.mock("../../api/session", () => ({ sessionId: "aabbccdd11223344" }));
|
|
4
|
+
describe("session recording log record", () => {
|
|
5
|
+
const stream = {
|
|
6
|
+
recordingId: "0123456789abcdef0123456789abcdef",
|
|
7
|
+
traceId: "d04200aabbccdd112233440123456789",
|
|
8
|
+
spanId: "0123456789abcdef",
|
|
9
|
+
};
|
|
10
|
+
const chunk = {
|
|
11
|
+
seq: 3,
|
|
12
|
+
body: '[{"type":2,"timestamp":1700000000000}]',
|
|
13
|
+
eventCount: 1,
|
|
14
|
+
hasSnapshot: true,
|
|
15
|
+
startTime: 1700000000000,
|
|
16
|
+
endTime: 1700000004500,
|
|
17
|
+
};
|
|
18
|
+
it("builds an INFO log record with the chunk body as a string", () => {
|
|
19
|
+
const log = buildSessionRecordingLog(stream, chunk);
|
|
20
|
+
expect(log.timeUnixNano).toBe("1700000000000000000");
|
|
21
|
+
expect(log.severityNumber).toBe(9);
|
|
22
|
+
expect(log.severityText).toBe("INFO");
|
|
23
|
+
expect(log.body).toEqual({ stringValue: chunk.body });
|
|
24
|
+
expect(log.traceId).toBe(stream.traceId);
|
|
25
|
+
expect(log.spanId).toBe(stream.spanId);
|
|
26
|
+
});
|
|
27
|
+
it("stamps the event name, the stream and the chunk metadata as attributes", () => {
|
|
28
|
+
const log = buildSessionRecordingLog(stream, chunk);
|
|
29
|
+
expect(log.attributes).toEqual(expect.arrayContaining([
|
|
30
|
+
{ key: "event.name", value: { stringValue: "browser.session_recording" } },
|
|
31
|
+
{ key: "session.id", value: { stringValue: "aabbccdd11223344" } },
|
|
32
|
+
{ key: "dash0.session_recording.id", value: { stringValue: stream.recordingId } },
|
|
33
|
+
{ key: "dash0.session_recording.seq", value: { intValue: "3" } },
|
|
34
|
+
{ key: "dash0.session_recording.event_count", value: { intValue: "1" } },
|
|
35
|
+
{ key: "dash0.session_recording.has_snapshot", value: { boolValue: true } },
|
|
36
|
+
{ key: "dash0.session_recording.end_time_unix_nano", value: { intValue: "1700000004500000000" } },
|
|
37
|
+
]));
|
|
38
|
+
});
|
|
39
|
+
it("keeps the trace and span ids stable across chunks of one stream", () => {
|
|
40
|
+
const first = buildSessionRecordingLog(stream, chunk);
|
|
41
|
+
const second = buildSessionRecordingLog(stream, { ...chunk, seq: 4, hasSnapshot: false });
|
|
42
|
+
expect(second.traceId).toBe(first.traceId);
|
|
43
|
+
expect(second.spanId).toBe(first.spanId);
|
|
44
|
+
expect(second.attributes).toEqual(expect.arrayContaining([{ key: "dash0.session_recording.has_snapshot", value: { boolValue: false } }]));
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -26,6 +26,12 @@ export const WINDOW_WIDTH = "browser.window.width";
|
|
|
26
26
|
export const WINDOW_HEIGHT = "browser.window.height";
|
|
27
27
|
export const NETWORK_CONNECTION_TYPE = "network.connection.subtype";
|
|
28
28
|
export const EXCEPTION_COMPONENT_STACK = "exception.component_stack";
|
|
29
|
+
// Session Recording Attribute Keys
|
|
30
|
+
export const SESSION_RECORDING_ID = "dash0.session_recording.id";
|
|
31
|
+
export const SESSION_RECORDING_SEQ = "dash0.session_recording.seq";
|
|
32
|
+
export const SESSION_RECORDING_EVENT_COUNT = "dash0.session_recording.event_count";
|
|
33
|
+
export const SESSION_RECORDING_HAS_SNAPSHOT = "dash0.session_recording.has_snapshot";
|
|
34
|
+
export const SESSION_RECORDING_END_TIME_UNIX_NANO = "dash0.session_recording.end_time_unix_nano";
|
|
29
35
|
// User Attribute Keys
|
|
30
36
|
export const USER_ID = "user.id";
|
|
31
37
|
export const USER_NAME = "user.name";
|
|
@@ -60,6 +66,7 @@ export const EVENT_NAMES = {
|
|
|
60
66
|
NAVIGATION_TIMING: "browser.navigation_timing",
|
|
61
67
|
WEB_VITAL: "browser.web_vital",
|
|
62
68
|
ERROR: "browser.error",
|
|
69
|
+
SESSION_RECORDING: "browser.session_recording",
|
|
63
70
|
};
|
|
64
71
|
export const SPAN_EVENT_NAME_EXCEPTION = "exception";
|
|
65
72
|
// Log Severities
|
|
@@ -7,14 +7,14 @@ import { noop, warn, fetch, debug } from "../utils";
|
|
|
7
7
|
const BEACON_BODY_SIZE_LIMIT = 60000;
|
|
8
8
|
let pendingBodySize = 0;
|
|
9
9
|
let pendingRequestCount = 0;
|
|
10
|
-
export async function send(path, body) {
|
|
10
|
+
export async function send(path, body, opts) {
|
|
11
11
|
debug("Transmitting telemetry to endpoints", body);
|
|
12
12
|
const jsonString = JSON.stringify(body);
|
|
13
13
|
let requestBody = jsonString;
|
|
14
14
|
let byteLength = new Blob([jsonString]).size;
|
|
15
15
|
let isCompressed = false;
|
|
16
16
|
// Try to compress if supported
|
|
17
|
-
if (typeof CompressionStream !== "undefined" && vars.enableTransportCompression) {
|
|
17
|
+
if (typeof CompressionStream !== "undefined" && (vars.enableTransportCompression || opts?.compress)) {
|
|
18
18
|
requestBody = await compressWithGzip(jsonString);
|
|
19
19
|
byteLength = requestBody.byteLength;
|
|
20
20
|
isCompressed = true;
|
|
@@ -264,4 +264,29 @@ describe("fetch transport keepalive handling", () => {
|
|
|
264
264
|
expect(call[1].keepalive).toBe(true);
|
|
265
265
|
});
|
|
266
266
|
});
|
|
267
|
+
describe("compress option", () => {
|
|
268
|
+
it("gzips the body when opts.compress is set even though enableTransportCompression is off", async () => {
|
|
269
|
+
// jsdom's Blob has no stream(); use Node's Blob, which CompressionStream can consume.
|
|
270
|
+
const { Blob: NodeBlob } = await import("node:buffer");
|
|
271
|
+
vi.stubGlobal("Blob", NodeBlob);
|
|
272
|
+
vars.enableTransportCompression = false;
|
|
273
|
+
try {
|
|
274
|
+
await send("/v1/logs", { data: "x".repeat(1000) }, { compress: true });
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
vi.unstubAllGlobals();
|
|
278
|
+
}
|
|
279
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
280
|
+
const init = fetchMock.mock.calls[0][1];
|
|
281
|
+
expect(init.headers["Content-Encoding"]).toBe("gzip");
|
|
282
|
+
expect(init.body).toBeInstanceOf(ArrayBuffer);
|
|
283
|
+
});
|
|
284
|
+
it("does not gzip without opts.compress when enableTransportCompression is off", async () => {
|
|
285
|
+
vars.enableTransportCompression = false;
|
|
286
|
+
await send("/v1/logs", { data: "x".repeat(1000) });
|
|
287
|
+
const init = fetchMock.mock.calls[0][1];
|
|
288
|
+
expect(init.headers["Content-Encoding"]).toBeUndefined();
|
|
289
|
+
expect(typeof init.body).toBe("string");
|
|
290
|
+
});
|
|
291
|
+
});
|
|
267
292
|
});
|