@dash0/sdk-web 0.23.0 → 0.24.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.
@@ -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
+ }
@@ -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,14 @@ 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";
12
13
 
13
14
  // Additional utility types
14
15
  export type { AttributeValueType } from "../utils/otel";
15
16
  export type { AnyValue } from "../types/otlp";
16
17
  export type { PageViewMeta, PropagatorConfig, PropagatorType } from "../vars";
17
18
  export type { UrlAttributeScrubber, UrlAttributeRecord } from "../attributes/url";
19
+ export type { StartViewOptions } from "../api/start-view";
18
20
 
19
21
  export function init(opts: InitOptions): void {
20
22
  debug(`${INIT_MESSAGE} (via package)`);
@@ -0,0 +1,8 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import * as npmPackage from "./npm-package";
3
+
4
+ describe("npm-package entrypoint", () => {
5
+ it("exports startView", () => {
6
+ expect(typeof npmPackage.startView).toBe("function");
7
+ });
8
+ });
@@ -8,6 +8,7 @@ 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";
11
12
 
12
13
  /**
13
14
  * All the APIs exposed through the script tag via `dash0('{{api name}}')`
@@ -22,6 +23,7 @@ const scriptApis = {
22
23
  removeSignalAttribute,
23
24
  setActiveLogLevel,
24
25
  sendEvent,
26
+ startView,
25
27
  } as const;
26
28
 
27
29
  type GlobalObject = {
@@ -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
- export function transmitPageViewEvent(timeUnixNano: string, url?: URL, virtual?: boolean, replaced?: boolean) {
24
- const meta = getPageViewMeta(url);
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 (meta.attributes) {
30
- Object.entries(meta.attributes).forEach(([key, value]) => addAttribute(attributes, key, value));
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", meta.title ?? doc?.title ?? NO_VALUE_FALLBACK);
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, virtual ? PAGE_VIEW_TYPE_VALUES.VIRTUAL : PAGE_VIEW_TYPE_VALUES.INITIAL);
41
- addAttribute(
42
- bodyAttributes,
43
- PAGE_VIEW_CHANGE_STATE,
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
+ });