@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
@@ -0,0 +1,38 @@
1
+ import { SessionRecordingEvent } from "../../types/session-recording";
2
+ export type Chunk = {
3
+ /**
4
+ * Zero-based, monotonic within one recording.
5
+ */
6
+ seq: number;
7
+ /**
8
+ * The rrweb events as a serialized JSON array.
9
+ */
10
+ body: string;
11
+ eventCount: number;
12
+ hasSnapshot: boolean;
13
+ /**
14
+ * Milliseconds since the unix epoch of the first and the last event in the chunk.
15
+ */
16
+ startTime: number;
17
+ endTime: number;
18
+ };
19
+ export type ChunkerOptions = {
20
+ maxBytes: number;
21
+ maxMillis: number;
22
+ onChunk: (chunk: Chunk) => void;
23
+ };
24
+ export type Chunker = {
25
+ add(event: SessionRecordingEvent): void;
26
+ flush(): void;
27
+ /**
28
+ * Drops buffered events without emitting a chunk and cancels the pending time-based flush. Used when the
29
+ * recorder failed to start after it already emitted events, so no stray chunk is transmitted later.
30
+ */
31
+ discard(): void;
32
+ };
33
+ /**
34
+ * Buffers rrweb events and hands them out as chunks. A chunk closes when its serialized size reaches
35
+ * `maxBytes`, when `maxMillis` have passed since its first event, when a new full snapshot begins, or when
36
+ * `flush()` is called. Events are serialized once, on arrival, so a flush is a join and not a second stringify.
37
+ */
38
+ export declare function newChunker(opts: ChunkerOptions): Chunker;
@@ -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.23.0",
3
+ "version": "0.25.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",
@@ -117,7 +125,7 @@
117
125
  "resolutions": {
118
126
  "conventional-changelog-conventionalcommits": "8.0.0"
119
127
  },
120
- "packageManager": "pnpm@9.15.1",
128
+ "packageManager": "pnpm@10.33.4",
121
129
  "publishConfig": {
122
130
  "access": "public"
123
131
  }
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,40 @@
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
+ export function startSessionRecording(recorder?: SessionRecorder): void {
20
+ // The script entrypoint forwards dash0("startSessionRecording", ...) arguments without type checking,
21
+ // so malformed calls must degrade to a logged no-op instead of throwing. An uncaught throw here would
22
+ // abort the command-queue drain and drop all subsequently queued api calls.
23
+ const r = recorder ?? (win as any)?.[GLOBAL_RECORDER_KEY];
24
+ if (typeof r !== "function") {
25
+ debug(
26
+ "startSessionRecording requires a recorder. Import `recorder` from `@dash0/sdk-web/session-recording` or load dash0-session-recording.iife.js. Ignoring call."
27
+ );
28
+ return;
29
+ }
30
+
31
+ registerSessionRecorder(r as SessionRecorder);
32
+ }
33
+
34
+ /**
35
+ * Stops the running session recording and transmits any buffered events. Calling this when no recording is
36
+ * running is a no-op.
37
+ */
38
+ export function stopSessionRecording(): void {
39
+ stopRecording();
40
+ }
@@ -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
+ });
@@ -0,0 +1,68 @@
1
+ import { transmitManualPageViewEvent } from "../instrumentations/navigation/event";
2
+ import { AttributeValueType } from "../utils/otel";
3
+ import { AnyValue } from "../types/otlp";
4
+ import { debug, nowNanos, win } from "../utils";
5
+ import { vars } from "../vars";
6
+
7
+ export type StartViewOptions = {
8
+ /**
9
+ * Optionally override the url reflected in `page.url.*` attributes for this view.
10
+ * Accepts an absolute or relative url; relative urls are resolved against the current
11
+ * `location.href`. Falls back to the real `location.href` if omitted or invalid.
12
+ * This is display-only: calling startView never navigates or mutates history/location.
13
+ */
14
+ url?: string;
15
+
16
+ /**
17
+ * Additional attributes to include with the page view.
18
+ * Added after the SDK-generated attributes, so they can override them.
19
+ */
20
+ attributes?: Record<string, AttributeValueType | AnyValue>;
21
+ };
22
+
23
+ /**
24
+ * Manually records a page view, side-effect free: this never calls `history.pushState` /
25
+ * `history.replaceState` and never mutates `location`. Intended for single-page applications
26
+ * that own their own router and cannot let the SDK touch navigation state (e.g. Electron apps
27
+ * serving the whole app from one root URL, where automatic page-view tracking would report
28
+ * every screen as "/").
29
+ *
30
+ * The emitted event is indistinguishable from an automatic virtual page view downstream
31
+ * (same `browser.page_view` event name, same `type` value), with two differences: it is never
32
+ * accompanied by a `change_state` value, since no history mutation occurred, and the
33
+ * `pageViewInstrumentation`'s `generateMetadata` callback is not invoked for manual views —
34
+ * supply title and attributes directly instead.
35
+ *
36
+ * @param name The name of the view, e.g. "/settings". Transmitted as the page view's title.
37
+ * @param opts Additional page view details.
38
+ */
39
+ export function startView(name: string, opts?: StartViewOptions) {
40
+ if (vars.endpoints.length === 0) {
41
+ debug("Dash0 SDK has not been initialized. Ignoring startView call.");
42
+ return;
43
+ }
44
+
45
+ // The script entrypoint forwards dash0("startView", ...) arguments without type checking,
46
+ // so malformed calls must degrade to a logged no-op instead of throwing. An uncaught throw
47
+ // here would abort the command-queue drain and drop all subsequently queued api calls.
48
+ if (typeof name !== "string" || name.length === 0) {
49
+ debug("startView requires a non-empty view name. Ignoring startView call.");
50
+ return;
51
+ }
52
+
53
+ let url: URL | undefined;
54
+ if (opts?.url != null) {
55
+ try {
56
+ url = new URL(opts.url, win?.location.href);
57
+ } catch (e) {
58
+ debug("Failed to parse startView url option. Falling back to the current location.", e);
59
+ }
60
+ }
61
+
62
+ transmitManualPageViewEvent({
63
+ timeUnixNano: nowNanos(),
64
+ title: name,
65
+ url,
66
+ attributes: opts?.attributes,
67
+ });
68
+ }