@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.
Files changed (91) 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/api/start-view.js +47 -0
  19. package/dist/modules/api/start-view_test.js +91 -0
  20. package/dist/modules/entrypoint/npm-package.js +2 -0
  21. package/dist/modules/entrypoint/npm-package_test.js +7 -0
  22. package/dist/modules/entrypoint/script.js +5 -0
  23. package/dist/modules/entrypoint/session-recording-script.js +19 -0
  24. package/dist/modules/entrypoint/session-recording.js +11 -0
  25. package/dist/modules/instrumentations/navigation/event.js +42 -10
  26. package/dist/modules/instrumentations/navigation/event_test.js +128 -0
  27. package/dist/modules/instrumentations/session-recording/chunker.js +70 -0
  28. package/dist/modules/instrumentations/session-recording/chunker_test.js +125 -0
  29. package/dist/modules/instrumentations/session-recording/index.js +165 -0
  30. package/dist/modules/instrumentations/session-recording/index_test.js +230 -0
  31. package/dist/modules/instrumentations/session-recording/log.js +23 -0
  32. package/dist/modules/instrumentations/session-recording/log_test.js +46 -0
  33. package/dist/modules/semantic-conventions.js +7 -0
  34. package/dist/modules/transport/fetch.js +2 -2
  35. package/dist/modules/transport/fetch_test.js +25 -0
  36. package/dist/modules/transport/index.js +44 -11
  37. package/dist/modules/transport/index_test.js +73 -0
  38. package/dist/modules/types/session-recording.js +1 -0
  39. package/dist/modules/vars.js +11 -0
  40. package/dist/tsconfig.tsbuildinfo +1 -1
  41. package/dist/types/api/session-recording.d.ts +17 -0
  42. package/dist/types/api/session-recording_test.d.ts +1 -0
  43. package/dist/types/api/start-view.d.ts +33 -0
  44. package/dist/types/api/start-view_test.d.ts +1 -0
  45. package/dist/types/entrypoint/npm-package.d.ts +4 -0
  46. package/dist/types/entrypoint/npm-package_test.d.ts +1 -0
  47. package/dist/types/entrypoint/session-recording-script.d.ts +1 -0
  48. package/dist/types/entrypoint/session-recording.d.ts +5 -0
  49. package/dist/types/instrumentations/navigation/event.d.ts +18 -0
  50. package/dist/types/instrumentations/navigation/event_test.d.ts +1 -0
  51. package/dist/types/instrumentations/session-recording/chunker.d.ts +38 -0
  52. package/dist/types/instrumentations/session-recording/chunker_test.d.ts +1 -0
  53. package/dist/types/instrumentations/session-recording/index.d.ts +23 -0
  54. package/dist/types/instrumentations/session-recording/index_test.d.ts +1 -0
  55. package/dist/types/instrumentations/session-recording/log.d.ts +14 -0
  56. package/dist/types/instrumentations/session-recording/log_test.d.ts +1 -0
  57. package/dist/types/semantic-conventions.d.ts +6 -0
  58. package/dist/types/transport/fetch.d.ts +8 -1
  59. package/dist/types/transport/index.d.ts +10 -0
  60. package/dist/types/transport/index_test.d.ts +1 -0
  61. package/dist/types/types/options.d.ts +2 -2
  62. package/dist/types/types/session-recording.d.ts +128 -0
  63. package/dist/types/vars.d.ts +6 -0
  64. package/package.json +11 -3
  65. package/src/api/init.ts +19 -1
  66. package/src/api/init_test.ts +33 -1
  67. package/src/api/session-recording.ts +40 -0
  68. package/src/api/session-recording_test.ts +44 -0
  69. package/src/api/start-view.ts +68 -0
  70. package/src/api/start-view_test.ts +125 -0
  71. package/src/entrypoint/npm-package.ts +4 -0
  72. package/src/entrypoint/npm-package_test.ts +8 -0
  73. package/src/entrypoint/script.ts +5 -0
  74. package/src/entrypoint/session-recording-script.ts +23 -0
  75. package/src/entrypoint/session-recording.ts +13 -0
  76. package/src/instrumentations/navigation/event.ts +63 -15
  77. package/src/instrumentations/navigation/event_test.ts +171 -0
  78. package/src/instrumentations/session-recording/chunker.ts +118 -0
  79. package/src/instrumentations/session-recording/chunker_test.ts +147 -0
  80. package/src/instrumentations/session-recording/index.ts +175 -0
  81. package/src/instrumentations/session-recording/index_test.ts +273 -0
  82. package/src/instrumentations/session-recording/log.ts +48 -0
  83. package/src/instrumentations/session-recording/log_test.ts +60 -0
  84. package/src/semantic-conventions.ts +8 -0
  85. package/src/transport/fetch.ts +10 -2
  86. package/src/transport/fetch_test.ts +30 -0
  87. package/src/transport/index.ts +64 -25
  88. package/src/transport/index_test.ts +90 -0
  89. package/src/types/options.ts +3 -1
  90. package/src/types/session-recording.ts +144 -0
  91. package/src/vars.ts +18 -0
@@ -4,16 +4,32 @@ import { vars } from "../vars";
4
4
  import { debug, error, createRateLimiter } from "../utils";
5
5
  const logBatcher = newBatcher(sendLogs);
6
6
  const spanBatcher = newBatcher(sendSpans);
7
- let rateLimiter;
8
- function isRateLimited() {
9
- if (!rateLimiter) {
10
- rateLimiter = createRateLimiter({
11
- maxCallsPerTenMinutes: 4096,
12
- maxCallsPerTenSeconds: 128,
13
- });
14
- }
15
- return rateLimiter();
7
+ /**
8
+ * Returns an `isRateLimited()` check whose limiter is created on first use, so the module can be imported
9
+ * without touching timers.
10
+ */
11
+ function lazyRateLimiter(opts) {
12
+ let limiter;
13
+ return () => {
14
+ if (!limiter) {
15
+ limiter = createRateLimiter(opts);
16
+ }
17
+ return limiter();
18
+ };
16
19
  }
20
+ const isRateLimited = lazyRateLimiter({
21
+ maxCallsPerTenMinutes: 4096,
22
+ maxCallsPerTenSeconds: 128,
23
+ });
24
+ // Session recording chunks get their own budget. They are large and periodic, and must neither consume the
25
+ // shared budget of spans and logs nor be starved by it. The budget is deliberately generous: every chunk
26
+ // depends on the ones before it, so a dropped chunk makes the replay unrenderable until the next full snapshot
27
+ // (`checkoutEveryNms`). At the default `chunkMaxBytes` of 48 KB, 64 chunks per 10 s allow a ~3 MB mutation
28
+ // burst (a heavy first render), and 512 per 10 min cap a runaway page at ~25 MB of uncompressed replay JSON.
29
+ const isSessionRecordingRateLimited = lazyRateLimiter({
30
+ maxCallsPerTenMinutes: 512,
31
+ maxCallsPerTenSeconds: 64,
32
+ });
17
33
  export function sendLog(log) {
18
34
  if (!vars.isSessionSampled)
19
35
  return;
@@ -23,7 +39,24 @@ export function sendLog(log) {
23
39
  }
24
40
  logBatcher.send(log);
25
41
  }
26
- function sendLogs(logs) {
42
+ /**
43
+ * Transmits a session recording chunk as a single log record. Chunks bypass the log batcher: batching 15 chunks
44
+ * of up to `chunkMaxBytes` each would produce requests far beyond the keepalive body limit.
45
+ *
46
+ * Chunks are gzipped by default because replay JSON compresses roughly 8:1. Pass `compress: false` when the
47
+ * request must be issued synchronously, i.e. while the document is being unloaded: compression is asynchronous
48
+ * and the page may be gone before `fetch()` is ever called.
49
+ */
50
+ export function sendSessionRecordingChunk(log, opts) {
51
+ if (!vars.isSessionSampled)
52
+ return;
53
+ if (isSessionRecordingRateLimited()) {
54
+ debug("Session recording rate limit. Will not send chunk.", log);
55
+ return;
56
+ }
57
+ sendLogs([log], { compress: opts?.compress ?? true });
58
+ }
59
+ function sendLogs(logs, opts) {
27
60
  send("/v1/logs", {
28
61
  resourceLogs: [
29
62
  {
@@ -36,7 +69,7 @@ function sendLogs(logs) {
36
69
  ],
37
70
  },
38
71
  ],
39
- }).catch((err) => {
72
+ }, opts).catch((err) => {
40
73
  error("Failed to transmit logs", err);
41
74
  });
42
75
  }
@@ -0,0 +1,73 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ vi.mock("./fetch", () => ({
3
+ send: vi.fn(() => Promise.resolve()),
4
+ }));
5
+ // The SDK's timer wrapper captures the window timers at import time, before vitest installs fake timers.
6
+ // Resolve the globals lazily so vi.useFakeTimers() drives the rate limiter's reset intervals.
7
+ vi.mock("../utils/timers", () => ({
8
+ setTimeout: (...args) => globalThis.setTimeout(...args),
9
+ clearTimeout: (...args) => globalThis.clearTimeout(...args),
10
+ setInterval: (...args) => globalThis.setInterval(...args),
11
+ clearInterval: (...args) => globalThis.clearInterval(...args),
12
+ }));
13
+ function log(seq) {
14
+ return {
15
+ timeUnixNano: "1",
16
+ severityNumber: 9,
17
+ severityText: "INFO",
18
+ body: { stringValue: "[]" },
19
+ attributes: [{ key: "dash0.session_recording.seq", value: { intValue: String(seq) } }],
20
+ };
21
+ }
22
+ describe("sendSessionRecordingChunk", () => {
23
+ let mod;
24
+ let vars;
25
+ let send;
26
+ beforeEach(async () => {
27
+ vi.resetModules();
28
+ vi.useFakeTimers();
29
+ mod = await import("./index");
30
+ vars = (await import("../vars")).vars;
31
+ send = (await import("./fetch")).send;
32
+ send.mockClear();
33
+ vars.isSessionSampled = true;
34
+ });
35
+ afterEach(() => {
36
+ vi.useRealTimers();
37
+ });
38
+ it("sends each chunk as its own compressed /v1/logs request, bypassing the log batcher", () => {
39
+ mod.sendSessionRecordingChunk(log(0));
40
+ mod.sendSessionRecordingChunk(log(1));
41
+ expect(send).toHaveBeenCalledTimes(2);
42
+ for (const [path, body, opts] of send.mock.calls) {
43
+ expect(path).toBe("/v1/logs");
44
+ expect(body.resourceLogs[0].scopeLogs[0].logRecords).toHaveLength(1);
45
+ expect(opts).toEqual({ compress: true });
46
+ }
47
+ });
48
+ it("sends uncompressed when asked to, so the unload flush reaches fetch synchronously", () => {
49
+ mod.sendSessionRecordingChunk(log(0), { compress: false });
50
+ expect(send).toHaveBeenCalledTimes(1);
51
+ expect(send.mock.calls[0][2]).toEqual({ compress: false });
52
+ });
53
+ it("does not send when the session is not sampled", () => {
54
+ vars.isSessionSampled = false;
55
+ mod.sendSessionRecordingChunk(log(0));
56
+ expect(send).not.toHaveBeenCalled();
57
+ });
58
+ it("drops chunks beyond its own burst budget without touching the shared log budget", () => {
59
+ for (let i = 0; i < 70; i++) {
60
+ mod.sendSessionRecordingChunk(log(i));
61
+ }
62
+ expect(send).toHaveBeenCalledTimes(64);
63
+ // Regular logs are batched, not sent immediately; the point is that they are still accepted.
64
+ mod.sendLog(log(100));
65
+ vi.advanceTimersByTime(10_000);
66
+ const logPaths = send.mock.calls.slice(64).map((c) => c[0]);
67
+ expect(logPaths).toContain("/v1/logs");
68
+ // The ten-second window has reset, so recording chunks flow again.
69
+ send.mockClear();
70
+ mod.sendSessionRecordingChunk(log(71));
71
+ expect(send).toHaveBeenCalledTimes(1);
72
+ });
73
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -23,6 +23,17 @@ export const vars = {
23
23
  trackVirtualPageViews: true,
24
24
  includeParts: [],
25
25
  },
26
+ sessionRecording: {
27
+ samplingRate: 100,
28
+ maskAllInputs: true,
29
+ maskTextClass: "dash0-mask",
30
+ blockClass: "dash0-block",
31
+ recordCanvas: false,
32
+ collectFonts: false,
33
+ chunkMaxBytes: 48000,
34
+ chunkMaxMillis: 5000,
35
+ checkoutEveryNms: 300000,
36
+ },
26
37
  enableTransportCompression: false,
27
38
  isSessionSampled: true,
28
39
  };