@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.
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 +38 -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 +235 -0
  25. package/dist/modules/instrumentations/session-recording/index_test.js +293 -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 +25 -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 +48 -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 +248 -0
  66. package/src/instrumentations/session-recording/index_test.ts +358 -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,23 @@
1
+ import { SessionRecorder } from "../../types/session-recording";
2
+ /**
3
+ * Global set by `dash0-session-recording.iife.js`. Read by `armSessionRecording()` and by
4
+ * `startSessionRecording()` when called without a recorder.
5
+ */
6
+ export declare const GLOBAL_RECORDER_KEY = "dash0Recorder";
7
+ /**
8
+ * Makes a recorder available. Called from the public `startSessionRecording` API, which the
9
+ * `dash0-session-recording.iife.js` script and npm consumers use. Recording starts as soon as both a recorder
10
+ * is registered and `init()` has armed session recording, in either order.
11
+ */
12
+ export declare function registerSessionRecorder(r: SessionRecorder): void;
13
+ /**
14
+ * Called from `init()` once configuration is in place and the session is sampled.
15
+ *
16
+ * Recorder precedence: `sessionRecording.recorder` from the init options, then a recorder registered through
17
+ * `startSessionRecording(recorder)`, then `window.dash0Recorder`. The last one is set by
18
+ * `dash0-session-recording.iife.js`, and is the only handover that works when that script executes before the
19
+ * initializer snippet has defined the `dash0` command queue.
20
+ */
21
+ export declare function armSessionRecording(): void;
22
+ export declare function stopSessionRecording(): void;
23
+ export declare function isSessionRecording(): boolean;
@@ -0,0 +1,14 @@
1
+ import { LogRecord } from "../../types/otlp";
2
+ import { Chunk } from "./chunker";
3
+ export type RecordingStream = {
4
+ /**
5
+ * Identifies one recorder run. All chunks of the run share it.
6
+ */
7
+ recordingId: string;
8
+ /**
9
+ * Trace context shared by all chunks of the run. The trace ID embeds the session ID.
10
+ */
11
+ traceId: string;
12
+ spanId: string;
13
+ };
14
+ export declare function buildSessionRecordingLog(stream: RecordingStream, chunk: Chunk): LogRecord;
@@ -23,6 +23,11 @@ export declare const WINDOW_WIDTH = "browser.window.width";
23
23
  export declare const WINDOW_HEIGHT = "browser.window.height";
24
24
  export declare const NETWORK_CONNECTION_TYPE = "network.connection.subtype";
25
25
  export declare const EXCEPTION_COMPONENT_STACK = "exception.component_stack";
26
+ export declare const SESSION_RECORDING_ID = "dash0.session_recording.id";
27
+ export declare const SESSION_RECORDING_SEQ = "dash0.session_recording.seq";
28
+ export declare const SESSION_RECORDING_EVENT_COUNT = "dash0.session_recording.event_count";
29
+ export declare const SESSION_RECORDING_HAS_SNAPSHOT = "dash0.session_recording.has_snapshot";
30
+ export declare const SESSION_RECORDING_END_TIME_UNIX_NANO = "dash0.session_recording.end_time_unix_nano";
26
31
  export declare const USER_ID = "user.id";
27
32
  export declare const USER_NAME = "user.name";
28
33
  export declare const USER_FULL_NAME = "user.full_name";
@@ -51,6 +56,7 @@ export declare const EVENT_NAMES: {
51
56
  NAVIGATION_TIMING: string;
52
57
  WEB_VITAL: string;
53
58
  ERROR: string;
59
+ SESSION_RECORDING: string;
54
60
  };
55
61
  export declare const SPAN_EVENT_NAME_EXCEPTION = "exception";
56
62
  export declare const LOG_SEVERITIES: {
@@ -1 +1,8 @@
1
- export declare function send(path: string, body: unknown): Promise<void>;
1
+ export type SendOptions = {
2
+ /**
3
+ * Compress this request regardless of `vars.enableTransportCompression`. Used for payloads that are
4
+ * large and highly compressible, such as session recording chunks.
5
+ */
6
+ compress?: boolean;
7
+ };
8
+ export declare function send(path: string, body: unknown, opts?: SendOptions): Promise<void>;
@@ -1,3 +1,13 @@
1
+ import { SendOptions } from "./fetch";
1
2
  import { LogRecord, Span } from "../types/otlp";
2
3
  export declare function sendLog(log: LogRecord): void;
4
+ /**
5
+ * Transmits a session recording chunk as a single log record. Chunks bypass the log batcher: batching 15 chunks
6
+ * of up to `chunkMaxBytes` each would produce requests far beyond the keepalive body limit.
7
+ *
8
+ * Chunks are gzipped by default because replay JSON compresses roughly 8:1. Pass `compress: false` when the
9
+ * request must be issued synchronously, i.e. while the document is being unloaded: compression is asynchronous
10
+ * and the page may be gone before `fetch()` is ever called.
11
+ */
12
+ export declare function sendSessionRecordingChunk(log: LogRecord, opts?: SendOptions): void;
3
13
  export declare function sendSpan(span: Span | undefined): void;
@@ -0,0 +1 @@
1
+ export {};
@@ -1,7 +1,7 @@
1
1
  import { AttributeValueType } from "../utils/otel";
2
2
  import { AnyValue } from "./otlp";
3
3
  import { Endpoint, Vars, PropagatorConfig } from "../vars";
4
- export type InstrumentationName = "@dash0/navigation" | "@dash0/web-vitals" | "@dash0/error" | "@dash0/fetch" | "@dash0/xhr";
4
+ export type InstrumentationName = "@dash0/navigation" | "@dash0/web-vitals" | "@dash0/error" | "@dash0/fetch" | "@dash0/xhr" | "@dash0/session-recording";
5
5
  /**
6
6
  * VCS (version control) context describing the build the SDK is running
7
7
  * inside. Used both as the public manual-override shape on `InitOptions.vcs`
@@ -110,4 +110,4 @@ export type InitOptions = {
110
110
  * Each propagator defines which header type to send for matching URLs.
111
111
  */
112
112
  propagators?: PropagatorConfig[];
113
- } & Partial<Pick<Vars, "ignoreUrls" | "ignoreErrorMessages" | "wrapEventHandlers" | "wrapTimers" | "propagateTraceHeadersCorsURLs" | "maxWaitForResourceTimingsMillis" | "maxToleranceForResourceTimingsMillis" | "headersToCapture" | "urlAttributeScrubber" | "pageViewInstrumentation" | "enableTransportCompression">>;
113
+ } & Partial<Pick<Vars, "ignoreUrls" | "ignoreErrorMessages" | "wrapEventHandlers" | "wrapTimers" | "propagateTraceHeadersCorsURLs" | "maxWaitForResourceTimingsMillis" | "maxToleranceForResourceTimingsMillis" | "headersToCapture" | "urlAttributeScrubber" | "pageViewInstrumentation" | "sessionRecording" | "enableTransportCompression">>;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The subset of rrweb's `record()` option surface the SDK forwards. Declared structurally so the
3
+ * public API of `@dash0/sdk-web` does not depend on `@rrweb/types`; rrweb's `record` satisfies it.
4
+ */
5
+ export type SessionRecorderOptions = {
6
+ /**
7
+ * Called by the recorder for every rrweb event. `isCheckout` is true for the events that
8
+ * start a new full snapshot (see `checkoutEveryNms`).
9
+ */
10
+ emit: (event: SessionRecordingEvent, isCheckout?: boolean) => void;
11
+ checkoutEveryNms?: number;
12
+ maskAllInputs?: boolean;
13
+ maskTextClass?: string | RegExp;
14
+ maskTextSelector?: string;
15
+ maskInputFn?: (text: string, element: HTMLElement | null) => string;
16
+ maskTextFn?: (text: string, element: HTMLElement | null) => string;
17
+ blockClass?: string | RegExp;
18
+ blockSelector?: string;
19
+ ignoreClass?: string;
20
+ recordCanvas?: boolean;
21
+ collectFonts?: boolean;
22
+ inlineStylesheet?: boolean;
23
+ };
24
+ /**
25
+ * A function that starts recording and returns a function that stops it.
26
+ * `record` from `@rrweb/record` (re-exported by `@dash0/sdk-web/session-recording`) has this shape.
27
+ */
28
+ export type SessionRecorder = (options: SessionRecorderOptions) => (() => void) | undefined;
29
+ /**
30
+ * The shape of an rrweb event the SDK relies on. rrweb events carry more data, which the SDK
31
+ * forwards untouched inside the chunk body.
32
+ */
33
+ export type SessionRecordingEvent = {
34
+ /**
35
+ * rrweb EventType. 2 is FullSnapshot, 4 is Meta.
36
+ */
37
+ type: number;
38
+ /**
39
+ * Milliseconds since the unix epoch.
40
+ */
41
+ timestamp: number;
42
+ data?: unknown;
43
+ };
44
+ export type SessionRecordingSettings = {
45
+ /**
46
+ * The percentage of sessions for which a recording is captured. Must be a number between 0 and 100.
47
+ * The decision is deterministic per session ID and uses the same hash as `sessionSamplingRate`, so
48
+ * recorded sessions are always a subset of the sessions for which telemetry is transmitted.
49
+ *
50
+ * @default 100
51
+ */
52
+ samplingRate?: number;
53
+ /**
54
+ * Replace the value of every visible input, textarea and select with asterisks before it leaves the browser.
55
+ * `<input type="hidden">` values are element attributes and are not masked; use `blockSelector` for those.
56
+ * Set to `false` only when you know no form on the page accepts sensitive data.
57
+ *
58
+ * @default true
59
+ */
60
+ maskAllInputs?: boolean;
61
+ /**
62
+ * CSS selector for elements whose text content must be masked. Use `"*"` to mask all text on the page.
63
+ */
64
+ maskTextSelector?: string;
65
+ /**
66
+ * Elements with this class have their text content masked.
67
+ *
68
+ * @default "dash0-mask"
69
+ */
70
+ maskTextClass?: string | RegExp;
71
+ /**
72
+ * Elements with this class are not recorded at all. A placeholder with the same dimensions
73
+ * is shown in the replay instead.
74
+ *
75
+ * @default "dash0-block"
76
+ */
77
+ blockClass?: string | RegExp;
78
+ /**
79
+ * CSS selector for elements that are not recorded at all.
80
+ */
81
+ blockSelector?: string;
82
+ /**
83
+ * Custom function to mask input values. Receives the raw value and the element, and must return the masked value.
84
+ */
85
+ maskInputFn?: (text: string, element: HTMLElement | null) => string;
86
+ /**
87
+ * Custom function to mask text nodes. Receives the raw text and the parent element, and must return the masked text.
88
+ */
89
+ maskTextFn?: (text: string, element: HTMLElement | null) => string;
90
+ /**
91
+ * Record the content of canvas elements. This is expensive and off by default.
92
+ *
93
+ * @default false
94
+ */
95
+ recordCanvas?: boolean;
96
+ /**
97
+ * Collect fonts so the replay renders with the same typefaces. Adds payload size.
98
+ *
99
+ * @default false
100
+ */
101
+ collectFonts?: boolean;
102
+ /**
103
+ * The maximum serialized size of one chunk in bytes. When the buffered events reach this size, a chunk is
104
+ * transmitted. A single rrweb event larger than this (typically a full snapshot) is transmitted on its own.
105
+ *
106
+ * @default 48000
107
+ */
108
+ chunkMaxBytes?: number;
109
+ /**
110
+ * The maximum time buffered events wait before they are transmitted as a chunk.
111
+ *
112
+ * @default 5000
113
+ */
114
+ chunkMaxMillis?: number;
115
+ /**
116
+ * How often the recorder takes a new full snapshot of the DOM, in milliseconds. A replay can start
117
+ * from any chunk that contains a full snapshot.
118
+ *
119
+ * @default 300000
120
+ */
121
+ checkoutEveryNms?: number;
122
+ /**
123
+ * The recorder to use. Pass `recorder` from `@dash0/sdk-web/session-recording`. When omitted, the SDK waits for
124
+ * a recorder to be registered through `startSessionRecording(recorder)` or through the
125
+ * `dash0-session-recording.iife.js` script.
126
+ */
127
+ recorder?: SessionRecorder;
128
+ };
@@ -1,6 +1,7 @@
1
1
  import { AttributeValueType } from "./utils/otel";
2
2
  import { AnyValue, InstrumentationScope, KeyValue, Resource } from "./types/otlp";
3
3
  import { UrlAttributeScrubber } from "./attributes";
4
+ import { SessionRecordingSettings } from "./types/session-recording";
4
5
  export type PropagatorType = "traceparent" | "xray";
5
6
  export type PropagatorConfig = {
6
7
  type: PropagatorType;
@@ -140,6 +141,11 @@ export type Vars = {
140
141
  */
141
142
  urlAttributeScrubber: UrlAttributeScrubber;
142
143
  pageViewInstrumentation: PageViewInstrumentationSettings;
144
+ /**
145
+ * Session recording (replay) settings. Recording only starts when a recorder is provided, either through
146
+ * `sessionRecording.recorder`, `startSessionRecording(recorder)`, or the `dash0-session-recording.iife.js` script.
147
+ */
148
+ sessionRecording: SessionRecordingSettings;
143
149
  /**
144
150
  * Enables telemetry transport compression using gzip.
145
151
  * experimental - in rare cases causes Chrome to crash to use at your own risk.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dash0/sdk-web",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "Dash0's Web SDK to collect telemetry from end-users' web browsers",
5
5
  "type": "module",
6
6
  "main": "dist/dash0.umd.cjs",
@@ -11,14 +11,20 @@
11
11
  "types": "./dist/types/entrypoint/npm-package.d.ts",
12
12
  "require": "./dist/dash0.umd.cjs",
13
13
  "default": "./dist/dash0.js"
14
+ },
15
+ "./session-recording": {
16
+ "types": "./dist/types/entrypoint/session-recording.d.ts",
17
+ "require": "./dist/dash0-session-recording.umd.cjs",
18
+ "default": "./dist/dash0-session-recording.js"
14
19
  }
15
20
  },
16
21
  "types": "./dist/types/entrypoint/npm-package.d.ts",
17
22
  "scripts": {
18
- "build": "run-s build:clean build:typescript build:rollup build:stats",
23
+ "build": "run-s build:clean build:typescript build:rollup build:verify build:stats",
19
24
  "build:clean": "rm -rf dist",
20
25
  "build:typescript": "tsc -b",
21
26
  "build:rollup": "rollup -c -m",
27
+ "build:verify": "node scripts/verify-bundles.mjs",
22
28
  "build:stats": "echo \"\nFile Stats:\" && ls dist/*.js | xargs -I '%' bash -c 'echo \"%: $(./node_modules/.bin/gzip-size % --include-original) (gzip)\"'",
23
29
  "prettier:all": "prettier . --write --cache --list-different",
24
30
  "prettier:check": "prettier . --check",
@@ -29,6 +35,7 @@
29
35
  "test:unit:watch": "vitest",
30
36
  "test:e2e": "wdio run ./test/e2e/wdio.conf.ts",
31
37
  "test:e2e:local": "pnpm run build && wdio run ./test/e2e/wdio.local.conf.ts",
38
+ "sink": "node scripts/otlp-sink.mjs",
32
39
  "test:e2e:server": "SERVER_PORTS='8010,8011,8012' node test/e2e/server/index.mjs",
33
40
  "test:e2e:tunnel": "dotenv -- bash -c './.lambdatest/v3/LT --user $LT_USERNAME --key $LT_ACCESS_KEY'",
34
41
  "test:e2e:live": "run-p test:e2e:server test:e2e:tunnel",
@@ -71,6 +78,7 @@
71
78
  "web-vitals": "^5.0.3"
72
79
  },
73
80
  "devDependencies": {
81
+ "@rrweb/record": "^2.1.1",
74
82
  "@babel/core": "^7.26.10",
75
83
  "@babel/preset-env": "^7.26.9",
76
84
  "@release-it/conventional-changelog": "^10.0.1",
package/src/api/init.ts CHANGED
@@ -35,6 +35,7 @@ import { initializeTabId } from "../utils/tab-id";
35
35
  import { InitOptions, InstrumentationName } from "../types/options";
36
36
  import { BrowserBuildEnv, pickFirstString } from "./browser-env";
37
37
  import { applyVcsResourceAttributes } from "./vcs";
38
+ import { armSessionRecording } from "../instrumentations/session-recording";
38
39
 
39
40
  declare const process: { env?: BrowserBuildEnv } | undefined;
40
41
 
@@ -86,6 +87,7 @@ export function init(opts: InitOptions) {
86
87
  "headersToCapture",
87
88
  "urlAttributeScrubber",
88
89
  "pageViewInstrumentation",
90
+ "sessionRecording",
89
91
  "enableTransportCompression",
90
92
  ])
91
93
  )
@@ -124,6 +126,9 @@ export function init(opts: InitOptions) {
124
126
  if (isInstrumentationEnabled("@dash0/xhr", opts)) {
125
127
  instrumentXhr();
126
128
  }
129
+ if (isInstrumentationEnabled("@dash0/session-recording", opts)) {
130
+ armSessionRecording();
131
+ }
127
132
 
128
133
  hasBeenInitialised = true;
129
134
  }
@@ -303,7 +308,10 @@ function merge<T extends Record<string, unknown>>(target: T, source: Partial<T>)
303
308
  dstVal !== null &&
304
309
  !Array.isArray(dstVal)
305
310
  ) {
306
- result[key] = { ...dstVal, ...srcVal } as T[keyof T];
311
+ // Like the top-level rule above, an explicit `undefined` inside a nested object means "not provided" and
312
+ // must not erase the default. Otherwise `sessionRecording: { maskAllInputs: someUnsetFlag }` would
313
+ // silently turn input masking off.
314
+ result[key] = { ...dstVal, ...withoutUndefined(srcVal as Record<string, unknown>) } as T[keyof T];
307
315
  } else {
308
316
  result[key] = srcVal as T[keyof T];
309
317
  }
@@ -311,3 +319,13 @@ function merge<T extends Record<string, unknown>>(target: T, source: Partial<T>)
311
319
  }
312
320
  return result;
313
321
  }
322
+
323
+ function withoutUndefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
324
+ const result: Partial<T> = {};
325
+ for (const key of Object.keys(obj) as Array<keyof T>) {
326
+ if (obj[key] !== undefined) {
327
+ result[key] = obj[key];
328
+ }
329
+ }
330
+ return result;
331
+ }
@@ -38,6 +38,10 @@ vi.mock("../instrumentations/navigation", () => ({
38
38
  startNavigationInstrumentation: vi.fn(),
39
39
  }));
40
40
 
41
+ vi.mock("../instrumentations/session-recording", () => ({
42
+ armSessionRecording: vi.fn(),
43
+ }));
44
+
41
45
  // Mock the utils module to control loc.hostname
42
46
  vi.mock("../utils", async () => {
43
47
  const actual = await vi.importActual("../utils");
@@ -52,6 +56,7 @@ import { instrumentFetch } from "../instrumentations/http/fetch";
52
56
  import { instrumentXhr } from "../instrumentations/http/xhr";
53
57
  import { startNavigationInstrumentation } from "../instrumentations/navigation";
54
58
  import { startWebVitalsInstrumentation } from "../instrumentations/web-vitals";
59
+ import { armSessionRecording } from "../instrumentations/session-recording";
55
60
 
56
61
  describe("init", () => {
57
62
  const baseOptions: InitOptions = {
@@ -78,6 +83,31 @@ describe("init", () => {
78
83
  vi.clearAllMocks();
79
84
  });
80
85
 
86
+ describe("nested option merging", () => {
87
+ it("keeps nested defaults when an override is explicitly undefined", () => {
88
+ init({
89
+ ...baseOptions,
90
+ sessionRecording: { maskAllInputs: undefined, chunkMaxMillis: 1000 },
91
+ });
92
+
93
+ expect(vars.sessionRecording.maskAllInputs).toBe(true);
94
+ expect(vars.sessionRecording.blockClass).toBe("dash0-block");
95
+ expect(vars.sessionRecording.chunkMaxMillis).toBe(1000);
96
+ });
97
+
98
+ it("still lets an explicit false override a nested default", () => {
99
+ init({
100
+ ...baseOptions,
101
+ sessionRecording: { maskAllInputs: false },
102
+ pageViewInstrumentation: { trackVirtualPageViews: false },
103
+ });
104
+
105
+ expect(vars.sessionRecording.maskAllInputs).toBe(false);
106
+ expect(vars.pageViewInstrumentation.trackVirtualPageViews).toBe(false);
107
+ expect(vars.pageViewInstrumentation.includeParts).toEqual([]);
108
+ });
109
+ });
110
+
81
111
  describe("instrumentation enablement", () => {
82
112
  it("should enable all instrumentations when enabledInstrumentations is undefined", async () => {
83
113
  init({
@@ -98,13 +128,15 @@ describe("init", () => {
98
128
  "@dash0/error",
99
129
  "@dash0/fetch",
100
130
  "@dash0/xhr",
131
+ "@dash0/session-recording",
101
132
  ];
102
- const instrumentationMocks = {
133
+ const instrumentationMocks: Record<InstrumentationName, () => void> = {
103
134
  "@dash0/navigation": startNavigationInstrumentation,
104
135
  "@dash0/web-vitals": startWebVitalsInstrumentation,
105
136
  "@dash0/error": startErrorInstrumentation,
106
137
  "@dash0/fetch": instrumentFetch,
107
138
  "@dash0/xhr": instrumentXhr,
139
+ "@dash0/session-recording": armSessionRecording,
108
140
  };
109
141
 
110
142
  instrumentations.forEach((instrumentation) => {
@@ -0,0 +1,48 @@
1
+ import { debug, win } from "../utils";
2
+ import { SessionRecorder } from "../types/session-recording";
3
+ import {
4
+ GLOBAL_RECORDER_KEY,
5
+ registerSessionRecorder,
6
+ stopSessionRecording as stopRecording,
7
+ } from "../instrumentations/session-recording";
8
+
9
+ /**
10
+ * Starts session recording with the given recorder. Pass `recorder` from `@dash0/sdk-web/session-recording`.
11
+ * When `recorder` is omitted, the SDK looks for `window.dash0Recorder`, which the
12
+ * `dash0-session-recording.iife.js` script sets. Calling this is not required when that script is used: `init()`
13
+ * picks up `window.dash0Recorder` on its own, regardless of the order in which the scripts execute.
14
+ *
15
+ * Recording only starts once `init()` has run with a sampled session. It is safe to call this before `init()`;
16
+ * the recorder is kept and recording starts as soon as the SDK is initialized. The recording is transmitted
17
+ * as `browser.session_recording` log records that share one trace ID, which embeds the session ID.
18
+ *
19
+ * Only the visible document is recorded. A tab that is hidden — switched away from, or opened in the
20
+ * background — stops recording and flushes what it buffered, and starts a fresh recording when it is shown
21
+ * again. So a session is a sequence of recordings that do not overlap in time, which is what lets the whole
22
+ * session, tab switches included, be replayed as one.
23
+ */
24
+ export function startSessionRecording(recorder?: SessionRecorder): void {
25
+ // The script entrypoint forwards dash0("startSessionRecording", ...) arguments without type checking,
26
+ // so malformed calls must degrade to a logged no-op instead of throwing. An uncaught throw here would
27
+ // abort the command-queue drain and drop all subsequently queued api calls.
28
+ const r = recorder ?? (win as any)?.[GLOBAL_RECORDER_KEY];
29
+ if (typeof r !== "function") {
30
+ debug(
31
+ "startSessionRecording requires a recorder. Import `recorder` from `@dash0/sdk-web/session-recording` or load dash0-session-recording.iife.js. Ignoring call."
32
+ );
33
+ return;
34
+ }
35
+
36
+ registerSessionRecorder(r as SessionRecorder);
37
+ }
38
+
39
+ /**
40
+ * Stops the running session recording and transmits any buffered events. Calling this when no recording is
41
+ * running is a no-op.
42
+ *
43
+ * Unlike the automatic pause while a tab is hidden, this is final: recording does not resume when the tab
44
+ * becomes visible again. Call `startSessionRecording()` to record again.
45
+ */
46
+ export function stopSessionRecording(): void {
47
+ stopRecording();
48
+ }
@@ -0,0 +1,44 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ vi.mock("../instrumentations/session-recording", () => ({
4
+ GLOBAL_RECORDER_KEY: "dash0Recorder",
5
+ registerSessionRecorder: vi.fn(),
6
+ stopSessionRecording: vi.fn(),
7
+ }));
8
+
9
+ import { registerSessionRecorder, stopSessionRecording as stopImpl } from "../instrumentations/session-recording";
10
+ import { startSessionRecording, stopSessionRecording } from "./session-recording";
11
+ import { win } from "../utils";
12
+
13
+ const globalObject = win as any;
14
+
15
+ describe("startSessionRecording api", () => {
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ delete globalObject.dash0Recorder;
19
+ });
20
+
21
+ it("registers an explicitly passed recorder", () => {
22
+ const recorder = vi.fn();
23
+ startSessionRecording(recorder as any);
24
+ expect(registerSessionRecorder).toHaveBeenCalledWith(recorder);
25
+ });
26
+
27
+ it("falls back to window.dash0Recorder", () => {
28
+ const recorder = vi.fn();
29
+ globalObject.dash0Recorder = recorder;
30
+ startSessionRecording();
31
+ expect(registerSessionRecorder).toHaveBeenCalledWith(recorder);
32
+ });
33
+
34
+ it("ignores calls without a usable recorder instead of throwing", () => {
35
+ expect(() => startSessionRecording()).not.toThrow();
36
+ expect(() => startSessionRecording("nope" as any)).not.toThrow();
37
+ expect(registerSessionRecorder).not.toHaveBeenCalled();
38
+ });
39
+
40
+ it("delegates stop", () => {
41
+ stopSessionRecording();
42
+ expect(stopImpl).toHaveBeenCalledTimes(1);
43
+ });
44
+ });
@@ -10,6 +10,7 @@ export * from "../api/log-level";
10
10
  export { terminateSession } from "../api/session";
11
11
  export { reportError } from "../api/report-error";
12
12
  export { startView } from "../api/start-view";
13
+ export { startSessionRecording, stopSessionRecording } from "../api/session-recording";
13
14
 
14
15
  // Additional utility types
15
16
  export type { AttributeValueType } from "../utils/otel";
@@ -17,6 +18,7 @@ export type { AnyValue } from "../types/otlp";
17
18
  export type { PageViewMeta, PropagatorConfig, PropagatorType } from "../vars";
18
19
  export type { UrlAttributeScrubber, UrlAttributeRecord } from "../attributes/url";
19
20
  export type { StartViewOptions } from "../api/start-view";
21
+ export type { SessionRecorder, SessionRecorderOptions, SessionRecordingSettings } from "../types/session-recording";
20
22
 
21
23
  export function init(opts: InitOptions): void {
22
24
  debug(`${INIT_MESSAGE} (via package)`);
@@ -9,6 +9,7 @@ import { addSignalAttribute, removeSignalAttribute } from "../api/attributes";
9
9
  import { sendEvent } from "../api/events";
10
10
  import { setActiveLogLevel } from "../api/log-level";
11
11
  import { startView } from "../api/start-view";
12
+ import { startSessionRecording, stopSessionRecording } from "../api/session-recording";
12
13
 
13
14
  /**
14
15
  * All the APIs exposed through the script tag via `dash0('{{api name}}')`
@@ -24,6 +25,8 @@ const scriptApis = {
24
25
  setActiveLogLevel,
25
26
  sendEvent,
26
27
  startView,
28
+ startSessionRecording,
29
+ stopSessionRecording,
27
30
  } as const;
28
31
 
29
32
  type GlobalObject = {
@@ -0,0 +1,23 @@
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
+
16
+ type Dash0Global = ((...args: unknown[]) => void) | undefined;
17
+
18
+ (window as unknown as { dash0Recorder?: unknown }).dash0Recorder = record;
19
+
20
+ const dash0 = (window as unknown as { dash0?: Dash0Global }).dash0;
21
+ if (typeof dash0 === "function") {
22
+ dash0("startSessionRecording", record);
23
+ }
@@ -0,0 +1,13 @@
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
+ import type { SessionRecorder } from "../types/session-recording";
9
+
10
+ /**
11
+ * The rrweb recorder. Pass it to `init({ sessionRecording: { recorder } })` or to `startSessionRecording(recorder)`.
12
+ */
13
+ export const recorder = record as unknown as SessionRecorder;