@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.
- package/README.md +5 -0
- package/dist/dash0-session-recording.iife.js +2 -0
- package/dist/dash0-session-recording.iife.js.map +1 -0
- package/dist/dash0-session-recording.js +2 -0
- package/dist/dash0-session-recording.js.map +1 -0
- package/dist/dash0-session-recording.umd.cjs +2 -0
- package/dist/dash0-session-recording.umd.cjs.map +1 -0
- package/dist/dash0.iife.js +1 -1
- package/dist/dash0.iife.js.map +1 -1
- package/dist/dash0.js +1 -1
- package/dist/dash0.js.map +1 -1
- package/dist/dash0.umd.cjs +1 -1
- package/dist/dash0.umd.cjs.map +1 -1
- package/dist/modules/api/init.js +18 -1
- package/dist/modules/api/init_test.js +27 -0
- package/dist/modules/api/session-recording.js +30 -0
- package/dist/modules/api/session-recording_test.js +36 -0
- package/dist/modules/api/start-view.js +47 -0
- package/dist/modules/api/start-view_test.js +91 -0
- package/dist/modules/entrypoint/npm-package.js +2 -0
- package/dist/modules/entrypoint/npm-package_test.js +7 -0
- package/dist/modules/entrypoint/script.js +5 -0
- package/dist/modules/entrypoint/session-recording-script.js +19 -0
- package/dist/modules/entrypoint/session-recording.js +11 -0
- package/dist/modules/instrumentations/navigation/event.js +42 -10
- package/dist/modules/instrumentations/navigation/event_test.js +128 -0
- package/dist/modules/instrumentations/session-recording/chunker.js +70 -0
- package/dist/modules/instrumentations/session-recording/chunker_test.js +125 -0
- package/dist/modules/instrumentations/session-recording/index.js +165 -0
- package/dist/modules/instrumentations/session-recording/index_test.js +230 -0
- package/dist/modules/instrumentations/session-recording/log.js +23 -0
- package/dist/modules/instrumentations/session-recording/log_test.js +46 -0
- package/dist/modules/semantic-conventions.js +7 -0
- package/dist/modules/transport/fetch.js +2 -2
- package/dist/modules/transport/fetch_test.js +25 -0
- package/dist/modules/transport/index.js +44 -11
- package/dist/modules/transport/index_test.js +73 -0
- package/dist/modules/types/session-recording.js +1 -0
- package/dist/modules/vars.js +11 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/api/session-recording.d.ts +17 -0
- package/dist/types/api/session-recording_test.d.ts +1 -0
- package/dist/types/api/start-view.d.ts +33 -0
- package/dist/types/api/start-view_test.d.ts +1 -0
- package/dist/types/entrypoint/npm-package.d.ts +4 -0
- package/dist/types/entrypoint/npm-package_test.d.ts +1 -0
- package/dist/types/entrypoint/session-recording-script.d.ts +1 -0
- package/dist/types/entrypoint/session-recording.d.ts +5 -0
- package/dist/types/instrumentations/navigation/event.d.ts +18 -0
- package/dist/types/instrumentations/navigation/event_test.d.ts +1 -0
- package/dist/types/instrumentations/session-recording/chunker.d.ts +38 -0
- package/dist/types/instrumentations/session-recording/chunker_test.d.ts +1 -0
- package/dist/types/instrumentations/session-recording/index.d.ts +23 -0
- package/dist/types/instrumentations/session-recording/index_test.d.ts +1 -0
- package/dist/types/instrumentations/session-recording/log.d.ts +14 -0
- package/dist/types/instrumentations/session-recording/log_test.d.ts +1 -0
- package/dist/types/semantic-conventions.d.ts +6 -0
- package/dist/types/transport/fetch.d.ts +8 -1
- package/dist/types/transport/index.d.ts +10 -0
- package/dist/types/transport/index_test.d.ts +1 -0
- package/dist/types/types/options.d.ts +2 -2
- package/dist/types/types/session-recording.d.ts +128 -0
- package/dist/types/vars.d.ts +6 -0
- package/package.json +11 -3
- package/src/api/init.ts +19 -1
- package/src/api/init_test.ts +33 -1
- package/src/api/session-recording.ts +40 -0
- package/src/api/session-recording_test.ts +44 -0
- package/src/api/start-view.ts +68 -0
- package/src/api/start-view_test.ts +125 -0
- package/src/entrypoint/npm-package.ts +4 -0
- package/src/entrypoint/npm-package_test.ts +8 -0
- package/src/entrypoint/script.ts +5 -0
- package/src/entrypoint/session-recording-script.ts +23 -0
- package/src/entrypoint/session-recording.ts +13 -0
- package/src/instrumentations/navigation/event.ts +63 -15
- package/src/instrumentations/navigation/event_test.ts +171 -0
- package/src/instrumentations/session-recording/chunker.ts +118 -0
- package/src/instrumentations/session-recording/chunker_test.ts +147 -0
- package/src/instrumentations/session-recording/index.ts +175 -0
- package/src/instrumentations/session-recording/index_test.ts +273 -0
- package/src/instrumentations/session-recording/log.ts +48 -0
- package/src/instrumentations/session-recording/log_test.ts +60 -0
- package/src/semantic-conventions.ts +8 -0
- package/src/transport/fetch.ts +10 -2
- package/src/transport/fetch_test.ts +30 -0
- package/src/transport/index.ts +64 -25
- package/src/transport/index_test.ts +90 -0
- package/src/types/options.ts +3 -1
- package/src/types/session-recording.ts +144 -0
- package/src/vars.ts +18 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { KeyValue, LogRecord } from "../types/otlp";
|
|
3
|
+
|
|
4
|
+
vi.mock("../transport", () => ({
|
|
5
|
+
sendLog: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
import { sendLog } from "../transport";
|
|
9
|
+
import { startView } from "./start-view";
|
|
10
|
+
import { vars } from "../vars";
|
|
11
|
+
|
|
12
|
+
const sendLogMock = sendLog as unknown as ReturnType<typeof vi.fn>;
|
|
13
|
+
|
|
14
|
+
describe("startView", () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
sendLogMock.mockClear();
|
|
17
|
+
vars.endpoints = [{ url: "https://example.com", authToken: "auth_abc123" }];
|
|
18
|
+
vars.pageViewInstrumentation = { trackVirtualPageViews: true, includeParts: [] };
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.clearAllMocks();
|
|
23
|
+
vars.endpoints = [];
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("accepts a string shorthand and uses it as the title", () => {
|
|
27
|
+
startView("/settings");
|
|
28
|
+
|
|
29
|
+
expect(sendLogMock).toHaveBeenCalledTimes(1);
|
|
30
|
+
const log = sendLogMock.mock.calls[0]![0] as LogRecord;
|
|
31
|
+
const bodyValues = log.body?.kvlistValue?.values as KeyValue[];
|
|
32
|
+
expect(bodyValues).toEqual(expect.arrayContaining([{ key: "title", value: { stringValue: "/settings" } }]));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("accepts an options object with attributes", () => {
|
|
36
|
+
startView("/settings", { attributes: { "app.screen": "settings" } });
|
|
37
|
+
|
|
38
|
+
const log = sendLogMock.mock.calls[0]![0] as LogRecord;
|
|
39
|
+
const bodyValues = log.body?.kvlistValue?.values as KeyValue[];
|
|
40
|
+
expect(bodyValues).toEqual(expect.arrayContaining([{ key: "title", value: { stringValue: "/settings" } }]));
|
|
41
|
+
expect(log.attributes).toEqual(expect.arrayContaining([{ key: "app.screen", value: { stringValue: "settings" } }]));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("parses a relative url option and reflects it in page.url.path", () => {
|
|
45
|
+
startView("/settings", { url: "/settings" });
|
|
46
|
+
|
|
47
|
+
const log = sendLogMock.mock.calls[0]![0] as LogRecord;
|
|
48
|
+
expect(log.attributes).toEqual(
|
|
49
|
+
expect.arrayContaining([{ key: "page.url.path", value: { stringValue: "/settings" } }])
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("falls back to the current location on an invalid url", () => {
|
|
54
|
+
startView("/settings", { url: "http://" });
|
|
55
|
+
|
|
56
|
+
expect(sendLogMock).toHaveBeenCalledTimes(1);
|
|
57
|
+
const log = sendLogMock.mock.calls[0]![0] as LogRecord;
|
|
58
|
+
expect(log.attributes).toEqual(
|
|
59
|
+
expect.arrayContaining([
|
|
60
|
+
// eslint-disable-next-line no-restricted-globals
|
|
61
|
+
{ key: "page.url.full", value: { stringValue: window.location.href } },
|
|
62
|
+
// eslint-disable-next-line no-restricted-globals
|
|
63
|
+
{ key: "page.url.domain", value: { stringValue: window.location.hostname } },
|
|
64
|
+
])
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("resolves an absolute cross-origin url override", () => {
|
|
69
|
+
startView("/settings", { url: "https://other-origin.example/path" });
|
|
70
|
+
|
|
71
|
+
const log = sendLogMock.mock.calls[0]![0] as LogRecord;
|
|
72
|
+
expect(log.attributes).toEqual(
|
|
73
|
+
expect.arrayContaining([
|
|
74
|
+
{ key: "page.url.full", value: { stringValue: "https://other-origin.example/path" } },
|
|
75
|
+
{ key: "page.url.domain", value: { stringValue: "other-origin.example" } },
|
|
76
|
+
{ key: "page.url.path", value: { stringValue: "/path" } },
|
|
77
|
+
])
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("does not touch history or location", () => {
|
|
82
|
+
// eslint-disable-next-line no-restricted-globals
|
|
83
|
+
const originalHref = window.location.href;
|
|
84
|
+
// eslint-disable-next-line no-restricted-globals
|
|
85
|
+
const originalLength = window.history.length;
|
|
86
|
+
|
|
87
|
+
startView("/settings");
|
|
88
|
+
|
|
89
|
+
// eslint-disable-next-line no-restricted-globals
|
|
90
|
+
expect(window.location.href).toBe(originalHref);
|
|
91
|
+
// eslint-disable-next-line no-restricted-globals
|
|
92
|
+
expect(window.history.length).toBe(originalLength);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("ignores calls without a name, as they can arrive via the untyped script entrypoint", () => {
|
|
96
|
+
// @ts-expect-error deliberately calling without arguments, mirroring dash0("startView")
|
|
97
|
+
startView();
|
|
98
|
+
startView(undefined as unknown as string);
|
|
99
|
+
startView(null as unknown as string);
|
|
100
|
+
|
|
101
|
+
expect(sendLogMock).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("ignores calls with a non-string or empty name", () => {
|
|
105
|
+
startView(123 as unknown as string);
|
|
106
|
+
startView({ name: "/settings" } as unknown as string);
|
|
107
|
+
startView("");
|
|
108
|
+
|
|
109
|
+
expect(sendLogMock).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("tolerates a nullish options argument", () => {
|
|
113
|
+
startView("/settings", null as unknown as undefined);
|
|
114
|
+
|
|
115
|
+
expect(sendLogMock).toHaveBeenCalledTimes(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("is a no-op before init (no endpoints configured)", () => {
|
|
119
|
+
vars.endpoints = [];
|
|
120
|
+
|
|
121
|
+
startView("/settings");
|
|
122
|
+
|
|
123
|
+
expect(sendLogMock).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -9,12 +9,16 @@ export * from "../api/events";
|
|
|
9
9
|
export * from "../api/log-level";
|
|
10
10
|
export { terminateSession } from "../api/session";
|
|
11
11
|
export { reportError } from "../api/report-error";
|
|
12
|
+
export { startView } from "../api/start-view";
|
|
13
|
+
export { startSessionRecording, stopSessionRecording } from "../api/session-recording";
|
|
12
14
|
|
|
13
15
|
// Additional utility types
|
|
14
16
|
export type { AttributeValueType } from "../utils/otel";
|
|
15
17
|
export type { AnyValue } from "../types/otlp";
|
|
16
18
|
export type { PageViewMeta, PropagatorConfig, PropagatorType } from "../vars";
|
|
17
19
|
export type { UrlAttributeScrubber, UrlAttributeRecord } from "../attributes/url";
|
|
20
|
+
export type { StartViewOptions } from "../api/start-view";
|
|
21
|
+
export type { SessionRecorder, SessionRecorderOptions, SessionRecordingSettings } from "../types/session-recording";
|
|
18
22
|
|
|
19
23
|
export function init(opts: InitOptions): void {
|
|
20
24
|
debug(`${INIT_MESSAGE} (via package)`);
|
package/src/entrypoint/script.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { terminateSession } from "../api/session";
|
|
|
8
8
|
import { addSignalAttribute, removeSignalAttribute } from "../api/attributes";
|
|
9
9
|
import { sendEvent } from "../api/events";
|
|
10
10
|
import { setActiveLogLevel } from "../api/log-level";
|
|
11
|
+
import { startView } from "../api/start-view";
|
|
12
|
+
import { startSessionRecording, stopSessionRecording } from "../api/session-recording";
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* All the APIs exposed through the script tag via `dash0('{{api name}}')`
|
|
@@ -22,6 +24,9 @@ const scriptApis = {
|
|
|
22
24
|
removeSignalAttribute,
|
|
23
25
|
setActiveLogLevel,
|
|
24
26
|
sendEvent,
|
|
27
|
+
startView,
|
|
28
|
+
startSessionRecording,
|
|
29
|
+
stopSessionRecording,
|
|
25
30
|
} as const;
|
|
26
31
|
|
|
27
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;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { addAttribute, getTraceContextForPageLoad } from "../../utils/otel";
|
|
1
|
+
import { addAttribute, addAttributes, AttributeValueType, getTraceContextForPageLoad } from "../../utils/otel";
|
|
2
2
|
import {
|
|
3
3
|
EVENT_NAME,
|
|
4
4
|
EVENT_NAMES,
|
|
@@ -12,7 +12,7 @@ import { doc, NO_VALUE_FALLBACK } from "../../utils";
|
|
|
12
12
|
import { addCommonAttributes } from "../../attributes";
|
|
13
13
|
import { sendLog } from "../../transport";
|
|
14
14
|
import { PageViewMeta, vars } from "../../vars";
|
|
15
|
-
import { KeyValue, LogRecord } from "../../types/otlp";
|
|
15
|
+
import { AnyValue, KeyValue, LogRecord } from "../../types/otlp";
|
|
16
16
|
|
|
17
17
|
function getPageViewMeta(url?: URL): PageViewMeta {
|
|
18
18
|
if (!url) return {};
|
|
@@ -20,32 +20,41 @@ function getPageViewMeta(url?: URL): PageViewMeta {
|
|
|
20
20
|
return vars.pageViewInstrumentation.generateMetadata?.(url) ?? {};
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
type BuildPageViewLogOptions = {
|
|
24
|
+
timeUnixNano: string;
|
|
25
|
+
url?: URL;
|
|
26
|
+
title?: string;
|
|
27
|
+
metaAttributes?: Record<string, AttributeValueType | AnyValue>;
|
|
28
|
+
customAttributes?: Record<string, AttributeValueType | AnyValue>;
|
|
29
|
+
pageViewType: (typeof PAGE_VIEW_TYPE_VALUES)[keyof typeof PAGE_VIEW_TYPE_VALUES];
|
|
30
|
+
changeState?: (typeof PAGE_VIEW_CHANGE_STATE_VALUES)[keyof typeof PAGE_VIEW_CHANGE_STATE_VALUES];
|
|
31
|
+
};
|
|
25
32
|
|
|
33
|
+
function buildAndSendPageViewLog(opts: BuildPageViewLogOptions) {
|
|
26
34
|
const attributes: KeyValue[] = [];
|
|
27
35
|
addAttribute(attributes, EVENT_NAME, EVENT_NAMES.PAGE_VIEW);
|
|
28
36
|
|
|
29
|
-
if (
|
|
30
|
-
Object.entries(
|
|
37
|
+
if (opts.metaAttributes) {
|
|
38
|
+
Object.entries(opts.metaAttributes).forEach(([key, value]) => addAttribute(attributes, key, value));
|
|
31
39
|
}
|
|
32
|
-
addCommonAttributes(attributes, { url });
|
|
40
|
+
addCommonAttributes(attributes, { url: opts.url });
|
|
41
|
+
|
|
42
|
+
// Add custom attributes last to allow overrides
|
|
43
|
+
addAttributes(attributes, opts.customAttributes);
|
|
33
44
|
|
|
34
45
|
const bodyAttributes: KeyValue[] = [];
|
|
35
|
-
addAttribute(bodyAttributes, "title",
|
|
46
|
+
addAttribute(bodyAttributes, "title", opts.title ?? doc?.title ?? NO_VALUE_FALLBACK);
|
|
36
47
|
if (doc?.referrer) {
|
|
37
48
|
addAttribute(bodyAttributes, "referrer", doc.referrer);
|
|
38
49
|
}
|
|
39
50
|
|
|
40
|
-
addAttribute(bodyAttributes, PAGE_VIEW_TYPE,
|
|
41
|
-
|
|
42
|
-
bodyAttributes,
|
|
43
|
-
|
|
44
|
-
replaced ? PAGE_VIEW_CHANGE_STATE_VALUES.REPLACE : PAGE_VIEW_CHANGE_STATE_VALUES.PUSH
|
|
45
|
-
);
|
|
51
|
+
addAttribute(bodyAttributes, PAGE_VIEW_TYPE, opts.pageViewType);
|
|
52
|
+
if (opts.changeState) {
|
|
53
|
+
addAttribute(bodyAttributes, PAGE_VIEW_CHANGE_STATE, opts.changeState);
|
|
54
|
+
}
|
|
46
55
|
|
|
47
56
|
const log: LogRecord = {
|
|
48
|
-
timeUnixNano: timeUnixNano,
|
|
57
|
+
timeUnixNano: opts.timeUnixNano,
|
|
49
58
|
attributes: attributes,
|
|
50
59
|
severityNumber: LOG_SEVERITIES.INFO,
|
|
51
60
|
severityText: "INFO",
|
|
@@ -64,3 +73,42 @@ export function transmitPageViewEvent(timeUnixNano: string, url?: URL, virtual?:
|
|
|
64
73
|
|
|
65
74
|
sendLog(log);
|
|
66
75
|
}
|
|
76
|
+
|
|
77
|
+
export function transmitPageViewEvent(timeUnixNano: string, url?: URL, virtual?: boolean, replaced?: boolean) {
|
|
78
|
+
const meta = getPageViewMeta(url);
|
|
79
|
+
|
|
80
|
+
buildAndSendPageViewLog({
|
|
81
|
+
timeUnixNano,
|
|
82
|
+
url,
|
|
83
|
+
title: meta.title,
|
|
84
|
+
metaAttributes: meta.attributes,
|
|
85
|
+
pageViewType: virtual ? PAGE_VIEW_TYPE_VALUES.VIRTUAL : PAGE_VIEW_TYPE_VALUES.INITIAL,
|
|
86
|
+
changeState: replaced ? PAGE_VIEW_CHANGE_STATE_VALUES.REPLACE : PAGE_VIEW_CHANGE_STATE_VALUES.PUSH,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type ManualPageViewOptions = {
|
|
91
|
+
timeUnixNano: string;
|
|
92
|
+
url?: URL;
|
|
93
|
+
title: string;
|
|
94
|
+
attributes?: Record<string, AttributeValueType | AnyValue>;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Emits a manually-triggered page view log, e.g. from the public `startView` API.
|
|
99
|
+
* Deliberately does NOT read `vars.pageViewInstrumentation.generateMetadata` and does NOT
|
|
100
|
+
* include a `change_state` body key (a manual view is neither a pushState nor replaceState).
|
|
101
|
+
* The emitted `type` is PAGE_VIEW_TYPE_VALUES.VIRTUAL, matching automatic virtual page views —
|
|
102
|
+
* manual views are deliberately indistinguishable from virtual ones downstream.
|
|
103
|
+
* Caller-provided attributes are added after the SDK-generated ones so they can override them,
|
|
104
|
+
* matching the sendEvent semantics.
|
|
105
|
+
*/
|
|
106
|
+
export function transmitManualPageViewEvent(opts: ManualPageViewOptions) {
|
|
107
|
+
buildAndSendPageViewLog({
|
|
108
|
+
timeUnixNano: opts.timeUnixNano,
|
|
109
|
+
url: opts.url,
|
|
110
|
+
title: opts.title,
|
|
111
|
+
customAttributes: opts.attributes,
|
|
112
|
+
pageViewType: PAGE_VIEW_TYPE_VALUES.VIRTUAL,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { KeyValue, LogRecord } from "../../types/otlp";
|
|
3
|
+
|
|
4
|
+
vi.mock("../../transport", () => ({
|
|
5
|
+
sendLog: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
import { sendLog } from "../../transport";
|
|
9
|
+
import { transmitPageViewEvent, transmitManualPageViewEvent } from "./event";
|
|
10
|
+
import { vars } from "../../vars";
|
|
11
|
+
|
|
12
|
+
const sendLogMock = sendLog as unknown as ReturnType<typeof vi.fn>;
|
|
13
|
+
|
|
14
|
+
describe("transmitPageViewEvent", () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
sendLogMock.mockClear();
|
|
17
|
+
vars.pageViewInstrumentation = { trackVirtualPageViews: true, includeParts: [] };
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
vi.clearAllMocks();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("transmits an initial page view with type=INITIAL and change_state=pushState", () => {
|
|
25
|
+
transmitPageViewEvent("1700000000000000000", new URL("https://example.com/landing"));
|
|
26
|
+
|
|
27
|
+
expect(sendLogMock).toHaveBeenCalledTimes(1);
|
|
28
|
+
const log = sendLogMock.mock.calls[0]![0] as LogRecord;
|
|
29
|
+
|
|
30
|
+
expect(log.timeUnixNano).toBe("1700000000000000000");
|
|
31
|
+
expect(log.severityNumber).toBe(9);
|
|
32
|
+
expect(log.severityText).toBe("INFO");
|
|
33
|
+
expect(log.attributes).toEqual(
|
|
34
|
+
expect.arrayContaining([{ key: "event.name", value: { stringValue: "browser.page_view" } }])
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const bodyValues = log.body?.kvlistValue?.values as KeyValue[];
|
|
38
|
+
expect(bodyValues).toEqual(
|
|
39
|
+
expect.arrayContaining([
|
|
40
|
+
{ key: "type", value: { doubleValue: 0 } },
|
|
41
|
+
{ key: "change_state", value: { stringValue: "pushState" } },
|
|
42
|
+
])
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("transmits a virtual page view with type=VIRTUAL and change_state=replaceState when replaced", () => {
|
|
47
|
+
transmitPageViewEvent("1700000000000000000", new URL("https://example.com/settings"), true, true);
|
|
48
|
+
|
|
49
|
+
const log = sendLogMock.mock.calls[0]![0] as LogRecord;
|
|
50
|
+
const bodyValues = log.body?.kvlistValue?.values as KeyValue[];
|
|
51
|
+
expect(bodyValues).toEqual(
|
|
52
|
+
expect.arrayContaining([
|
|
53
|
+
{ key: "type", value: { doubleValue: 1 } },
|
|
54
|
+
{ key: "change_state", value: { stringValue: "replaceState" } },
|
|
55
|
+
])
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("applies generateMetadata title and attributes when provided", () => {
|
|
60
|
+
vars.pageViewInstrumentation = {
|
|
61
|
+
trackVirtualPageViews: true,
|
|
62
|
+
includeParts: [],
|
|
63
|
+
generateMetadata: () => ({ title: "Custom Title", attributes: { "app.screen": "settings" } }),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
transmitPageViewEvent("1700000000000000000", new URL("https://example.com/settings"), true);
|
|
67
|
+
|
|
68
|
+
const log = sendLogMock.mock.calls[0]![0] as LogRecord;
|
|
69
|
+
expect(log.attributes).toEqual(expect.arrayContaining([{ key: "app.screen", value: { stringValue: "settings" } }]));
|
|
70
|
+
const bodyValues = log.body?.kvlistValue?.values as KeyValue[];
|
|
71
|
+
expect(bodyValues).toEqual(expect.arrayContaining([{ key: "title", value: { stringValue: "Custom Title" } }]));
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("transmitManualPageViewEvent", () => {
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
sendLogMock.mockClear();
|
|
78
|
+
vars.pageViewInstrumentation = {
|
|
79
|
+
trackVirtualPageViews: true,
|
|
80
|
+
includeParts: [],
|
|
81
|
+
generateMetadata: () => ({ title: "should not be used", attributes: { should_not_appear: true } }),
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
vi.clearAllMocks();
|
|
87
|
+
vars.pageViewInstrumentation = { trackVirtualPageViews: true, includeParts: [] };
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("emits type=VIRTUAL and no change_state key", () => {
|
|
91
|
+
transmitManualPageViewEvent({ timeUnixNano: "1700000000000000000", title: "/settings" });
|
|
92
|
+
|
|
93
|
+
const log = sendLogMock.mock.calls[0]![0] as LogRecord;
|
|
94
|
+
const bodyValues = log.body?.kvlistValue?.values as KeyValue[];
|
|
95
|
+
|
|
96
|
+
expect(bodyValues).toEqual(
|
|
97
|
+
expect.arrayContaining([
|
|
98
|
+
{ key: "type", value: { doubleValue: 1 } },
|
|
99
|
+
{ key: "title", value: { stringValue: "/settings" } },
|
|
100
|
+
])
|
|
101
|
+
);
|
|
102
|
+
expect(bodyValues.find((v) => v.key === "change_state")).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("does not invoke vars.pageViewInstrumentation.generateMetadata", () => {
|
|
106
|
+
const generateMetadata = vi.fn().mockReturnValue({ title: "ignored" });
|
|
107
|
+
vars.pageViewInstrumentation = { trackVirtualPageViews: true, includeParts: [], generateMetadata };
|
|
108
|
+
|
|
109
|
+
transmitManualPageViewEvent({ timeUnixNano: "1700000000000000000", title: "/settings" });
|
|
110
|
+
|
|
111
|
+
expect(generateMetadata).not.toHaveBeenCalled();
|
|
112
|
+
const log = sendLogMock.mock.calls[0]![0] as LogRecord;
|
|
113
|
+
const bodyValues = log.body?.kvlistValue?.values as KeyValue[];
|
|
114
|
+
expect(bodyValues).toEqual(expect.arrayContaining([{ key: "title", value: { stringValue: "/settings" } }]));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("merges custom attributes into signal attributes", () => {
|
|
118
|
+
transmitManualPageViewEvent({
|
|
119
|
+
timeUnixNano: "1700000000000000000",
|
|
120
|
+
title: "/settings",
|
|
121
|
+
attributes: { "app.screen": "settings", "app.tab_index": 2 },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const log = sendLogMock.mock.calls[0]![0] as LogRecord;
|
|
125
|
+
expect(log.attributes).toEqual(
|
|
126
|
+
expect.arrayContaining([
|
|
127
|
+
{ key: "app.screen", value: { stringValue: "settings" } },
|
|
128
|
+
{ key: "app.tab_index", value: { doubleValue: 2 } },
|
|
129
|
+
])
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("adds custom attributes after SDK-generated ones so they can override", () => {
|
|
134
|
+
transmitManualPageViewEvent({
|
|
135
|
+
timeUnixNano: "1700000000000000000",
|
|
136
|
+
title: "/settings",
|
|
137
|
+
url: new URL("https://example.com/real-path"),
|
|
138
|
+
attributes: { "page.url.path": "/custom-path" },
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const log = sendLogMock.mock.calls[0]![0] as LogRecord;
|
|
142
|
+
const pathAttributes = (log.attributes as KeyValue[]).filter((attr) => attr.key === "page.url.path");
|
|
143
|
+
expect(pathAttributes.length).toBeGreaterThan(1);
|
|
144
|
+
expect(pathAttributes[pathAttributes.length - 1]).toEqual({
|
|
145
|
+
key: "page.url.path",
|
|
146
|
+
value: { stringValue: "/custom-path" },
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("passes url through to page.url.* attributes when provided", () => {
|
|
151
|
+
transmitManualPageViewEvent({
|
|
152
|
+
timeUnixNano: "1700000000000000000",
|
|
153
|
+
title: "/settings",
|
|
154
|
+
url: new URL("https://example.com/settings"),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const log = sendLogMock.mock.calls[0]![0] as LogRecord;
|
|
158
|
+
expect(log.attributes).toEqual(
|
|
159
|
+
expect.arrayContaining([{ key: "page.url.path", value: { stringValue: "/settings" } }])
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("includes referrer the same way the auto path does", () => {
|
|
164
|
+
transmitManualPageViewEvent({ timeUnixNano: "1700000000000000000", title: "/settings" });
|
|
165
|
+
|
|
166
|
+
const log = sendLogMock.mock.calls[0]![0] as LogRecord;
|
|
167
|
+
const bodyValues = log.body?.kvlistValue?.values as KeyValue[];
|
|
168
|
+
// doc.referrer is empty string in jsdom by default, so "referrer" key should be absent
|
|
169
|
+
expect(bodyValues.find((v) => v.key === "referrer")).toBeUndefined();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { setTimeout, clearTimeout } from "../../utils/timers";
|
|
2
|
+
import { SessionRecordingEvent } from "../../types/session-recording";
|
|
3
|
+
|
|
4
|
+
const RRWEB_EVENT_TYPE_FULL_SNAPSHOT = 2;
|
|
5
|
+
const RRWEB_EVENT_TYPE_META = 4;
|
|
6
|
+
|
|
7
|
+
export type Chunk = {
|
|
8
|
+
/**
|
|
9
|
+
* Zero-based, monotonic within one recording.
|
|
10
|
+
*/
|
|
11
|
+
seq: number;
|
|
12
|
+
/**
|
|
13
|
+
* The rrweb events as a serialized JSON array.
|
|
14
|
+
*/
|
|
15
|
+
body: string;
|
|
16
|
+
eventCount: number;
|
|
17
|
+
hasSnapshot: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Milliseconds since the unix epoch of the first and the last event in the chunk.
|
|
20
|
+
*/
|
|
21
|
+
startTime: number;
|
|
22
|
+
endTime: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ChunkerOptions = {
|
|
26
|
+
maxBytes: number;
|
|
27
|
+
maxMillis: number;
|
|
28
|
+
onChunk: (chunk: Chunk) => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type Chunker = {
|
|
32
|
+
add(event: SessionRecordingEvent): void;
|
|
33
|
+
flush(): void;
|
|
34
|
+
/**
|
|
35
|
+
* Drops buffered events without emitting a chunk and cancels the pending time-based flush. Used when the
|
|
36
|
+
* recorder failed to start after it already emitted events, so no stray chunk is transmitted later.
|
|
37
|
+
*/
|
|
38
|
+
discard(): void;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Buffers rrweb events and hands them out as chunks. A chunk closes when its serialized size reaches
|
|
43
|
+
* `maxBytes`, when `maxMillis` have passed since its first event, when a new full snapshot begins, or when
|
|
44
|
+
* `flush()` is called. Events are serialized once, on arrival, so a flush is a join and not a second stringify.
|
|
45
|
+
*/
|
|
46
|
+
export function newChunker(opts: ChunkerOptions): Chunker {
|
|
47
|
+
let serialized: string[] = [];
|
|
48
|
+
let byteSize = 0;
|
|
49
|
+
let hasSnapshot = false;
|
|
50
|
+
let startTime = 0;
|
|
51
|
+
let endTime = 0;
|
|
52
|
+
let seq = 0;
|
|
53
|
+
let pendingFlushTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
54
|
+
|
|
55
|
+
return { add, flush, discard };
|
|
56
|
+
|
|
57
|
+
function add(event: SessionRecordingEvent): void {
|
|
58
|
+
// A Meta event announces a new full snapshot. Close the current chunk first so the snapshot starts a fresh
|
|
59
|
+
// one and a replay can begin at that chunk.
|
|
60
|
+
if (event.type === RRWEB_EVENT_TYPE_META && serialized.length > 0) {
|
|
61
|
+
flush();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const json = JSON.stringify(event);
|
|
65
|
+
if (json == null) return;
|
|
66
|
+
|
|
67
|
+
if (serialized.length === 0) {
|
|
68
|
+
startTime = event.timestamp;
|
|
69
|
+
pendingFlushTimeout = setTimeout(flush, opts.maxMillis);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
serialized.push(json);
|
|
73
|
+
byteSize += json.length;
|
|
74
|
+
endTime = event.timestamp;
|
|
75
|
+
if (event.type === RRWEB_EVENT_TYPE_FULL_SNAPSHOT) {
|
|
76
|
+
hasSnapshot = true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (byteSize >= opts.maxBytes) {
|
|
80
|
+
flush();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function discard(): void {
|
|
85
|
+
clearPendingFlush();
|
|
86
|
+
serialized = [];
|
|
87
|
+
byteSize = 0;
|
|
88
|
+
hasSnapshot = false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function clearPendingFlush(): void {
|
|
92
|
+
if (pendingFlushTimeout != null) {
|
|
93
|
+
clearTimeout(pendingFlushTimeout);
|
|
94
|
+
pendingFlushTimeout = null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function flush(): void {
|
|
99
|
+
clearPendingFlush();
|
|
100
|
+
|
|
101
|
+
if (serialized.length === 0) return;
|
|
102
|
+
|
|
103
|
+
const chunk: Chunk = {
|
|
104
|
+
seq: seq++,
|
|
105
|
+
body: "[" + serialized.join(",") + "]",
|
|
106
|
+
eventCount: serialized.length,
|
|
107
|
+
hasSnapshot,
|
|
108
|
+
startTime,
|
|
109
|
+
endTime,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
serialized = [];
|
|
113
|
+
byteSize = 0;
|
|
114
|
+
hasSnapshot = false;
|
|
115
|
+
|
|
116
|
+
opts.onChunk(chunk);
|
|
117
|
+
}
|
|
118
|
+
}
|