@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
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { SessionRecorder, SessionRecorderOptions } from "../../types/session-recording";
|
|
3
|
+
import { doc, win } from "../../utils";
|
|
4
|
+
|
|
5
|
+
vi.mock("../../transport", () => ({
|
|
6
|
+
sendSessionRecordingChunk: vi.fn(),
|
|
7
|
+
sendLog: vi.fn(),
|
|
8
|
+
sendSpan: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("../../api/session", () => ({
|
|
12
|
+
sessionId: "aabbccdd11223344",
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
type Module = typeof import("./index");
|
|
16
|
+
|
|
17
|
+
describe("session recording lifecycle", () => {
|
|
18
|
+
let mod: Module;
|
|
19
|
+
let vars: typeof import("../../vars").vars;
|
|
20
|
+
let sendSessionRecordingChunk: ReturnType<typeof vi.fn>;
|
|
21
|
+
let stopFn: ReturnType<typeof vi.fn>;
|
|
22
|
+
let recorder: ReturnType<typeof vi.fn> & SessionRecorder;
|
|
23
|
+
let capturedOptions: SessionRecorderOptions | undefined;
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
vi.resetModules();
|
|
27
|
+
vi.useFakeTimers();
|
|
28
|
+
mod = await import("./index");
|
|
29
|
+
vars = (await import("../../vars")).vars;
|
|
30
|
+
sendSessionRecordingChunk = (await import("../../transport")).sendSessionRecordingChunk as any;
|
|
31
|
+
sendSessionRecordingChunk.mockClear();
|
|
32
|
+
|
|
33
|
+
vars.isSessionSampled = true;
|
|
34
|
+
vars.ignoreUrls = [];
|
|
35
|
+
vars.endpoints = [{ url: "https://ingress.example.com", authToken: "x" }];
|
|
36
|
+
vars.sessionRecording = { ...vars.sessionRecording, samplingRate: 100, recorder: undefined };
|
|
37
|
+
|
|
38
|
+
stopFn = vi.fn();
|
|
39
|
+
capturedOptions = undefined;
|
|
40
|
+
recorder = vi.fn((opts: SessionRecorderOptions) => {
|
|
41
|
+
capturedOptions = opts;
|
|
42
|
+
return stopFn;
|
|
43
|
+
}) as any;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
mod.stopSessionRecording();
|
|
48
|
+
vi.useRealTimers();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("does not start before init arms recording", () => {
|
|
52
|
+
mod.registerSessionRecorder(recorder);
|
|
53
|
+
expect(recorder).not.toHaveBeenCalled();
|
|
54
|
+
expect(mod.isSessionRecording()).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("starts when armed after the recorder was registered", () => {
|
|
58
|
+
mod.registerSessionRecorder(recorder);
|
|
59
|
+
mod.armSessionRecording();
|
|
60
|
+
expect(recorder).toHaveBeenCalledTimes(1);
|
|
61
|
+
expect(mod.isSessionRecording()).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("starts when the recorder is registered after arming", () => {
|
|
65
|
+
mod.armSessionRecording();
|
|
66
|
+
expect(recorder).not.toHaveBeenCalled();
|
|
67
|
+
mod.registerSessionRecorder(recorder);
|
|
68
|
+
expect(recorder).toHaveBeenCalledTimes(1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("picks up window.dash0Recorder when arming, so the recorder script may run before the initializer", () => {
|
|
72
|
+
(win as any).dash0Recorder = recorder;
|
|
73
|
+
try {
|
|
74
|
+
mod.armSessionRecording();
|
|
75
|
+
expect(recorder).toHaveBeenCalledTimes(1);
|
|
76
|
+
expect(mod.isSessionRecording()).toBe(true);
|
|
77
|
+
} finally {
|
|
78
|
+
delete (win as any).dash0Recorder;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("prefers an explicitly registered recorder over window.dash0Recorder", () => {
|
|
83
|
+
const globalRecorder = vi.fn(() => vi.fn());
|
|
84
|
+
(win as any).dash0Recorder = globalRecorder;
|
|
85
|
+
try {
|
|
86
|
+
mod.registerSessionRecorder(recorder);
|
|
87
|
+
mod.armSessionRecording();
|
|
88
|
+
expect(recorder).toHaveBeenCalledTimes(1);
|
|
89
|
+
expect(globalRecorder).not.toHaveBeenCalled();
|
|
90
|
+
} finally {
|
|
91
|
+
delete (win as any).dash0Recorder;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("ignores a window.dash0Recorder that is not a function", () => {
|
|
96
|
+
(win as any).dash0Recorder = "nope";
|
|
97
|
+
try {
|
|
98
|
+
mod.armSessionRecording();
|
|
99
|
+
expect(mod.isSessionRecording()).toBe(false);
|
|
100
|
+
} finally {
|
|
101
|
+
delete (win as any).dash0Recorder;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("uses the recorder from vars.sessionRecording.recorder", () => {
|
|
106
|
+
vars.sessionRecording.recorder = recorder;
|
|
107
|
+
mod.armSessionRecording();
|
|
108
|
+
expect(recorder).toHaveBeenCalledTimes(1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("does not start twice", () => {
|
|
112
|
+
mod.armSessionRecording();
|
|
113
|
+
mod.registerSessionRecorder(recorder);
|
|
114
|
+
mod.registerSessionRecorder(recorder);
|
|
115
|
+
mod.armSessionRecording();
|
|
116
|
+
expect(recorder).toHaveBeenCalledTimes(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("forwards privacy settings to the recorder with masking on by default", () => {
|
|
120
|
+
mod.armSessionRecording();
|
|
121
|
+
mod.registerSessionRecorder(recorder);
|
|
122
|
+
|
|
123
|
+
expect(capturedOptions).toMatchObject({
|
|
124
|
+
maskAllInputs: true,
|
|
125
|
+
maskTextClass: "dash0-mask",
|
|
126
|
+
blockClass: "dash0-block",
|
|
127
|
+
recordCanvas: false,
|
|
128
|
+
collectFonts: false,
|
|
129
|
+
checkoutEveryNms: 300000,
|
|
130
|
+
});
|
|
131
|
+
expect(typeof capturedOptions!.emit).toBe("function");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("does not start when the session is not sampled", () => {
|
|
135
|
+
vars.isSessionSampled = false;
|
|
136
|
+
mod.armSessionRecording();
|
|
137
|
+
mod.registerSessionRecorder(recorder);
|
|
138
|
+
expect(recorder).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("does not start when the recording sampling rate excludes the session", () => {
|
|
142
|
+
vars.sessionRecording.samplingRate = 0;
|
|
143
|
+
mod.armSessionRecording();
|
|
144
|
+
mod.registerSessionRecorder(recorder);
|
|
145
|
+
expect(recorder).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("does not start when the page url is ignored", () => {
|
|
149
|
+
vars.ignoreUrls = [/localhost/];
|
|
150
|
+
mod.armSessionRecording();
|
|
151
|
+
mod.registerSessionRecorder(recorder);
|
|
152
|
+
expect(recorder).not.toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("survives a recorder that throws", () => {
|
|
156
|
+
const throwing = vi.fn(() => {
|
|
157
|
+
throw new Error("boom");
|
|
158
|
+
}) as any;
|
|
159
|
+
mod.armSessionRecording();
|
|
160
|
+
mod.registerSessionRecorder(throwing);
|
|
161
|
+
expect(mod.isSessionRecording()).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("discards events emitted by a recorder that then refuses to start", () => {
|
|
165
|
+
// rrweb emits Meta + FullSnapshot synchronously and returns undefined when it cannot record.
|
|
166
|
+
const refusing = vi.fn((opts: SessionRecorderOptions) => {
|
|
167
|
+
opts.emit({ type: 4, timestamp: 1000, data: {} });
|
|
168
|
+
opts.emit({ type: 2, timestamp: 1001, data: {} });
|
|
169
|
+
return undefined;
|
|
170
|
+
}) as any;
|
|
171
|
+
mod.armSessionRecording();
|
|
172
|
+
mod.registerSessionRecorder(refusing);
|
|
173
|
+
|
|
174
|
+
expect(mod.isSessionRecording()).toBe(false);
|
|
175
|
+
vi.advanceTimersByTime(10_000);
|
|
176
|
+
expect(sendSessionRecordingChunk).not.toHaveBeenCalled();
|
|
177
|
+
|
|
178
|
+
// stop must be a harmless no-op afterwards
|
|
179
|
+
expect(() => mod.stopSessionRecording()).not.toThrow();
|
|
180
|
+
expect(sendSessionRecordingChunk).not.toHaveBeenCalled();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("swallows a stop function that throws and still resets its state", () => {
|
|
184
|
+
stopFn.mockImplementation(() => {
|
|
185
|
+
throw new Error("stop failed");
|
|
186
|
+
});
|
|
187
|
+
mod.armSessionRecording();
|
|
188
|
+
mod.registerSessionRecorder(recorder);
|
|
189
|
+
capturedOptions!.emit({ type: 3, timestamp: 1, data: {} });
|
|
190
|
+
|
|
191
|
+
expect(() => mod.stopSessionRecording()).not.toThrow();
|
|
192
|
+
expect(mod.isSessionRecording()).toBe(false);
|
|
193
|
+
// buffered events are still flushed
|
|
194
|
+
expect(sendSessionRecordingChunk).toHaveBeenCalledTimes(1);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("ignores events the recorder emits after stop", () => {
|
|
198
|
+
mod.armSessionRecording();
|
|
199
|
+
mod.registerSessionRecorder(recorder);
|
|
200
|
+
mod.stopSessionRecording();
|
|
201
|
+
sendSessionRecordingChunk.mockClear();
|
|
202
|
+
|
|
203
|
+
// rrweb's trailing throttle timers can still call emit after its stop function ran.
|
|
204
|
+
capturedOptions!.emit({ type: 3, timestamp: 1, data: {} });
|
|
205
|
+
vi.advanceTimersByTime(10_000);
|
|
206
|
+
expect(sendSessionRecordingChunk).not.toHaveBeenCalled();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("compresses periodic chunks but sends the last-chance flush uncompressed", () => {
|
|
210
|
+
mod.armSessionRecording();
|
|
211
|
+
mod.registerSessionRecorder(recorder);
|
|
212
|
+
|
|
213
|
+
capturedOptions!.emit({ type: 3, timestamp: 1, data: {} });
|
|
214
|
+
vi.advanceTimersByTime(5000);
|
|
215
|
+
expect(sendSessionRecordingChunk).toHaveBeenCalledTimes(1);
|
|
216
|
+
expect(sendSessionRecordingChunk.mock.calls[0]![1]).toEqual({ compress: true });
|
|
217
|
+
|
|
218
|
+
capturedOptions!.emit({ type: 3, timestamp: 2, data: {} });
|
|
219
|
+
globalThis.dispatchEvent(new Event("pagehide"));
|
|
220
|
+
expect(sendSessionRecordingChunk).toHaveBeenCalledTimes(2);
|
|
221
|
+
expect(sendSessionRecordingChunk.mock.calls[1]![1]).toEqual({ compress: false });
|
|
222
|
+
|
|
223
|
+
// back to normal once the page keeps running
|
|
224
|
+
capturedOptions!.emit({ type: 3, timestamp: 3, data: {} });
|
|
225
|
+
vi.advanceTimersByTime(5000);
|
|
226
|
+
expect(sendSessionRecordingChunk).toHaveBeenCalledTimes(3);
|
|
227
|
+
expect(sendSessionRecordingChunk.mock.calls[2]![1]).toEqual({ compress: true });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("transmits chunks that share one trace id embedding the session id", () => {
|
|
231
|
+
mod.armSessionRecording();
|
|
232
|
+
mod.registerSessionRecorder(recorder);
|
|
233
|
+
|
|
234
|
+
capturedOptions!.emit({ type: 4, timestamp: 1000, data: {} });
|
|
235
|
+
capturedOptions!.emit({ type: 2, timestamp: 1001, data: {} });
|
|
236
|
+
vi.advanceTimersByTime(5000);
|
|
237
|
+
capturedOptions!.emit({ type: 3, timestamp: 7000, data: {} });
|
|
238
|
+
vi.advanceTimersByTime(5000);
|
|
239
|
+
|
|
240
|
+
expect(sendSessionRecordingChunk).toHaveBeenCalledTimes(2);
|
|
241
|
+
const [first, second] = sendSessionRecordingChunk.mock.calls.map((c) => c[0]);
|
|
242
|
+
|
|
243
|
+
expect(first.traceId).toMatch(/^d04200aabbccdd11223344[0-9a-f]{10}$/);
|
|
244
|
+
expect(second.traceId).toBe(first.traceId);
|
|
245
|
+
expect(second.spanId).toBe(first.spanId);
|
|
246
|
+
expect(first.attributes).toEqual(
|
|
247
|
+
expect.arrayContaining([
|
|
248
|
+
{ key: "event.name", value: { stringValue: "browser.session_recording" } },
|
|
249
|
+
{ key: "dash0.session_recording.seq", value: { intValue: "0" } },
|
|
250
|
+
{ key: "dash0.session_recording.has_snapshot", value: { boolValue: true } },
|
|
251
|
+
])
|
|
252
|
+
);
|
|
253
|
+
expect(second.attributes).toEqual(
|
|
254
|
+
expect.arrayContaining([
|
|
255
|
+
{ key: "dash0.session_recording.seq", value: { intValue: "1" } },
|
|
256
|
+
{ key: "dash0.session_recording.has_snapshot", value: { boolValue: false } },
|
|
257
|
+
])
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("stops the recorder and flushes buffered events on stop", () => {
|
|
262
|
+
mod.armSessionRecording();
|
|
263
|
+
mod.registerSessionRecorder(recorder);
|
|
264
|
+
capturedOptions!.emit({ type: 3, timestamp: 1, data: {} });
|
|
265
|
+
expect(sendSessionRecordingChunk).not.toHaveBeenCalled();
|
|
266
|
+
|
|
267
|
+
mod.stopSessionRecording();
|
|
268
|
+
|
|
269
|
+
expect(stopFn).toHaveBeenCalledTimes(1);
|
|
270
|
+
expect(sendSessionRecordingChunk).toHaveBeenCalledTimes(1);
|
|
271
|
+
expect(mod.isSessionRecording()).toBe(false);
|
|
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
|
+
});
|
|
358
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { addAttribute } from "../../utils/otel";
|
|
2
|
+
import {
|
|
3
|
+
EVENT_NAME,
|
|
4
|
+
EVENT_NAMES,
|
|
5
|
+
LOG_SEVERITIES,
|
|
6
|
+
SESSION_RECORDING_END_TIME_UNIX_NANO,
|
|
7
|
+
SESSION_RECORDING_EVENT_COUNT,
|
|
8
|
+
SESSION_RECORDING_HAS_SNAPSHOT,
|
|
9
|
+
SESSION_RECORDING_ID,
|
|
10
|
+
SESSION_RECORDING_SEQ,
|
|
11
|
+
} from "../../semantic-conventions";
|
|
12
|
+
import { addCommonAttributes } from "../../attributes";
|
|
13
|
+
import { toNanosTs } from "../../utils";
|
|
14
|
+
import { KeyValue, LogRecord } from "../../types/otlp";
|
|
15
|
+
import { Chunk } from "./chunker";
|
|
16
|
+
|
|
17
|
+
export type RecordingStream = {
|
|
18
|
+
/**
|
|
19
|
+
* Identifies one recorder run. All chunks of the run share it.
|
|
20
|
+
*/
|
|
21
|
+
recordingId: string;
|
|
22
|
+
/**
|
|
23
|
+
* Trace context shared by all chunks of the run. The trace ID embeds the session ID.
|
|
24
|
+
*/
|
|
25
|
+
traceId: string;
|
|
26
|
+
spanId: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function buildSessionRecordingLog(stream: RecordingStream, chunk: Chunk): LogRecord {
|
|
30
|
+
const attributes: KeyValue[] = [];
|
|
31
|
+
addAttribute(attributes, EVENT_NAME, EVENT_NAMES.SESSION_RECORDING);
|
|
32
|
+
addCommonAttributes(attributes);
|
|
33
|
+
addAttribute(attributes, SESSION_RECORDING_ID, stream.recordingId);
|
|
34
|
+
addAttribute(attributes, SESSION_RECORDING_SEQ, { intValue: String(chunk.seq) });
|
|
35
|
+
addAttribute(attributes, SESSION_RECORDING_EVENT_COUNT, { intValue: String(chunk.eventCount) });
|
|
36
|
+
addAttribute(attributes, SESSION_RECORDING_HAS_SNAPSHOT, chunk.hasSnapshot);
|
|
37
|
+
addAttribute(attributes, SESSION_RECORDING_END_TIME_UNIX_NANO, { intValue: toNanosTs(chunk.endTime) });
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
timeUnixNano: toNanosTs(chunk.startTime),
|
|
41
|
+
severityNumber: LOG_SEVERITIES.INFO,
|
|
42
|
+
severityText: "INFO",
|
|
43
|
+
body: { stringValue: chunk.body },
|
|
44
|
+
attributes,
|
|
45
|
+
traceId: stream.traceId,
|
|
46
|
+
spanId: stream.spanId,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { buildSessionRecordingLog } from "./log";
|
|
3
|
+
import { Chunk } from "./chunker";
|
|
4
|
+
|
|
5
|
+
vi.mock("../../api/session", () => ({ sessionId: "aabbccdd11223344" }));
|
|
6
|
+
|
|
7
|
+
describe("session recording log record", () => {
|
|
8
|
+
const stream = {
|
|
9
|
+
recordingId: "0123456789abcdef0123456789abcdef",
|
|
10
|
+
traceId: "d04200aabbccdd112233440123456789",
|
|
11
|
+
spanId: "0123456789abcdef",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const chunk: Chunk = {
|
|
15
|
+
seq: 3,
|
|
16
|
+
body: '[{"type":2,"timestamp":1700000000000}]',
|
|
17
|
+
eventCount: 1,
|
|
18
|
+
hasSnapshot: true,
|
|
19
|
+
startTime: 1700000000000,
|
|
20
|
+
endTime: 1700000004500,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
it("builds an INFO log record with the chunk body as a string", () => {
|
|
24
|
+
const log = buildSessionRecordingLog(stream, chunk);
|
|
25
|
+
|
|
26
|
+
expect(log.timeUnixNano).toBe("1700000000000000000");
|
|
27
|
+
expect(log.severityNumber).toBe(9);
|
|
28
|
+
expect(log.severityText).toBe("INFO");
|
|
29
|
+
expect(log.body).toEqual({ stringValue: chunk.body });
|
|
30
|
+
expect(log.traceId).toBe(stream.traceId);
|
|
31
|
+
expect(log.spanId).toBe(stream.spanId);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("stamps the event name, the stream and the chunk metadata as attributes", () => {
|
|
35
|
+
const log = buildSessionRecordingLog(stream, chunk);
|
|
36
|
+
|
|
37
|
+
expect(log.attributes).toEqual(
|
|
38
|
+
expect.arrayContaining([
|
|
39
|
+
{ key: "event.name", value: { stringValue: "browser.session_recording" } },
|
|
40
|
+
{ key: "session.id", value: { stringValue: "aabbccdd11223344" } },
|
|
41
|
+
{ key: "dash0.session_recording.id", value: { stringValue: stream.recordingId } },
|
|
42
|
+
{ key: "dash0.session_recording.seq", value: { intValue: "3" } },
|
|
43
|
+
{ key: "dash0.session_recording.event_count", value: { intValue: "1" } },
|
|
44
|
+
{ key: "dash0.session_recording.has_snapshot", value: { boolValue: true } },
|
|
45
|
+
{ key: "dash0.session_recording.end_time_unix_nano", value: { intValue: "1700000004500000000" } },
|
|
46
|
+
])
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("keeps the trace and span ids stable across chunks of one stream", () => {
|
|
51
|
+
const first = buildSessionRecordingLog(stream, chunk);
|
|
52
|
+
const second = buildSessionRecordingLog(stream, { ...chunk, seq: 4, hasSnapshot: false });
|
|
53
|
+
|
|
54
|
+
expect(second.traceId).toBe(first.traceId);
|
|
55
|
+
expect(second.spanId).toBe(first.spanId);
|
|
56
|
+
expect(second.attributes).toEqual(
|
|
57
|
+
expect.arrayContaining([{ key: "dash0.session_recording.has_snapshot", value: { boolValue: false } }])
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -29,6 +29,13 @@ export const WINDOW_HEIGHT = "browser.window.height";
|
|
|
29
29
|
export const NETWORK_CONNECTION_TYPE = "network.connection.subtype";
|
|
30
30
|
export const EXCEPTION_COMPONENT_STACK = "exception.component_stack";
|
|
31
31
|
|
|
32
|
+
// Session Recording Attribute Keys
|
|
33
|
+
export const SESSION_RECORDING_ID = "dash0.session_recording.id";
|
|
34
|
+
export const SESSION_RECORDING_SEQ = "dash0.session_recording.seq";
|
|
35
|
+
export const SESSION_RECORDING_EVENT_COUNT = "dash0.session_recording.event_count";
|
|
36
|
+
export const SESSION_RECORDING_HAS_SNAPSHOT = "dash0.session_recording.has_snapshot";
|
|
37
|
+
export const SESSION_RECORDING_END_TIME_UNIX_NANO = "dash0.session_recording.end_time_unix_nano";
|
|
38
|
+
|
|
32
39
|
// User Attribute Keys
|
|
33
40
|
export const USER_ID = "user.id";
|
|
34
41
|
export const USER_NAME = "user.name";
|
|
@@ -68,6 +75,7 @@ export const EVENT_NAMES = {
|
|
|
68
75
|
NAVIGATION_TIMING: "browser.navigation_timing",
|
|
69
76
|
WEB_VITAL: "browser.web_vital",
|
|
70
77
|
ERROR: "browser.error",
|
|
78
|
+
SESSION_RECORDING: "browser.session_recording",
|
|
71
79
|
};
|
|
72
80
|
export const SPAN_EVENT_NAME_EXCEPTION = "exception";
|
|
73
81
|
|
package/src/transport/fetch.ts
CHANGED
|
@@ -9,7 +9,15 @@ const BEACON_BODY_SIZE_LIMIT = 60000;
|
|
|
9
9
|
let pendingBodySize = 0;
|
|
10
10
|
let pendingRequestCount = 0;
|
|
11
11
|
|
|
12
|
-
export
|
|
12
|
+
export type SendOptions = {
|
|
13
|
+
/**
|
|
14
|
+
* Compress this request regardless of `vars.enableTransportCompression`. Used for payloads that are
|
|
15
|
+
* large and highly compressible, such as session recording chunks.
|
|
16
|
+
*/
|
|
17
|
+
compress?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export async function send(path: string, body: unknown, opts?: SendOptions): Promise<void> {
|
|
13
21
|
debug("Transmitting telemetry to endpoints", body);
|
|
14
22
|
|
|
15
23
|
const jsonString = JSON.stringify(body);
|
|
@@ -18,7 +26,7 @@ export async function send(path: string, body: unknown): Promise<void> {
|
|
|
18
26
|
let isCompressed = false;
|
|
19
27
|
|
|
20
28
|
// Try to compress if supported
|
|
21
|
-
if (typeof CompressionStream !== "undefined" && vars.enableTransportCompression) {
|
|
29
|
+
if (typeof CompressionStream !== "undefined" && (vars.enableTransportCompression || opts?.compress)) {
|
|
22
30
|
requestBody = await compressWithGzip(jsonString);
|
|
23
31
|
byteLength = requestBody.byteLength;
|
|
24
32
|
isCompressed = true;
|
|
@@ -334,4 +334,34 @@ describe("fetch transport keepalive handling", () => {
|
|
|
334
334
|
expect(call[1].keepalive).toBe(true);
|
|
335
335
|
});
|
|
336
336
|
});
|
|
337
|
+
|
|
338
|
+
describe("compress option", () => {
|
|
339
|
+
it("gzips the body when opts.compress is set even though enableTransportCompression is off", async () => {
|
|
340
|
+
// jsdom's Blob has no stream(); use Node's Blob, which CompressionStream can consume.
|
|
341
|
+
const { Blob: NodeBlob } = await import("node:buffer");
|
|
342
|
+
vi.stubGlobal("Blob", NodeBlob);
|
|
343
|
+
vars.enableTransportCompression = false;
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
await send("/v1/logs", { data: "x".repeat(1000) }, { compress: true });
|
|
347
|
+
} finally {
|
|
348
|
+
vi.unstubAllGlobals();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
352
|
+
const init = fetchMock.mock.calls[0]![1];
|
|
353
|
+
expect(init.headers["Content-Encoding"]).toBe("gzip");
|
|
354
|
+
expect(init.body).toBeInstanceOf(ArrayBuffer);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("does not gzip without opts.compress when enableTransportCompression is off", async () => {
|
|
358
|
+
vars.enableTransportCompression = false;
|
|
359
|
+
|
|
360
|
+
await send("/v1/logs", { data: "x".repeat(1000) });
|
|
361
|
+
|
|
362
|
+
const init = fetchMock.mock.calls[0]![1];
|
|
363
|
+
expect(init.headers["Content-Encoding"]).toBeUndefined();
|
|
364
|
+
expect(typeof init.body).toBe("string");
|
|
365
|
+
});
|
|
366
|
+
});
|
|
337
367
|
});
|
package/src/transport/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { newBatcher } from "./batcher";
|
|
2
|
-
import { send } from "./fetch";
|
|
2
|
+
import { send, SendOptions } from "./fetch";
|
|
3
3
|
import { vars } from "../vars";
|
|
4
4
|
import { debug, error, createRateLimiter } from "../utils";
|
|
5
5
|
import { ExportLogsServiceRequest, ExportTraceServiceRequest, LogRecord, Span } from "../types/otlp";
|
|
@@ -7,18 +7,34 @@ import { ExportLogsServiceRequest, ExportTraceServiceRequest, LogRecord, Span }
|
|
|
7
7
|
const logBatcher = newBatcher<LogRecord>(sendLogs);
|
|
8
8
|
const spanBatcher = newBatcher<Span>(sendSpans);
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Returns an `isRateLimited()` check whose limiter is created on first use, so the module can be imported
|
|
12
|
+
* without touching timers.
|
|
13
|
+
*/
|
|
14
|
+
function lazyRateLimiter(opts: Parameters<typeof createRateLimiter>[0]): () => boolean {
|
|
15
|
+
let limiter: (() => boolean) | undefined;
|
|
16
|
+
return () => {
|
|
17
|
+
if (!limiter) {
|
|
18
|
+
limiter = createRateLimiter(opts);
|
|
19
|
+
}
|
|
20
|
+
return limiter();
|
|
21
|
+
};
|
|
22
|
+
}
|
|
11
23
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
maxCallsPerTenSeconds: 128,
|
|
17
|
-
});
|
|
18
|
-
}
|
|
24
|
+
const isRateLimited = lazyRateLimiter({
|
|
25
|
+
maxCallsPerTenMinutes: 4096,
|
|
26
|
+
maxCallsPerTenSeconds: 128,
|
|
27
|
+
});
|
|
19
28
|
|
|
20
|
-
|
|
21
|
-
|
|
29
|
+
// Session recording chunks get their own budget. They are large and periodic, and must neither consume the
|
|
30
|
+
// shared budget of spans and logs nor be starved by it. The budget is deliberately generous: every chunk
|
|
31
|
+
// depends on the ones before it, so a dropped chunk makes the replay unrenderable until the next full snapshot
|
|
32
|
+
// (`checkoutEveryNms`). At the default `chunkMaxBytes` of 48 KB, 64 chunks per 10 s allow a ~3 MB mutation
|
|
33
|
+
// burst (a heavy first render), and 512 per 10 min cap a runaway page at ~25 MB of uncompressed replay JSON.
|
|
34
|
+
const isSessionRecordingRateLimited = lazyRateLimiter({
|
|
35
|
+
maxCallsPerTenMinutes: 512,
|
|
36
|
+
maxCallsPerTenSeconds: 64,
|
|
37
|
+
});
|
|
22
38
|
|
|
23
39
|
export function sendLog(log: LogRecord): void {
|
|
24
40
|
if (!vars.isSessionSampled) return;
|
|
@@ -31,20 +47,43 @@ export function sendLog(log: LogRecord): void {
|
|
|
31
47
|
logBatcher.send(log);
|
|
32
48
|
}
|
|
33
49
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Transmits a session recording chunk as a single log record. Chunks bypass the log batcher: batching 15 chunks
|
|
52
|
+
* of up to `chunkMaxBytes` each would produce requests far beyond the keepalive body limit.
|
|
53
|
+
*
|
|
54
|
+
* Chunks are gzipped by default because replay JSON compresses roughly 8:1. Pass `compress: false` when the
|
|
55
|
+
* request must be issued synchronously, i.e. while the document is being unloaded: compression is asynchronous
|
|
56
|
+
* and the page may be gone before `fetch()` is ever called.
|
|
57
|
+
*/
|
|
58
|
+
export function sendSessionRecordingChunk(log: LogRecord, opts?: SendOptions): void {
|
|
59
|
+
if (!vars.isSessionSampled) return;
|
|
60
|
+
|
|
61
|
+
if (isSessionRecordingRateLimited()) {
|
|
62
|
+
debug("Session recording rate limit. Will not send chunk.", log);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
sendLogs([log], { compress: opts?.compress ?? true });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sendLogs(logs: LogRecord[], opts?: SendOptions): void {
|
|
70
|
+
send(
|
|
71
|
+
"/v1/logs",
|
|
72
|
+
{
|
|
73
|
+
resourceLogs: [
|
|
74
|
+
{
|
|
75
|
+
resource: vars.resource,
|
|
76
|
+
scopeLogs: [
|
|
77
|
+
{
|
|
78
|
+
scope: vars.scope,
|
|
79
|
+
logRecords: logs,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
} satisfies ExportLogsServiceRequest,
|
|
85
|
+
opts
|
|
86
|
+
).catch((err) => {
|
|
48
87
|
error("Failed to transmit logs", err);
|
|
49
88
|
});
|
|
50
89
|
}
|