@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
@@ -11,6 +11,7 @@ import { startNavigationInstrumentation } from "../instrumentations/navigation";
11
11
  import { initializeTabId } from "../utils/tab-id";
12
12
  import { pickFirstString } from "./browser-env";
13
13
  import { applyVcsResourceAttributes } from "./vcs";
14
+ import { armSessionRecording } from "../instrumentations/session-recording";
14
15
  let hasBeenInitialised = false;
15
16
  export function init(opts) {
16
17
  if (hasBeenInitialised) {
@@ -50,6 +51,7 @@ export function init(opts) {
50
51
  "headersToCapture",
51
52
  "urlAttributeScrubber",
52
53
  "pageViewInstrumentation",
54
+ "sessionRecording",
53
55
  "enableTransportCompression",
54
56
  ])));
55
57
  initializePropagators(opts);
@@ -81,6 +83,9 @@ export function init(opts) {
81
83
  if (isInstrumentationEnabled("@dash0/xhr", opts)) {
82
84
  instrumentXhr();
83
85
  }
86
+ if (isInstrumentationEnabled("@dash0/session-recording", opts)) {
87
+ armSessionRecording();
88
+ }
84
89
  hasBeenInitialised = true;
85
90
  }
86
91
  function initializeResourceAttributes(opts) {
@@ -206,7 +211,10 @@ function merge(target, source) {
206
211
  typeof dstVal === "object" &&
207
212
  dstVal !== null &&
208
213
  !Array.isArray(dstVal)) {
209
- result[key] = { ...dstVal, ...srcVal };
214
+ // Like the top-level rule above, an explicit `undefined` inside a nested object means "not provided" and
215
+ // must not erase the default. Otherwise `sessionRecording: { maskAllInputs: someUnsetFlag }` would
216
+ // silently turn input masking off.
217
+ result[key] = { ...dstVal, ...withoutUndefined(srcVal) };
210
218
  }
211
219
  else {
212
220
  result[key] = srcVal;
@@ -215,3 +223,12 @@ function merge(target, source) {
215
223
  }
216
224
  return result;
217
225
  }
226
+ function withoutUndefined(obj) {
227
+ const result = {};
228
+ for (const key of Object.keys(obj)) {
229
+ if (obj[key] !== undefined) {
230
+ result[key] = obj[key];
231
+ }
232
+ }
233
+ return result;
234
+ }
@@ -16,6 +16,9 @@ vi.mock("../instrumentations/http/xhr", () => ({
16
16
  vi.mock("../instrumentations/navigation", () => ({
17
17
  startNavigationInstrumentation: vi.fn(),
18
18
  }));
19
+ vi.mock("../instrumentations/session-recording", () => ({
20
+ armSessionRecording: vi.fn(),
21
+ }));
19
22
  // Mock the utils module to control loc.hostname
20
23
  vi.mock("../utils", async () => {
21
24
  const actual = await vi.importActual("../utils");
@@ -29,6 +32,7 @@ import { instrumentFetch } from "../instrumentations/http/fetch";
29
32
  import { instrumentXhr } from "../instrumentations/http/xhr";
30
33
  import { startNavigationInstrumentation } from "../instrumentations/navigation";
31
34
  import { startWebVitalsInstrumentation } from "../instrumentations/web-vitals";
35
+ import { armSessionRecording } from "../instrumentations/session-recording";
32
36
  describe("init", () => {
33
37
  const baseOptions = {
34
38
  serviceName: "test-service",
@@ -51,6 +55,27 @@ describe("init", () => {
51
55
  afterEach(() => {
52
56
  vi.clearAllMocks();
53
57
  });
58
+ describe("nested option merging", () => {
59
+ it("keeps nested defaults when an override is explicitly undefined", () => {
60
+ init({
61
+ ...baseOptions,
62
+ sessionRecording: { maskAllInputs: undefined, chunkMaxMillis: 1000 },
63
+ });
64
+ expect(vars.sessionRecording.maskAllInputs).toBe(true);
65
+ expect(vars.sessionRecording.blockClass).toBe("dash0-block");
66
+ expect(vars.sessionRecording.chunkMaxMillis).toBe(1000);
67
+ });
68
+ it("still lets an explicit false override a nested default", () => {
69
+ init({
70
+ ...baseOptions,
71
+ sessionRecording: { maskAllInputs: false },
72
+ pageViewInstrumentation: { trackVirtualPageViews: false },
73
+ });
74
+ expect(vars.sessionRecording.maskAllInputs).toBe(false);
75
+ expect(vars.pageViewInstrumentation.trackVirtualPageViews).toBe(false);
76
+ expect(vars.pageViewInstrumentation.includeParts).toEqual([]);
77
+ });
78
+ });
54
79
  describe("instrumentation enablement", () => {
55
80
  it("should enable all instrumentations when enabledInstrumentations is undefined", async () => {
56
81
  init({
@@ -69,6 +94,7 @@ describe("init", () => {
69
94
  "@dash0/error",
70
95
  "@dash0/fetch",
71
96
  "@dash0/xhr",
97
+ "@dash0/session-recording",
72
98
  ];
73
99
  const instrumentationMocks = {
74
100
  "@dash0/navigation": startNavigationInstrumentation,
@@ -76,6 +102,7 @@ describe("init", () => {
76
102
  "@dash0/error": startErrorInstrumentation,
77
103
  "@dash0/fetch": instrumentFetch,
78
104
  "@dash0/xhr": instrumentXhr,
105
+ "@dash0/session-recording": armSessionRecording,
79
106
  };
80
107
  instrumentations.forEach((instrumentation) => {
81
108
  it(`should enable ${instrumentation} instrumentation when present in enabledInstrumentations array`, async () => {
@@ -0,0 +1,30 @@
1
+ import { debug, win } from "../utils";
2
+ import { GLOBAL_RECORDER_KEY, registerSessionRecorder, stopSessionRecording as stopRecording, } from "../instrumentations/session-recording";
3
+ /**
4
+ * Starts session recording with the given recorder. Pass `recorder` from `@dash0/sdk-web/session-recording`.
5
+ * When `recorder` is omitted, the SDK looks for `window.dash0Recorder`, which the
6
+ * `dash0-session-recording.iife.js` script sets. Calling this is not required when that script is used: `init()`
7
+ * picks up `window.dash0Recorder` on its own, regardless of the order in which the scripts execute.
8
+ *
9
+ * Recording only starts once `init()` has run with a sampled session. It is safe to call this before `init()`;
10
+ * the recorder is kept and recording starts as soon as the SDK is initialized. The recording is transmitted
11
+ * as `browser.session_recording` log records that share one trace ID, which embeds the session ID.
12
+ */
13
+ export function startSessionRecording(recorder) {
14
+ // The script entrypoint forwards dash0("startSessionRecording", ...) arguments without type checking,
15
+ // so malformed calls must degrade to a logged no-op instead of throwing. An uncaught throw here would
16
+ // abort the command-queue drain and drop all subsequently queued api calls.
17
+ const r = recorder ?? win?.[GLOBAL_RECORDER_KEY];
18
+ if (typeof r !== "function") {
19
+ debug("startSessionRecording requires a recorder. Import `recorder` from `@dash0/sdk-web/session-recording` or load dash0-session-recording.iife.js. Ignoring call.");
20
+ return;
21
+ }
22
+ registerSessionRecorder(r);
23
+ }
24
+ /**
25
+ * Stops the running session recording and transmits any buffered events. Calling this when no recording is
26
+ * running is a no-op.
27
+ */
28
+ export function stopSessionRecording() {
29
+ stopRecording();
30
+ }
@@ -0,0 +1,36 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ vi.mock("../instrumentations/session-recording", () => ({
3
+ GLOBAL_RECORDER_KEY: "dash0Recorder",
4
+ registerSessionRecorder: vi.fn(),
5
+ stopSessionRecording: vi.fn(),
6
+ }));
7
+ import { registerSessionRecorder, stopSessionRecording as stopImpl } from "../instrumentations/session-recording";
8
+ import { startSessionRecording, stopSessionRecording } from "./session-recording";
9
+ import { win } from "../utils";
10
+ const globalObject = win;
11
+ describe("startSessionRecording api", () => {
12
+ beforeEach(() => {
13
+ vi.clearAllMocks();
14
+ delete globalObject.dash0Recorder;
15
+ });
16
+ it("registers an explicitly passed recorder", () => {
17
+ const recorder = vi.fn();
18
+ startSessionRecording(recorder);
19
+ expect(registerSessionRecorder).toHaveBeenCalledWith(recorder);
20
+ });
21
+ it("falls back to window.dash0Recorder", () => {
22
+ const recorder = vi.fn();
23
+ globalObject.dash0Recorder = recorder;
24
+ startSessionRecording();
25
+ expect(registerSessionRecorder).toHaveBeenCalledWith(recorder);
26
+ });
27
+ it("ignores calls without a usable recorder instead of throwing", () => {
28
+ expect(() => startSessionRecording()).not.toThrow();
29
+ expect(() => startSessionRecording("nope")).not.toThrow();
30
+ expect(registerSessionRecorder).not.toHaveBeenCalled();
31
+ });
32
+ it("delegates stop", () => {
33
+ stopSessionRecording();
34
+ expect(stopImpl).toHaveBeenCalledTimes(1);
35
+ });
36
+ });
@@ -0,0 +1,47 @@
1
+ import { transmitManualPageViewEvent } from "../instrumentations/navigation/event";
2
+ import { debug, nowNanos, win } from "../utils";
3
+ import { vars } from "../vars";
4
+ /**
5
+ * Manually records a page view, side-effect free: this never calls `history.pushState` /
6
+ * `history.replaceState` and never mutates `location`. Intended for single-page applications
7
+ * that own their own router and cannot let the SDK touch navigation state (e.g. Electron apps
8
+ * serving the whole app from one root URL, where automatic page-view tracking would report
9
+ * every screen as "/").
10
+ *
11
+ * The emitted event is indistinguishable from an automatic virtual page view downstream
12
+ * (same `browser.page_view` event name, same `type` value), with two differences: it is never
13
+ * accompanied by a `change_state` value, since no history mutation occurred, and the
14
+ * `pageViewInstrumentation`'s `generateMetadata` callback is not invoked for manual views —
15
+ * supply title and attributes directly instead.
16
+ *
17
+ * @param name The name of the view, e.g. "/settings". Transmitted as the page view's title.
18
+ * @param opts Additional page view details.
19
+ */
20
+ export function startView(name, opts) {
21
+ if (vars.endpoints.length === 0) {
22
+ debug("Dash0 SDK has not been initialized. Ignoring startView call.");
23
+ return;
24
+ }
25
+ // The script entrypoint forwards dash0("startView", ...) arguments without type checking,
26
+ // so malformed calls must degrade to a logged no-op instead of throwing. An uncaught throw
27
+ // here would abort the command-queue drain and drop all subsequently queued api calls.
28
+ if (typeof name !== "string" || name.length === 0) {
29
+ debug("startView requires a non-empty view name. Ignoring startView call.");
30
+ return;
31
+ }
32
+ let url;
33
+ if (opts?.url != null) {
34
+ try {
35
+ url = new URL(opts.url, win?.location.href);
36
+ }
37
+ catch (e) {
38
+ debug("Failed to parse startView url option. Falling back to the current location.", e);
39
+ }
40
+ }
41
+ transmitManualPageViewEvent({
42
+ timeUnixNano: nowNanos(),
43
+ title: name,
44
+ url,
45
+ attributes: opts?.attributes,
46
+ });
47
+ }
@@ -0,0 +1,91 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ vi.mock("../transport", () => ({
3
+ sendLog: vi.fn(),
4
+ }));
5
+ import { sendLog } from "../transport";
6
+ import { startView } from "./start-view";
7
+ import { vars } from "../vars";
8
+ const sendLogMock = sendLog;
9
+ describe("startView", () => {
10
+ beforeEach(() => {
11
+ sendLogMock.mockClear();
12
+ vars.endpoints = [{ url: "https://example.com", authToken: "auth_abc123" }];
13
+ vars.pageViewInstrumentation = { trackVirtualPageViews: true, includeParts: [] };
14
+ });
15
+ afterEach(() => {
16
+ vi.clearAllMocks();
17
+ vars.endpoints = [];
18
+ });
19
+ it("accepts a string shorthand and uses it as the title", () => {
20
+ startView("/settings");
21
+ expect(sendLogMock).toHaveBeenCalledTimes(1);
22
+ const log = sendLogMock.mock.calls[0][0];
23
+ const bodyValues = log.body?.kvlistValue?.values;
24
+ expect(bodyValues).toEqual(expect.arrayContaining([{ key: "title", value: { stringValue: "/settings" } }]));
25
+ });
26
+ it("accepts an options object with attributes", () => {
27
+ startView("/settings", { attributes: { "app.screen": "settings" } });
28
+ const log = sendLogMock.mock.calls[0][0];
29
+ const bodyValues = log.body?.kvlistValue?.values;
30
+ expect(bodyValues).toEqual(expect.arrayContaining([{ key: "title", value: { stringValue: "/settings" } }]));
31
+ expect(log.attributes).toEqual(expect.arrayContaining([{ key: "app.screen", value: { stringValue: "settings" } }]));
32
+ });
33
+ it("parses a relative url option and reflects it in page.url.path", () => {
34
+ startView("/settings", { url: "/settings" });
35
+ const log = sendLogMock.mock.calls[0][0];
36
+ expect(log.attributes).toEqual(expect.arrayContaining([{ key: "page.url.path", value: { stringValue: "/settings" } }]));
37
+ });
38
+ it("falls back to the current location on an invalid url", () => {
39
+ startView("/settings", { url: "http://" });
40
+ expect(sendLogMock).toHaveBeenCalledTimes(1);
41
+ const log = sendLogMock.mock.calls[0][0];
42
+ expect(log.attributes).toEqual(expect.arrayContaining([
43
+ // eslint-disable-next-line no-restricted-globals
44
+ { key: "page.url.full", value: { stringValue: window.location.href } },
45
+ // eslint-disable-next-line no-restricted-globals
46
+ { key: "page.url.domain", value: { stringValue: window.location.hostname } },
47
+ ]));
48
+ });
49
+ it("resolves an absolute cross-origin url override", () => {
50
+ startView("/settings", { url: "https://other-origin.example/path" });
51
+ const log = sendLogMock.mock.calls[0][0];
52
+ expect(log.attributes).toEqual(expect.arrayContaining([
53
+ { key: "page.url.full", value: { stringValue: "https://other-origin.example/path" } },
54
+ { key: "page.url.domain", value: { stringValue: "other-origin.example" } },
55
+ { key: "page.url.path", value: { stringValue: "/path" } },
56
+ ]));
57
+ });
58
+ it("does not touch history or location", () => {
59
+ // eslint-disable-next-line no-restricted-globals
60
+ const originalHref = window.location.href;
61
+ // eslint-disable-next-line no-restricted-globals
62
+ const originalLength = window.history.length;
63
+ startView("/settings");
64
+ // eslint-disable-next-line no-restricted-globals
65
+ expect(window.location.href).toBe(originalHref);
66
+ // eslint-disable-next-line no-restricted-globals
67
+ expect(window.history.length).toBe(originalLength);
68
+ });
69
+ it("ignores calls without a name, as they can arrive via the untyped script entrypoint", () => {
70
+ // @ts-expect-error deliberately calling without arguments, mirroring dash0("startView")
71
+ startView();
72
+ startView(undefined);
73
+ startView(null);
74
+ expect(sendLogMock).not.toHaveBeenCalled();
75
+ });
76
+ it("ignores calls with a non-string or empty name", () => {
77
+ startView(123);
78
+ startView({ name: "/settings" });
79
+ startView("");
80
+ expect(sendLogMock).not.toHaveBeenCalled();
81
+ });
82
+ it("tolerates a nullish options argument", () => {
83
+ startView("/settings", null);
84
+ expect(sendLogMock).toHaveBeenCalledTimes(1);
85
+ });
86
+ it("is a no-op before init (no endpoints configured)", () => {
87
+ vars.endpoints = [];
88
+ startView("/settings");
89
+ expect(sendLogMock).not.toHaveBeenCalled();
90
+ });
91
+ });
@@ -7,6 +7,8 @@ export * from "../api/events";
7
7
  export * from "../api/log-level";
8
8
  export { terminateSession } from "../api/session";
9
9
  export { reportError } from "../api/report-error";
10
+ export { startView } from "../api/start-view";
11
+ export { startSessionRecording, stopSessionRecording } from "../api/session-recording";
10
12
  export function init(opts) {
11
13
  debug(`${INIT_MESSAGE} (via package)`);
12
14
  initApi(opts);
@@ -0,0 +1,7 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import * as npmPackage from "./npm-package";
3
+ describe("npm-package entrypoint", () => {
4
+ it("exports startView", () => {
5
+ expect(typeof npmPackage.startView).toBe("function");
6
+ });
7
+ });
@@ -7,6 +7,8 @@ import { terminateSession } from "../api/session";
7
7
  import { addSignalAttribute, removeSignalAttribute } from "../api/attributes";
8
8
  import { sendEvent } from "../api/events";
9
9
  import { setActiveLogLevel } from "../api/log-level";
10
+ import { startView } from "../api/start-view";
11
+ import { startSessionRecording, stopSessionRecording } from "../api/session-recording";
10
12
  /**
11
13
  * All the APIs exposed through the script tag via `dash0('{{api name}}')`
12
14
  */
@@ -20,6 +22,9 @@ const scriptApis = {
20
22
  removeSignalAttribute,
21
23
  setActiveLogLevel,
22
24
  sendEvent,
25
+ startView,
26
+ startSessionRecording,
27
+ stopSessionRecording,
23
28
  };
24
29
  init();
25
30
  function init() {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Script-tag entrypoint, built to `dist/dash0-session-recording.iife.js`.
3
+ *
4
+ * This bundle contains only rrweb's recorder. It must not import any SDK module (see eslint.config.js), so the
5
+ * main SDK bundle stays the single owner of configuration, session and transport state. It hands the recorder
6
+ * to the SDK in two ways, so it can be loaded in any order relative to the initializer snippet and `dash0.iife.js`:
7
+ *
8
+ * - `window.dash0Recorder`: picked up by the SDK when `init()` runs. Covers the case where this bundle executes
9
+ * before the initializer snippet has defined the `dash0` command queue.
10
+ * - `dash0("startSessionRecording", record)`: covers the case where the SDK is already initialized, or the
11
+ * command queue exists and will be drained on `init()`.
12
+ */
13
+ /* eslint-disable no-restricted-globals */
14
+ import { record } from "@rrweb/record";
15
+ window.dash0Recorder = record;
16
+ const dash0 = window.dash0;
17
+ if (typeof dash0 === "function") {
18
+ dash0("startSessionRecording", record);
19
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * npm entrypoint of `@dash0/sdk-web/session-recording`.
3
+ *
4
+ * This bundle contains only rrweb's recorder. It must not import any SDK module (see eslint.config.js), so the
5
+ * main SDK bundle stays the single owner of configuration, session and transport state.
6
+ */
7
+ import { record } from "@rrweb/record";
8
+ /**
9
+ * The rrweb recorder. Pass it to `init({ sessionRecording: { recorder } })` or to `startSessionRecording(recorder)`.
10
+ */
11
+ export const recorder = record;
@@ -1,4 +1,4 @@
1
- import { addAttribute, getTraceContextForPageLoad } from "../../utils/otel";
1
+ import { addAttribute, addAttributes, getTraceContextForPageLoad } from "../../utils/otel";
2
2
  import { EVENT_NAME, EVENT_NAMES, LOG_SEVERITIES, PAGE_VIEW_CHANGE_STATE, PAGE_VIEW_CHANGE_STATE_VALUES, PAGE_VIEW_TYPE, PAGE_VIEW_TYPE_VALUES, } from "../../semantic-conventions";
3
3
  import { doc, NO_VALUE_FALLBACK } from "../../utils";
4
4
  import { addCommonAttributes } from "../../attributes";
@@ -9,23 +9,26 @@ function getPageViewMeta(url) {
9
9
  return {};
10
10
  return vars.pageViewInstrumentation.generateMetadata?.(url) ?? {};
11
11
  }
12
- export function transmitPageViewEvent(timeUnixNano, url, virtual, replaced) {
13
- const meta = getPageViewMeta(url);
12
+ function buildAndSendPageViewLog(opts) {
14
13
  const attributes = [];
15
14
  addAttribute(attributes, EVENT_NAME, EVENT_NAMES.PAGE_VIEW);
16
- if (meta.attributes) {
17
- Object.entries(meta.attributes).forEach(([key, value]) => addAttribute(attributes, key, value));
15
+ if (opts.metaAttributes) {
16
+ Object.entries(opts.metaAttributes).forEach(([key, value]) => addAttribute(attributes, key, value));
18
17
  }
19
- addCommonAttributes(attributes, { url });
18
+ addCommonAttributes(attributes, { url: opts.url });
19
+ // Add custom attributes last to allow overrides
20
+ addAttributes(attributes, opts.customAttributes);
20
21
  const bodyAttributes = [];
21
- addAttribute(bodyAttributes, "title", meta.title ?? doc?.title ?? NO_VALUE_FALLBACK);
22
+ addAttribute(bodyAttributes, "title", opts.title ?? doc?.title ?? NO_VALUE_FALLBACK);
22
23
  if (doc?.referrer) {
23
24
  addAttribute(bodyAttributes, "referrer", doc.referrer);
24
25
  }
25
- addAttribute(bodyAttributes, PAGE_VIEW_TYPE, virtual ? PAGE_VIEW_TYPE_VALUES.VIRTUAL : PAGE_VIEW_TYPE_VALUES.INITIAL);
26
- addAttribute(bodyAttributes, PAGE_VIEW_CHANGE_STATE, replaced ? PAGE_VIEW_CHANGE_STATE_VALUES.REPLACE : PAGE_VIEW_CHANGE_STATE_VALUES.PUSH);
26
+ addAttribute(bodyAttributes, PAGE_VIEW_TYPE, opts.pageViewType);
27
+ if (opts.changeState) {
28
+ addAttribute(bodyAttributes, PAGE_VIEW_CHANGE_STATE, opts.changeState);
29
+ }
27
30
  const log = {
28
- timeUnixNano: timeUnixNano,
31
+ timeUnixNano: opts.timeUnixNano,
29
32
  attributes: attributes,
30
33
  severityNumber: LOG_SEVERITIES.INFO,
31
34
  severityText: "INFO",
@@ -42,3 +45,32 @@ export function transmitPageViewEvent(timeUnixNano, url, virtual, replaced) {
42
45
  }
43
46
  sendLog(log);
44
47
  }
48
+ export function transmitPageViewEvent(timeUnixNano, url, virtual, replaced) {
49
+ const meta = getPageViewMeta(url);
50
+ buildAndSendPageViewLog({
51
+ timeUnixNano,
52
+ url,
53
+ title: meta.title,
54
+ metaAttributes: meta.attributes,
55
+ pageViewType: virtual ? PAGE_VIEW_TYPE_VALUES.VIRTUAL : PAGE_VIEW_TYPE_VALUES.INITIAL,
56
+ changeState: replaced ? PAGE_VIEW_CHANGE_STATE_VALUES.REPLACE : PAGE_VIEW_CHANGE_STATE_VALUES.PUSH,
57
+ });
58
+ }
59
+ /**
60
+ * Emits a manually-triggered page view log, e.g. from the public `startView` API.
61
+ * Deliberately does NOT read `vars.pageViewInstrumentation.generateMetadata` and does NOT
62
+ * include a `change_state` body key (a manual view is neither a pushState nor replaceState).
63
+ * The emitted `type` is PAGE_VIEW_TYPE_VALUES.VIRTUAL, matching automatic virtual page views —
64
+ * manual views are deliberately indistinguishable from virtual ones downstream.
65
+ * Caller-provided attributes are added after the SDK-generated ones so they can override them,
66
+ * matching the sendEvent semantics.
67
+ */
68
+ export function transmitManualPageViewEvent(opts) {
69
+ buildAndSendPageViewLog({
70
+ timeUnixNano: opts.timeUnixNano,
71
+ url: opts.url,
72
+ title: opts.title,
73
+ customAttributes: opts.attributes,
74
+ pageViewType: PAGE_VIEW_TYPE_VALUES.VIRTUAL,
75
+ });
76
+ }
@@ -0,0 +1,128 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ vi.mock("../../transport", () => ({
3
+ sendLog: vi.fn(),
4
+ }));
5
+ import { sendLog } from "../../transport";
6
+ import { transmitPageViewEvent, transmitManualPageViewEvent } from "./event";
7
+ import { vars } from "../../vars";
8
+ const sendLogMock = sendLog;
9
+ describe("transmitPageViewEvent", () => {
10
+ beforeEach(() => {
11
+ sendLogMock.mockClear();
12
+ vars.pageViewInstrumentation = { trackVirtualPageViews: true, includeParts: [] };
13
+ });
14
+ afterEach(() => {
15
+ vi.clearAllMocks();
16
+ });
17
+ it("transmits an initial page view with type=INITIAL and change_state=pushState", () => {
18
+ transmitPageViewEvent("1700000000000000000", new URL("https://example.com/landing"));
19
+ expect(sendLogMock).toHaveBeenCalledTimes(1);
20
+ const log = sendLogMock.mock.calls[0][0];
21
+ expect(log.timeUnixNano).toBe("1700000000000000000");
22
+ expect(log.severityNumber).toBe(9);
23
+ expect(log.severityText).toBe("INFO");
24
+ expect(log.attributes).toEqual(expect.arrayContaining([{ key: "event.name", value: { stringValue: "browser.page_view" } }]));
25
+ const bodyValues = log.body?.kvlistValue?.values;
26
+ expect(bodyValues).toEqual(expect.arrayContaining([
27
+ { key: "type", value: { doubleValue: 0 } },
28
+ { key: "change_state", value: { stringValue: "pushState" } },
29
+ ]));
30
+ });
31
+ it("transmits a virtual page view with type=VIRTUAL and change_state=replaceState when replaced", () => {
32
+ transmitPageViewEvent("1700000000000000000", new URL("https://example.com/settings"), true, true);
33
+ const log = sendLogMock.mock.calls[0][0];
34
+ const bodyValues = log.body?.kvlistValue?.values;
35
+ expect(bodyValues).toEqual(expect.arrayContaining([
36
+ { key: "type", value: { doubleValue: 1 } },
37
+ { key: "change_state", value: { stringValue: "replaceState" } },
38
+ ]));
39
+ });
40
+ it("applies generateMetadata title and attributes when provided", () => {
41
+ vars.pageViewInstrumentation = {
42
+ trackVirtualPageViews: true,
43
+ includeParts: [],
44
+ generateMetadata: () => ({ title: "Custom Title", attributes: { "app.screen": "settings" } }),
45
+ };
46
+ transmitPageViewEvent("1700000000000000000", new URL("https://example.com/settings"), true);
47
+ const log = sendLogMock.mock.calls[0][0];
48
+ expect(log.attributes).toEqual(expect.arrayContaining([{ key: "app.screen", value: { stringValue: "settings" } }]));
49
+ const bodyValues = log.body?.kvlistValue?.values;
50
+ expect(bodyValues).toEqual(expect.arrayContaining([{ key: "title", value: { stringValue: "Custom Title" } }]));
51
+ });
52
+ });
53
+ describe("transmitManualPageViewEvent", () => {
54
+ beforeEach(() => {
55
+ sendLogMock.mockClear();
56
+ vars.pageViewInstrumentation = {
57
+ trackVirtualPageViews: true,
58
+ includeParts: [],
59
+ generateMetadata: () => ({ title: "should not be used", attributes: { should_not_appear: true } }),
60
+ };
61
+ });
62
+ afterEach(() => {
63
+ vi.clearAllMocks();
64
+ vars.pageViewInstrumentation = { trackVirtualPageViews: true, includeParts: [] };
65
+ });
66
+ it("emits type=VIRTUAL and no change_state key", () => {
67
+ transmitManualPageViewEvent({ timeUnixNano: "1700000000000000000", title: "/settings" });
68
+ const log = sendLogMock.mock.calls[0][0];
69
+ const bodyValues = log.body?.kvlistValue?.values;
70
+ expect(bodyValues).toEqual(expect.arrayContaining([
71
+ { key: "type", value: { doubleValue: 1 } },
72
+ { key: "title", value: { stringValue: "/settings" } },
73
+ ]));
74
+ expect(bodyValues.find((v) => v.key === "change_state")).toBeUndefined();
75
+ });
76
+ it("does not invoke vars.pageViewInstrumentation.generateMetadata", () => {
77
+ const generateMetadata = vi.fn().mockReturnValue({ title: "ignored" });
78
+ vars.pageViewInstrumentation = { trackVirtualPageViews: true, includeParts: [], generateMetadata };
79
+ transmitManualPageViewEvent({ timeUnixNano: "1700000000000000000", title: "/settings" });
80
+ expect(generateMetadata).not.toHaveBeenCalled();
81
+ const log = sendLogMock.mock.calls[0][0];
82
+ const bodyValues = log.body?.kvlistValue?.values;
83
+ expect(bodyValues).toEqual(expect.arrayContaining([{ key: "title", value: { stringValue: "/settings" } }]));
84
+ });
85
+ it("merges custom attributes into signal attributes", () => {
86
+ transmitManualPageViewEvent({
87
+ timeUnixNano: "1700000000000000000",
88
+ title: "/settings",
89
+ attributes: { "app.screen": "settings", "app.tab_index": 2 },
90
+ });
91
+ const log = sendLogMock.mock.calls[0][0];
92
+ expect(log.attributes).toEqual(expect.arrayContaining([
93
+ { key: "app.screen", value: { stringValue: "settings" } },
94
+ { key: "app.tab_index", value: { doubleValue: 2 } },
95
+ ]));
96
+ });
97
+ it("adds custom attributes after SDK-generated ones so they can override", () => {
98
+ transmitManualPageViewEvent({
99
+ timeUnixNano: "1700000000000000000",
100
+ title: "/settings",
101
+ url: new URL("https://example.com/real-path"),
102
+ attributes: { "page.url.path": "/custom-path" },
103
+ });
104
+ const log = sendLogMock.mock.calls[0][0];
105
+ const pathAttributes = log.attributes.filter((attr) => attr.key === "page.url.path");
106
+ expect(pathAttributes.length).toBeGreaterThan(1);
107
+ expect(pathAttributes[pathAttributes.length - 1]).toEqual({
108
+ key: "page.url.path",
109
+ value: { stringValue: "/custom-path" },
110
+ });
111
+ });
112
+ it("passes url through to page.url.* attributes when provided", () => {
113
+ transmitManualPageViewEvent({
114
+ timeUnixNano: "1700000000000000000",
115
+ title: "/settings",
116
+ url: new URL("https://example.com/settings"),
117
+ });
118
+ const log = sendLogMock.mock.calls[0][0];
119
+ expect(log.attributes).toEqual(expect.arrayContaining([{ key: "page.url.path", value: { stringValue: "/settings" } }]));
120
+ });
121
+ it("includes referrer the same way the auto path does", () => {
122
+ transmitManualPageViewEvent({ timeUnixNano: "1700000000000000000", title: "/settings" });
123
+ const log = sendLogMock.mock.calls[0][0];
124
+ const bodyValues = log.body?.kvlistValue?.values;
125
+ // doc.referrer is empty string in jsdom by default, so "referrer" key should be absent
126
+ expect(bodyValues.find((v) => v.key === "referrer")).toBeUndefined();
127
+ });
128
+ });
@@ -0,0 +1,70 @@
1
+ import { setTimeout, clearTimeout } from "../../utils/timers";
2
+ const RRWEB_EVENT_TYPE_FULL_SNAPSHOT = 2;
3
+ const RRWEB_EVENT_TYPE_META = 4;
4
+ /**
5
+ * Buffers rrweb events and hands them out as chunks. A chunk closes when its serialized size reaches
6
+ * `maxBytes`, when `maxMillis` have passed since its first event, when a new full snapshot begins, or when
7
+ * `flush()` is called. Events are serialized once, on arrival, so a flush is a join and not a second stringify.
8
+ */
9
+ export function newChunker(opts) {
10
+ let serialized = [];
11
+ let byteSize = 0;
12
+ let hasSnapshot = false;
13
+ let startTime = 0;
14
+ let endTime = 0;
15
+ let seq = 0;
16
+ let pendingFlushTimeout = null;
17
+ return { add, flush, discard };
18
+ function add(event) {
19
+ // A Meta event announces a new full snapshot. Close the current chunk first so the snapshot starts a fresh
20
+ // one and a replay can begin at that chunk.
21
+ if (event.type === RRWEB_EVENT_TYPE_META && serialized.length > 0) {
22
+ flush();
23
+ }
24
+ const json = JSON.stringify(event);
25
+ if (json == null)
26
+ return;
27
+ if (serialized.length === 0) {
28
+ startTime = event.timestamp;
29
+ pendingFlushTimeout = setTimeout(flush, opts.maxMillis);
30
+ }
31
+ serialized.push(json);
32
+ byteSize += json.length;
33
+ endTime = event.timestamp;
34
+ if (event.type === RRWEB_EVENT_TYPE_FULL_SNAPSHOT) {
35
+ hasSnapshot = true;
36
+ }
37
+ if (byteSize >= opts.maxBytes) {
38
+ flush();
39
+ }
40
+ }
41
+ function discard() {
42
+ clearPendingFlush();
43
+ serialized = [];
44
+ byteSize = 0;
45
+ hasSnapshot = false;
46
+ }
47
+ function clearPendingFlush() {
48
+ if (pendingFlushTimeout != null) {
49
+ clearTimeout(pendingFlushTimeout);
50
+ pendingFlushTimeout = null;
51
+ }
52
+ }
53
+ function flush() {
54
+ clearPendingFlush();
55
+ if (serialized.length === 0)
56
+ return;
57
+ const chunk = {
58
+ seq: seq++,
59
+ body: "[" + serialized.join(",") + "]",
60
+ eventCount: serialized.length,
61
+ hasSnapshot,
62
+ startTime,
63
+ endTime,
64
+ };
65
+ serialized = [];
66
+ byteSize = 0;
67
+ hasSnapshot = false;
68
+ opts.onChunk(chunk);
69
+ }
70
+ }