@dash0/sdk-web 0.24.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.
Files changed (76) hide show
  1. package/README.md +5 -0
  2. package/dist/dash0-session-recording.iife.js +2 -0
  3. package/dist/dash0-session-recording.iife.js.map +1 -0
  4. package/dist/dash0-session-recording.js +2 -0
  5. package/dist/dash0-session-recording.js.map +1 -0
  6. package/dist/dash0-session-recording.umd.cjs +2 -0
  7. package/dist/dash0-session-recording.umd.cjs.map +1 -0
  8. package/dist/dash0.iife.js +1 -1
  9. package/dist/dash0.iife.js.map +1 -1
  10. package/dist/dash0.js +1 -1
  11. package/dist/dash0.js.map +1 -1
  12. package/dist/dash0.umd.cjs +1 -1
  13. package/dist/dash0.umd.cjs.map +1 -1
  14. package/dist/modules/api/init.js +18 -1
  15. package/dist/modules/api/init_test.js +27 -0
  16. package/dist/modules/api/session-recording.js +30 -0
  17. package/dist/modules/api/session-recording_test.js +36 -0
  18. package/dist/modules/entrypoint/npm-package.js +1 -0
  19. package/dist/modules/entrypoint/script.js +3 -0
  20. package/dist/modules/entrypoint/session-recording-script.js +19 -0
  21. package/dist/modules/entrypoint/session-recording.js +11 -0
  22. package/dist/modules/instrumentations/session-recording/chunker.js +70 -0
  23. package/dist/modules/instrumentations/session-recording/chunker_test.js +125 -0
  24. package/dist/modules/instrumentations/session-recording/index.js +165 -0
  25. package/dist/modules/instrumentations/session-recording/index_test.js +230 -0
  26. package/dist/modules/instrumentations/session-recording/log.js +23 -0
  27. package/dist/modules/instrumentations/session-recording/log_test.js +46 -0
  28. package/dist/modules/semantic-conventions.js +7 -0
  29. package/dist/modules/transport/fetch.js +2 -2
  30. package/dist/modules/transport/fetch_test.js +25 -0
  31. package/dist/modules/transport/index.js +44 -11
  32. package/dist/modules/transport/index_test.js +73 -0
  33. package/dist/modules/types/session-recording.js +1 -0
  34. package/dist/modules/vars.js +11 -0
  35. package/dist/tsconfig.tsbuildinfo +1 -1
  36. package/dist/types/api/session-recording.d.ts +17 -0
  37. package/dist/types/api/session-recording_test.d.ts +1 -0
  38. package/dist/types/entrypoint/npm-package.d.ts +2 -0
  39. package/dist/types/entrypoint/session-recording-script.d.ts +1 -0
  40. package/dist/types/entrypoint/session-recording.d.ts +5 -0
  41. package/dist/types/instrumentations/session-recording/chunker.d.ts +38 -0
  42. package/dist/types/instrumentations/session-recording/chunker_test.d.ts +1 -0
  43. package/dist/types/instrumentations/session-recording/index.d.ts +23 -0
  44. package/dist/types/instrumentations/session-recording/index_test.d.ts +1 -0
  45. package/dist/types/instrumentations/session-recording/log.d.ts +14 -0
  46. package/dist/types/instrumentations/session-recording/log_test.d.ts +1 -0
  47. package/dist/types/semantic-conventions.d.ts +6 -0
  48. package/dist/types/transport/fetch.d.ts +8 -1
  49. package/dist/types/transport/index.d.ts +10 -0
  50. package/dist/types/transport/index_test.d.ts +1 -0
  51. package/dist/types/types/options.d.ts +2 -2
  52. package/dist/types/types/session-recording.d.ts +128 -0
  53. package/dist/types/vars.d.ts +6 -0
  54. package/package.json +10 -2
  55. package/src/api/init.ts +19 -1
  56. package/src/api/init_test.ts +33 -1
  57. package/src/api/session-recording.ts +40 -0
  58. package/src/api/session-recording_test.ts +44 -0
  59. package/src/entrypoint/npm-package.ts +2 -0
  60. package/src/entrypoint/script.ts +3 -0
  61. package/src/entrypoint/session-recording-script.ts +23 -0
  62. package/src/entrypoint/session-recording.ts +13 -0
  63. package/src/instrumentations/session-recording/chunker.ts +118 -0
  64. package/src/instrumentations/session-recording/chunker_test.ts +147 -0
  65. package/src/instrumentations/session-recording/index.ts +175 -0
  66. package/src/instrumentations/session-recording/index_test.ts +273 -0
  67. package/src/instrumentations/session-recording/log.ts +48 -0
  68. package/src/instrumentations/session-recording/log_test.ts +60 -0
  69. package/src/semantic-conventions.ts +8 -0
  70. package/src/transport/fetch.ts +10 -2
  71. package/src/transport/fetch_test.ts +30 -0
  72. package/src/transport/index.ts +64 -25
  73. package/src/transport/index_test.ts +90 -0
  74. package/src/types/options.ts +3 -1
  75. package/src/types/session-recording.ts +144 -0
  76. package/src/vars.ts +18 -0
@@ -0,0 +1,118 @@
1
+ import { setTimeout, clearTimeout } from "../../utils/timers";
2
+ import { SessionRecordingEvent } from "../../types/session-recording";
3
+
4
+ const RRWEB_EVENT_TYPE_FULL_SNAPSHOT = 2;
5
+ const RRWEB_EVENT_TYPE_META = 4;
6
+
7
+ export type Chunk = {
8
+ /**
9
+ * Zero-based, monotonic within one recording.
10
+ */
11
+ seq: number;
12
+ /**
13
+ * The rrweb events as a serialized JSON array.
14
+ */
15
+ body: string;
16
+ eventCount: number;
17
+ hasSnapshot: boolean;
18
+ /**
19
+ * Milliseconds since the unix epoch of the first and the last event in the chunk.
20
+ */
21
+ startTime: number;
22
+ endTime: number;
23
+ };
24
+
25
+ export type ChunkerOptions = {
26
+ maxBytes: number;
27
+ maxMillis: number;
28
+ onChunk: (chunk: Chunk) => void;
29
+ };
30
+
31
+ export type Chunker = {
32
+ add(event: SessionRecordingEvent): void;
33
+ flush(): void;
34
+ /**
35
+ * Drops buffered events without emitting a chunk and cancels the pending time-based flush. Used when the
36
+ * recorder failed to start after it already emitted events, so no stray chunk is transmitted later.
37
+ */
38
+ discard(): void;
39
+ };
40
+
41
+ /**
42
+ * Buffers rrweb events and hands them out as chunks. A chunk closes when its serialized size reaches
43
+ * `maxBytes`, when `maxMillis` have passed since its first event, when a new full snapshot begins, or when
44
+ * `flush()` is called. Events are serialized once, on arrival, so a flush is a join and not a second stringify.
45
+ */
46
+ export function newChunker(opts: ChunkerOptions): Chunker {
47
+ let serialized: string[] = [];
48
+ let byteSize = 0;
49
+ let hasSnapshot = false;
50
+ let startTime = 0;
51
+ let endTime = 0;
52
+ let seq = 0;
53
+ let pendingFlushTimeout: ReturnType<typeof setTimeout> | null = null;
54
+
55
+ return { add, flush, discard };
56
+
57
+ function add(event: SessionRecordingEvent): void {
58
+ // A Meta event announces a new full snapshot. Close the current chunk first so the snapshot starts a fresh
59
+ // one and a replay can begin at that chunk.
60
+ if (event.type === RRWEB_EVENT_TYPE_META && serialized.length > 0) {
61
+ flush();
62
+ }
63
+
64
+ const json = JSON.stringify(event);
65
+ if (json == null) return;
66
+
67
+ if (serialized.length === 0) {
68
+ startTime = event.timestamp;
69
+ pendingFlushTimeout = setTimeout(flush, opts.maxMillis);
70
+ }
71
+
72
+ serialized.push(json);
73
+ byteSize += json.length;
74
+ endTime = event.timestamp;
75
+ if (event.type === RRWEB_EVENT_TYPE_FULL_SNAPSHOT) {
76
+ hasSnapshot = true;
77
+ }
78
+
79
+ if (byteSize >= opts.maxBytes) {
80
+ flush();
81
+ }
82
+ }
83
+
84
+ function discard(): void {
85
+ clearPendingFlush();
86
+ serialized = [];
87
+ byteSize = 0;
88
+ hasSnapshot = false;
89
+ }
90
+
91
+ function clearPendingFlush(): void {
92
+ if (pendingFlushTimeout != null) {
93
+ clearTimeout(pendingFlushTimeout);
94
+ pendingFlushTimeout = null;
95
+ }
96
+ }
97
+
98
+ function flush(): void {
99
+ clearPendingFlush();
100
+
101
+ if (serialized.length === 0) return;
102
+
103
+ const chunk: Chunk = {
104
+ seq: seq++,
105
+ body: "[" + serialized.join(",") + "]",
106
+ eventCount: serialized.length,
107
+ hasSnapshot,
108
+ startTime,
109
+ endTime,
110
+ };
111
+
112
+ serialized = [];
113
+ byteSize = 0;
114
+ hasSnapshot = false;
115
+
116
+ opts.onChunk(chunk);
117
+ }
118
+ }
@@ -0,0 +1,147 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { Chunk, newChunker } from "./chunker";
3
+
4
+ // The SDK's timer wrapper captures window.setTimeout at import time, before vitest installs fake timers.
5
+ // Resolve the globals lazily so vi.useFakeTimers() takes effect.
6
+ vi.mock("../../utils/timers", () => ({
7
+ setTimeout: (...args: Parameters<typeof globalThis.setTimeout>) => globalThis.setTimeout(...args),
8
+ clearTimeout: (...args: Parameters<typeof globalThis.clearTimeout>) => globalThis.clearTimeout(...args),
9
+ }));
10
+
11
+ const META = 4;
12
+ const FULL_SNAPSHOT = 2;
13
+ const INCREMENTAL = 3;
14
+
15
+ function event(type: number, timestamp: number, data: unknown = {}) {
16
+ return { type, timestamp, data };
17
+ }
18
+
19
+ describe("session recording chunker", () => {
20
+ let chunks: Chunk[];
21
+
22
+ beforeEach(() => {
23
+ vi.useFakeTimers();
24
+ chunks = [];
25
+ });
26
+
27
+ afterEach(() => {
28
+ vi.useRealTimers();
29
+ });
30
+
31
+ function create(maxBytes = 10_000, maxMillis = 5000) {
32
+ return newChunker({ maxBytes, maxMillis, onChunk: (c) => chunks.push(c) });
33
+ }
34
+
35
+ it("does nothing on flush when empty", () => {
36
+ const chunker = create();
37
+ chunker.flush();
38
+ expect(chunks).toEqual([]);
39
+ });
40
+
41
+ it("discards buffered events and the pending time-based flush", () => {
42
+ const chunker = create();
43
+ chunker.add(event(META, 1000));
44
+ chunker.add(event(FULL_SNAPSHOT, 1001));
45
+
46
+ chunker.discard();
47
+ vi.advanceTimersByTime(10_000);
48
+ expect(chunks).toEqual([]);
49
+
50
+ // The chunker stays usable, and the dropped events do not leak into the next chunk.
51
+ chunker.add(event(INCREMENTAL, 2000));
52
+ chunker.flush();
53
+ expect(chunks).toHaveLength(1);
54
+ expect(chunks[0]!.seq).toBe(0);
55
+ expect(chunks[0]!.eventCount).toBe(1);
56
+ expect(chunks[0]!.hasSnapshot).toBe(false);
57
+ });
58
+
59
+ it("flushes on explicit flush with a serialized JSON array body", () => {
60
+ const chunker = create();
61
+ chunker.add(event(META, 1000, { href: "http://x" }));
62
+ chunker.add(event(FULL_SNAPSHOT, 1001));
63
+ chunker.add(event(INCREMENTAL, 1002));
64
+ chunker.flush();
65
+
66
+ expect(chunks).toHaveLength(1);
67
+ const chunk = chunks[0]!;
68
+ expect(chunk.seq).toBe(0);
69
+ expect(chunk.eventCount).toBe(3);
70
+ expect(chunk.hasSnapshot).toBe(true);
71
+ expect(chunk.startTime).toBe(1000);
72
+ expect(chunk.endTime).toBe(1002);
73
+ expect(JSON.parse(chunk.body)).toEqual([
74
+ { type: META, timestamp: 1000, data: { href: "http://x" } },
75
+ { type: FULL_SNAPSHOT, timestamp: 1001, data: {} },
76
+ { type: INCREMENTAL, timestamp: 1002, data: {} },
77
+ ]);
78
+ });
79
+
80
+ it("flushes when maxMillis elapse after the first event", () => {
81
+ const chunker = create(10_000, 5000);
82
+ chunker.add(event(INCREMENTAL, 1));
83
+ vi.advanceTimersByTime(4999);
84
+ expect(chunks).toHaveLength(0);
85
+ chunker.add(event(INCREMENTAL, 2));
86
+ vi.advanceTimersByTime(1);
87
+ expect(chunks).toHaveLength(1);
88
+ expect(chunks[0]!.eventCount).toBe(2);
89
+ });
90
+
91
+ it("flushes when the serialized size reaches maxBytes", () => {
92
+ const chunker = create(100, 60_000);
93
+ const payload = "x".repeat(40);
94
+ chunker.add(event(INCREMENTAL, 1, payload)); // ~70 bytes
95
+ expect(chunks).toHaveLength(0);
96
+ chunker.add(event(INCREMENTAL, 2, payload)); // crosses 100
97
+ expect(chunks).toHaveLength(1);
98
+ expect(chunks[0]!.eventCount).toBe(2);
99
+ });
100
+
101
+ it("emits an oversized single event as its own chunk", () => {
102
+ const chunker = create(50, 60_000);
103
+ chunker.add(event(FULL_SNAPSHOT, 1, "y".repeat(500)));
104
+ expect(chunks).toHaveLength(1);
105
+ expect(chunks[0]!.eventCount).toBe(1);
106
+ expect(chunks[0]!.hasSnapshot).toBe(true);
107
+ });
108
+
109
+ it("closes the current chunk when a Meta event starts a new snapshot", () => {
110
+ const chunker = create();
111
+ chunker.add(event(META, 1));
112
+ chunker.add(event(FULL_SNAPSHOT, 2));
113
+ chunker.add(event(INCREMENTAL, 3));
114
+ expect(chunks).toHaveLength(0);
115
+
116
+ chunker.add(event(META, 4));
117
+ expect(chunks).toHaveLength(1);
118
+ expect(chunks[0]!.eventCount).toBe(3);
119
+
120
+ chunker.add(event(FULL_SNAPSHOT, 5));
121
+ chunker.flush();
122
+ expect(chunks).toHaveLength(2);
123
+ expect(chunks[1]!.eventCount).toBe(2);
124
+ expect(chunks[1]!.hasSnapshot).toBe(true);
125
+ });
126
+
127
+ it("increments seq across chunks and resets hasSnapshot", () => {
128
+ const chunker = create();
129
+ chunker.add(event(FULL_SNAPSHOT, 1));
130
+ chunker.flush();
131
+ chunker.add(event(INCREMENTAL, 2));
132
+ chunker.flush();
133
+ chunker.add(event(INCREMENTAL, 3));
134
+ chunker.flush();
135
+
136
+ expect(chunks.map((c) => c.seq)).toEqual([0, 1, 2]);
137
+ expect(chunks.map((c) => c.hasSnapshot)).toEqual([true, false, false]);
138
+ });
139
+
140
+ it("cancels the pending timer on flush so it does not fire twice", () => {
141
+ const chunker = create(10_000, 1000);
142
+ chunker.add(event(INCREMENTAL, 1));
143
+ chunker.flush();
144
+ vi.advanceTimersByTime(5000);
145
+ expect(chunks).toHaveLength(1);
146
+ });
147
+ });
@@ -0,0 +1,175 @@
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 { SessionRecorder, SessionRecordingEvent } from "../../types/session-recording";
10
+ import { Chunker, newChunker } from "./chunker";
11
+ import { buildSessionRecordingLog, RecordingStream } from "./log";
12
+
13
+ /**
14
+ * Global set by `dash0-session-recording.iife.js`. Read by `armSessionRecording()` and by
15
+ * `startSessionRecording()` when called without a recorder.
16
+ */
17
+ export const GLOBAL_RECORDER_KEY = "dash0Recorder";
18
+
19
+ let recorder: SessionRecorder | undefined;
20
+ let armed = false;
21
+ let stopRecorder: (() => void) | undefined;
22
+ let chunker: Chunker | undefined;
23
+ let lastChanceRegistered = false;
24
+ // True while the last-chance handler flushes. The chunk emitted then must be sent uncompressed: gzip is
25
+ // asynchronous, and a document that is being unloaded may never get to the `fetch()` behind the await.
26
+ let flushingOnLastChance = false;
27
+
28
+ /**
29
+ * Makes a recorder available. Called from the public `startSessionRecording` API, which the
30
+ * `dash0-session-recording.iife.js` script and npm consumers use. Recording starts as soon as both a recorder
31
+ * is registered and `init()` has armed session recording, in either order.
32
+ */
33
+ export function registerSessionRecorder(r: SessionRecorder): void {
34
+ recorder = r;
35
+ if (armed) {
36
+ start();
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Called from `init()` once configuration is in place and the session is sampled.
42
+ *
43
+ * Recorder precedence: `sessionRecording.recorder` from the init options, then a recorder registered through
44
+ * `startSessionRecording(recorder)`, then `window.dash0Recorder`. The last one is set by
45
+ * `dash0-session-recording.iife.js`, and is the only handover that works when that script executes before the
46
+ * initializer snippet has defined the `dash0` command queue.
47
+ */
48
+ export function armSessionRecording(): void {
49
+ armed = true;
50
+ if (vars.sessionRecording.recorder) {
51
+ recorder = vars.sessionRecording.recorder;
52
+ } else if (!recorder) {
53
+ const globalRecorder = (win as any)?.[GLOBAL_RECORDER_KEY];
54
+ if (typeof globalRecorder === "function") {
55
+ recorder = globalRecorder as SessionRecorder;
56
+ }
57
+ }
58
+ if (recorder) {
59
+ start();
60
+ }
61
+ }
62
+
63
+ export function stopSessionRecording(): void {
64
+ if (stopRecorder) {
65
+ try {
66
+ stopRecorder();
67
+ } catch (e) {
68
+ debug("Failed to stop session recorder", e);
69
+ }
70
+ stopRecorder = undefined;
71
+ }
72
+ chunker?.flush();
73
+ chunker = undefined;
74
+ }
75
+
76
+ export function isSessionRecording(): boolean {
77
+ return stopRecorder != null;
78
+ }
79
+
80
+ function start(): void {
81
+ if (stopRecorder) {
82
+ debug("Session recording already running. Ignoring start.");
83
+ return;
84
+ }
85
+ if (!recorder || !win) return;
86
+
87
+ const settings = vars.sessionRecording;
88
+
89
+ if (!vars.isSessionSampled) {
90
+ debug("Session is not sampled. Session recording will not start.");
91
+ return;
92
+ }
93
+ if (!isSessionSampledIn(sessionId ?? "", settings.samplingRate ?? 100)) {
94
+ debug("Session is not sampled for recording. Session recording will not start.");
95
+ return;
96
+ }
97
+ if (isUrlIgnored(win.location.href)) {
98
+ debug("Page URL is ignored. Session recording will not start.");
99
+ return;
100
+ }
101
+
102
+ const traceId = generateTraceId(sessionId);
103
+ const stream: RecordingStream = {
104
+ recordingId: generateUniqueId(TRACE_ID_BYTES),
105
+ traceId,
106
+ spanId: generateSpanId(traceId),
107
+ };
108
+
109
+ const c = newChunker({
110
+ maxBytes: settings.chunkMaxBytes ?? 48000,
111
+ maxMillis: settings.chunkMaxMillis ?? 5000,
112
+ onChunk: (chunk) => {
113
+ try {
114
+ sendSessionRecordingChunk(buildSessionRecordingLog(stream, chunk), { compress: !flushingOnLastChance });
115
+ } catch (e) {
116
+ warn("Failed to transmit session recording chunk", e);
117
+ }
118
+ },
119
+ });
120
+ chunker = c;
121
+
122
+ try {
123
+ stopRecorder = recorder({
124
+ // rrweb can still emit after its stop function ran (trailing throttle timers), and a recorder that failed
125
+ // to start may have emitted already. Only accept events while this chunker is the active one.
126
+ emit: (event: SessionRecordingEvent) => {
127
+ if (chunker === c) c.add(event);
128
+ },
129
+ checkoutEveryNms: settings.checkoutEveryNms,
130
+ maskAllInputs: settings.maskAllInputs,
131
+ maskTextClass: settings.maskTextClass,
132
+ maskTextSelector: settings.maskTextSelector,
133
+ maskInputFn: settings.maskInputFn,
134
+ maskTextFn: settings.maskTextFn,
135
+ blockClass: settings.blockClass,
136
+ blockSelector: settings.blockSelector,
137
+ recordCanvas: settings.recordCanvas,
138
+ collectFonts: settings.collectFonts,
139
+ });
140
+ } catch (e) {
141
+ warn("Failed to start session recorder", e);
142
+ abandonChunker(c);
143
+ return;
144
+ }
145
+
146
+ if (!stopRecorder) {
147
+ // rrweb returns undefined when it refuses to record, e.g. in an unsupported environment.
148
+ warn("Session recorder did not start.");
149
+ abandonChunker(c);
150
+ return;
151
+ }
152
+
153
+ if (!lastChanceRegistered) {
154
+ lastChanceRegistered = true;
155
+ onLastChance(() => {
156
+ flushingOnLastChance = true;
157
+ try {
158
+ chunker?.flush();
159
+ } finally {
160
+ flushingOnLastChance = false;
161
+ }
162
+ });
163
+ }
164
+
165
+ debug("Session recording started", stream);
166
+ }
167
+
168
+ /**
169
+ * The recorder did not start, but it may already have emitted events into `c` and armed its flush timer. Drop
170
+ * them so no chunk of a stream that never started is transmitted later.
171
+ */
172
+ function abandonChunker(c: Chunker): void {
173
+ c.discard();
174
+ chunker = undefined;
175
+ }