@dash0/sdk-web 0.22.1 → 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.
- package/README.md +1 -1
- 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 +4 -0
- package/dist/modules/api/init_test.js +7 -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 +1 -0
- package/dist/modules/entrypoint/npm-package_test.js +7 -0
- package/dist/modules/entrypoint/script.js +2 -0
- package/dist/modules/instrumentations/http/fetch.js +90 -119
- package/dist/modules/instrumentations/http/fetch_test.js +47 -0
- package/dist/modules/instrumentations/http/utils.js +67 -3
- package/dist/modules/instrumentations/http/xhr.js +340 -0
- package/dist/modules/instrumentations/http/xhr_test.js +705 -0
- package/dist/modules/instrumentations/navigation/event.js +42 -10
- package/dist/modules/instrumentations/navigation/event_test.js +128 -0
- package/dist/modules/utils/wrap.js +16 -3
- package/dist/modules/utils/wrap_test.js +31 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- 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 +2 -0
- package/dist/types/entrypoint/npm-package_test.d.ts +1 -0
- package/dist/types/instrumentations/http/utils.d.ts +6 -1
- package/dist/types/instrumentations/http/xhr.d.ts +1 -0
- package/dist/types/instrumentations/http/xhr_test.d.ts +1 -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/types/options.d.ts +1 -1
- package/dist/types/utils/wrap_test.d.ts +1 -0
- package/package.json +3 -2
- package/src/api/init.ts +4 -0
- package/src/api/init_test.ts +8 -0
- package/src/api/start-view.ts +68 -0
- package/src/api/start-view_test.ts +125 -0
- package/src/entrypoint/npm-package.ts +2 -0
- package/src/entrypoint/npm-package_test.ts +8 -0
- package/src/entrypoint/script.ts +2 -0
- package/src/instrumentations/http/fetch.ts +120 -159
- package/src/instrumentations/http/fetch_test.ts +58 -0
- package/src/instrumentations/http/utils.ts +90 -3
- package/src/instrumentations/http/xhr.ts +431 -0
- package/src/instrumentations/http/xhr_test.ts +850 -0
- package/src/instrumentations/navigation/event.ts +63 -15
- package/src/instrumentations/navigation/event_test.ts +171 -0
- package/src/types/options.ts +6 -1
- package/src/utils/wrap.ts +15 -3
- package/src/utils/wrap_test.ts +41 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dash0/sdk-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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",
|
|
@@ -92,6 +92,7 @@
|
|
|
92
92
|
"@wdio/mocha-framework": "^9.12.5",
|
|
93
93
|
"@wdio/sauce-service": "^9.12.5",
|
|
94
94
|
"@wdio/spec-reporter": "^9.12.3",
|
|
95
|
+
"axios": "^1.18.1",
|
|
95
96
|
"body-parser": "^2.2.0",
|
|
96
97
|
"dotenv-cli": "^8.0.0",
|
|
97
98
|
"eslint": "^9.24.0",
|
|
@@ -116,7 +117,7 @@
|
|
|
116
117
|
"resolutions": {
|
|
117
118
|
"conventional-changelog-conventionalcommits": "8.0.0"
|
|
118
119
|
},
|
|
119
|
-
"packageManager": "pnpm@
|
|
120
|
+
"packageManager": "pnpm@10.33.4",
|
|
120
121
|
"publishConfig": {
|
|
121
122
|
"access": "public"
|
|
122
123
|
}
|
package/src/api/init.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { startWebVitalsInstrumentation } from "../instrumentations/web-vitals";
|
|
|
29
29
|
import { startErrorInstrumentation } from "../instrumentations/errors";
|
|
30
30
|
import { addAttribute } from "../utils/otel";
|
|
31
31
|
import { instrumentFetch } from "../instrumentations/http/fetch";
|
|
32
|
+
import { instrumentXhr } from "../instrumentations/http/xhr";
|
|
32
33
|
import { startNavigationInstrumentation } from "../instrumentations/navigation";
|
|
33
34
|
import { initializeTabId } from "../utils/tab-id";
|
|
34
35
|
import { InitOptions, InstrumentationName } from "../types/options";
|
|
@@ -120,6 +121,9 @@ export function init(opts: InitOptions) {
|
|
|
120
121
|
if (isInstrumentationEnabled("@dash0/fetch", opts)) {
|
|
121
122
|
instrumentFetch();
|
|
122
123
|
}
|
|
124
|
+
if (isInstrumentationEnabled("@dash0/xhr", opts)) {
|
|
125
|
+
instrumentXhr();
|
|
126
|
+
}
|
|
123
127
|
|
|
124
128
|
hasBeenInitialised = true;
|
|
125
129
|
}
|
package/src/api/init_test.ts
CHANGED
|
@@ -30,6 +30,10 @@ vi.mock("../instrumentations/http/fetch", () => ({
|
|
|
30
30
|
instrumentFetch: vi.fn(),
|
|
31
31
|
}));
|
|
32
32
|
|
|
33
|
+
vi.mock("../instrumentations/http/xhr", () => ({
|
|
34
|
+
instrumentXhr: vi.fn(),
|
|
35
|
+
}));
|
|
36
|
+
|
|
33
37
|
vi.mock("../instrumentations/navigation", () => ({
|
|
34
38
|
startNavigationInstrumentation: vi.fn(),
|
|
35
39
|
}));
|
|
@@ -45,6 +49,7 @@ vi.mock("../utils", async () => {
|
|
|
45
49
|
|
|
46
50
|
import { startErrorInstrumentation } from "../instrumentations/errors";
|
|
47
51
|
import { instrumentFetch } from "../instrumentations/http/fetch";
|
|
52
|
+
import { instrumentXhr } from "../instrumentations/http/xhr";
|
|
48
53
|
import { startNavigationInstrumentation } from "../instrumentations/navigation";
|
|
49
54
|
import { startWebVitalsInstrumentation } from "../instrumentations/web-vitals";
|
|
50
55
|
|
|
@@ -84,6 +89,7 @@ describe("init", () => {
|
|
|
84
89
|
expect(startWebVitalsInstrumentation).toHaveBeenCalled();
|
|
85
90
|
expect(startErrorInstrumentation).toHaveBeenCalled();
|
|
86
91
|
expect(instrumentFetch).toHaveBeenCalled();
|
|
92
|
+
expect(instrumentXhr).toHaveBeenCalled();
|
|
87
93
|
});
|
|
88
94
|
|
|
89
95
|
const instrumentations: InstrumentationName[] = [
|
|
@@ -91,12 +97,14 @@ describe("init", () => {
|
|
|
91
97
|
"@dash0/web-vitals",
|
|
92
98
|
"@dash0/error",
|
|
93
99
|
"@dash0/fetch",
|
|
100
|
+
"@dash0/xhr",
|
|
94
101
|
];
|
|
95
102
|
const instrumentationMocks = {
|
|
96
103
|
"@dash0/navigation": startNavigationInstrumentation,
|
|
97
104
|
"@dash0/web-vitals": startWebVitalsInstrumentation,
|
|
98
105
|
"@dash0/error": startErrorInstrumentation,
|
|
99
106
|
"@dash0/fetch": instrumentFetch,
|
|
107
|
+
"@dash0/xhr": instrumentXhr,
|
|
100
108
|
};
|
|
101
109
|
|
|
102
110
|
instrumentations.forEach((instrumentation) => {
|
|
@@ -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)`);
|
package/src/entrypoint/script.ts
CHANGED
|
@@ -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,27 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
perf,
|
|
5
|
-
win,
|
|
6
|
-
setTimeout,
|
|
7
|
-
isSameOrigin,
|
|
8
|
-
wrap,
|
|
9
|
-
parseUrl,
|
|
10
|
-
clearTimeout,
|
|
11
|
-
} from "../../utils";
|
|
12
|
-
import { isUrlIgnored, matchesAny } from "../../utils/ignore-rules";
|
|
13
|
-
import {
|
|
14
|
-
addAttribute,
|
|
15
|
-
setSpanStatus,
|
|
16
|
-
addW3CTraceContextHttpHeaders,
|
|
17
|
-
addXRayTraceContextHttpHeaders,
|
|
18
|
-
endSpan,
|
|
19
|
-
errorToSpanStatus,
|
|
20
|
-
Exception,
|
|
21
|
-
InProgressSpan,
|
|
22
|
-
recordException,
|
|
23
|
-
startSpan,
|
|
24
|
-
} from "../../utils/otel";
|
|
1
|
+
import { debug, observeResourcePerformance, perf, win, setTimeout, wrap, parseUrl, clearTimeout } from "../../utils";
|
|
2
|
+
import { isUrlIgnored } from "../../utils/ignore-rules";
|
|
3
|
+
import { addAttribute, endSpan, setSpanStatus, Exception, InProgressSpan, startSpan } from "../../utils/otel";
|
|
25
4
|
import {
|
|
26
5
|
ERROR_TYPE,
|
|
27
6
|
HTTP_REQUEST_METHOD,
|
|
@@ -29,12 +8,20 @@ import {
|
|
|
29
8
|
HTTP_RESPONSE_STATUS_CODE,
|
|
30
9
|
SPAN_STATUS_ERROR,
|
|
31
10
|
SPAN_STATUS_UNSET,
|
|
32
|
-
WEB_REQUEST_CANCELLED,
|
|
33
11
|
} from "../../semantic-conventions";
|
|
34
|
-
import { vars
|
|
12
|
+
import { vars } from "../../vars";
|
|
35
13
|
import { httpRequestHeaderKey, httpResponseHeaderKey } from "../../utils/otel/http";
|
|
36
14
|
import { sendSpan } from "../../transport";
|
|
37
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
addResourceNetworkEvents,
|
|
17
|
+
addResourceSize,
|
|
18
|
+
addTraceContextHttpHeaders,
|
|
19
|
+
determinePropagatorTypes,
|
|
20
|
+
endSpanOnAbort,
|
|
21
|
+
endSpanOnError,
|
|
22
|
+
HTTP_METHOD_OTHER,
|
|
23
|
+
isWellKnownHttpMethod,
|
|
24
|
+
} from "./utils";
|
|
38
25
|
import { addCommonAttributes, addUrlAttributes } from "../../attributes";
|
|
39
26
|
|
|
40
27
|
export function instrumentFetch() {
|
|
@@ -45,83 +32,53 @@ export function instrumentFetch() {
|
|
|
45
32
|
wrap(win, "fetch", wrapFetch);
|
|
46
33
|
}
|
|
47
34
|
|
|
35
|
+
type FetchInstrumentation = {
|
|
36
|
+
copyOfInit?: RequestInit;
|
|
37
|
+
span: InProgressSpan;
|
|
38
|
+
performanceObserver: ReturnType<typeof observeResourcePerformance>;
|
|
39
|
+
};
|
|
40
|
+
|
|
48
41
|
// eslint-disable-next-line no-restricted-globals -- only used as type here
|
|
49
42
|
function wrapFetch(original: typeof fetch) {
|
|
50
43
|
return async function fetchWithInstrumentation(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
51
|
-
let
|
|
52
|
-
|
|
53
|
-
let
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
copyOfInit.body = undefined;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const request = new Request(input, copyOfInit);
|
|
60
|
-
if (body && copyOfInit) {
|
|
61
|
-
copyOfInit.body = body;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const url = request.url;
|
|
65
|
-
if (isUrlIgnored(url)) {
|
|
66
|
-
debug(`Not creating span for fetch call because the url is ignored, URL: ${url}`);
|
|
67
|
-
return original(input instanceof Request ? request : input, init);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// https://fetch.spec.whatwg.org/#concept-request-method
|
|
71
|
-
// We'll match methods case insensitive here to make the user experience a bit less painful
|
|
72
|
-
const originalMethod = request.method ?? "GET";
|
|
73
|
-
const isWellKnownMethod = isWellKnownHttpMethod(originalMethod);
|
|
74
|
-
const isWellKnownMethodMatchingLeniently = isWellKnownHttpMethod(originalMethod.toUpperCase());
|
|
75
|
-
const method = isWellKnownMethodMatchingLeniently ? originalMethod.toUpperCase() : HTTP_METHOD_OTHER;
|
|
44
|
+
let fetchInput: RequestInfo | URL = input;
|
|
45
|
+
let request: Request | undefined;
|
|
46
|
+
let instrumentation: FetchInstrumentation | undefined;
|
|
47
|
+
try {
|
|
48
|
+
let copyOfInit = init ? Object.assign({}, init) : init;
|
|
76
49
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (!isWellKnownMethod) {
|
|
83
|
-
addAttribute(span.attributes, HTTP_REQUEST_METHOD_ORIGINAL, originalMethod);
|
|
84
|
-
}
|
|
50
|
+
let body: BodyInit | null = null;
|
|
51
|
+
if (copyOfInit?.body) {
|
|
52
|
+
body = copyOfInit.body;
|
|
53
|
+
copyOfInit.body = undefined;
|
|
54
|
+
}
|
|
85
55
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
addTraceContextHttpHeaders(copyOfInit.headers.append, copyOfInit.headers, span, propagatorTypes);
|
|
56
|
+
request = new Request(input, copyOfInit);
|
|
57
|
+
if (body && copyOfInit) {
|
|
58
|
+
copyOfInit.body = body;
|
|
59
|
+
}
|
|
60
|
+
// Constructing the Request above disturbs the body of a Request input, so from here on the
|
|
61
|
+
// copy has to be handed to the original fetch in place of the input -- including on the
|
|
62
|
+
// ignored and instrumentation-failure paths below.
|
|
63
|
+
fetchInput = input instanceof Request ? request : input;
|
|
64
|
+
|
|
65
|
+
if (isUrlIgnored(request.url)) {
|
|
66
|
+
debug(`Not creating span for fetch call because the url is ignored, URL: ${request.url}`);
|
|
67
|
+
// Note: the rejection of the returned promise does not route through the catch below --
|
|
68
|
+
// only synchronous throws do, so the original fetch cannot be invoked twice.
|
|
69
|
+
return original(fetchInput, init);
|
|
101
70
|
}
|
|
102
|
-
}
|
|
103
71
|
|
|
104
|
-
|
|
72
|
+
instrumentation = onFetchStart(input, init, request, copyOfInit);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
debug("failed to instrument fetch call", e);
|
|
75
|
+
return original(fetchInput, init);
|
|
76
|
+
}
|
|
105
77
|
|
|
106
|
-
const performanceObserver =
|
|
107
|
-
// We match on both fetch and XHR here to support polyfills
|
|
108
|
-
resourceMatcher: ({ initiatorType, name }) =>
|
|
109
|
-
(initiatorType === "fetch" || initiatorType === "xmlhttprequest") && name === parseUrl(url).href,
|
|
110
|
-
maxWaitForResourceMillis: vars.maxWaitForResourceTimingsMillis,
|
|
111
|
-
maxToleranceForResourceTimingsMillis: vars.maxToleranceForResourceTimingsMillis,
|
|
112
|
-
onEnd: ({ duration, resource }) => {
|
|
113
|
-
if (resource) {
|
|
114
|
-
addResourceNetworkEvents(span, resource);
|
|
115
|
-
addResourceSize(span, resource);
|
|
116
|
-
}
|
|
117
|
-
// duration is millis we need to convert to nanos
|
|
118
|
-
sendSpan(endSpan(span, undefined, duration * 1000000));
|
|
119
|
-
},
|
|
120
|
-
});
|
|
78
|
+
const { copyOfInit, span, performanceObserver } = instrumentation;
|
|
121
79
|
|
|
122
|
-
performanceObserver.start();
|
|
123
80
|
try {
|
|
124
|
-
const origResponse = await original(
|
|
81
|
+
const origResponse = await original(fetchInput, copyOfInit);
|
|
125
82
|
addResponseData(span, origResponse);
|
|
126
83
|
|
|
127
84
|
return wrapResponse(
|
|
@@ -130,7 +87,7 @@ function wrapFetch(original: typeof fetch) {
|
|
|
130
87
|
() => performanceObserver.end(),
|
|
131
88
|
(e) => {
|
|
132
89
|
performanceObserver.cancel();
|
|
133
|
-
if (request
|
|
90
|
+
if (request?.signal?.aborted) {
|
|
134
91
|
endSpanOnAbort(span);
|
|
135
92
|
} else {
|
|
136
93
|
endSpanOnError(span, e);
|
|
@@ -139,7 +96,7 @@ function wrapFetch(original: typeof fetch) {
|
|
|
139
96
|
);
|
|
140
97
|
} catch (e) {
|
|
141
98
|
performanceObserver.cancel();
|
|
142
|
-
if (request
|
|
99
|
+
if (request?.signal?.aborted) {
|
|
143
100
|
endSpanOnAbort(span);
|
|
144
101
|
} else {
|
|
145
102
|
endSpanOnError(span, e as Exception);
|
|
@@ -149,6 +106,71 @@ function wrapFetch(original: typeof fetch) {
|
|
|
149
106
|
};
|
|
150
107
|
}
|
|
151
108
|
|
|
109
|
+
function onFetchStart(
|
|
110
|
+
input: RequestInfo | URL,
|
|
111
|
+
init: RequestInit | undefined,
|
|
112
|
+
request: Request,
|
|
113
|
+
copyOfInit: RequestInit | undefined
|
|
114
|
+
): FetchInstrumentation {
|
|
115
|
+
const url = request.url;
|
|
116
|
+
|
|
117
|
+
// https://fetch.spec.whatwg.org/#concept-request-method
|
|
118
|
+
// We'll match methods case insensitive here to make the user experience a bit less painful
|
|
119
|
+
const originalMethod = request.method ?? "GET";
|
|
120
|
+
const isWellKnownMethod = isWellKnownHttpMethod(originalMethod);
|
|
121
|
+
const isWellKnownMethodMatchingLeniently = isWellKnownHttpMethod(originalMethod.toUpperCase());
|
|
122
|
+
const method = isWellKnownMethodMatchingLeniently ? originalMethod.toUpperCase() : HTTP_METHOD_OTHER;
|
|
123
|
+
|
|
124
|
+
const span = startSpan(`HTTP ${method}`);
|
|
125
|
+
addCommonAttributes(span.attributes);
|
|
126
|
+
addUrlAttributes(span.attributes, url);
|
|
127
|
+
addGraphQlProperties(input, init, span);
|
|
128
|
+
addAttribute(span.attributes, HTTP_REQUEST_METHOD, method);
|
|
129
|
+
if (!isWellKnownMethod) {
|
|
130
|
+
addAttribute(span.attributes, HTTP_REQUEST_METHOD_ORIGINAL, originalMethod);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const propagatorTypes = determinePropagatorTypes(url);
|
|
134
|
+
const shouldSetCorrelationHeaders = propagatorTypes.length > 0;
|
|
135
|
+
if (shouldSetCorrelationHeaders) {
|
|
136
|
+
if (copyOfInit?.headers) {
|
|
137
|
+
// ensure we have a unified container for the headers
|
|
138
|
+
copyOfInit.headers = new Headers(copyOfInit.headers);
|
|
139
|
+
addTraceContextHttpHeaders(copyOfInit.headers.append, copyOfInit.headers, span, propagatorTypes);
|
|
140
|
+
} else if (input instanceof Request) {
|
|
141
|
+
addTraceContextHttpHeaders(request.headers.append, request.headers, span, propagatorTypes);
|
|
142
|
+
} else {
|
|
143
|
+
if (!copyOfInit) {
|
|
144
|
+
copyOfInit = {};
|
|
145
|
+
}
|
|
146
|
+
copyOfInit.headers = new Headers();
|
|
147
|
+
addTraceContextHttpHeaders(copyOfInit.headers.append, copyOfInit.headers, span, propagatorTypes);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
tryCaptureHttpHeaders(request.headers, span, (k) => httpRequestHeaderKey(k));
|
|
152
|
+
|
|
153
|
+
const performanceObserver = observeResourcePerformance({
|
|
154
|
+
// We match on both fetch and XHR here to support polyfills
|
|
155
|
+
resourceMatcher: ({ initiatorType, name }) =>
|
|
156
|
+
(initiatorType === "fetch" || initiatorType === "xmlhttprequest") && name === parseUrl(url).href,
|
|
157
|
+
maxWaitForResourceMillis: vars.maxWaitForResourceTimingsMillis,
|
|
158
|
+
maxToleranceForResourceTimingsMillis: vars.maxToleranceForResourceTimingsMillis,
|
|
159
|
+
onEnd: ({ duration, resource }) => {
|
|
160
|
+
if (resource) {
|
|
161
|
+
addResourceNetworkEvents(span, resource);
|
|
162
|
+
addResourceSize(span, resource);
|
|
163
|
+
}
|
|
164
|
+
// duration is millis we need to convert to nanos
|
|
165
|
+
sendSpan(endSpan(span, undefined, duration * 1000000));
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
performanceObserver.start();
|
|
170
|
+
|
|
171
|
+
return { copyOfInit, span, performanceObserver };
|
|
172
|
+
}
|
|
173
|
+
|
|
152
174
|
// @ts-expect-error -- WIP
|
|
153
175
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- WIP
|
|
154
176
|
function addGraphQlProperties(input: RequestInfo | URL, init?: RequestInit, span: InProgressSpan) {
|
|
@@ -182,8 +204,8 @@ function tryCaptureHttpHeaders(headers: Headers, span: InProgressSpan, getAttrib
|
|
|
182
204
|
addAttribute(span.attributes, getAttributeKey(key), value);
|
|
183
205
|
}
|
|
184
206
|
});
|
|
185
|
-
} catch (
|
|
186
|
-
debug("unable to capture http headers
|
|
207
|
+
} catch (e) {
|
|
208
|
+
debug("unable to capture http headers", e);
|
|
187
209
|
}
|
|
188
210
|
}
|
|
189
211
|
|
|
@@ -281,67 +303,6 @@ function wrapResponse(
|
|
|
281
303
|
});
|
|
282
304
|
}
|
|
283
305
|
|
|
284
|
-
function endSpanOnError(span: InProgressSpan, error: Exception) {
|
|
285
|
-
recordException(span, error);
|
|
286
|
-
sendSpan(endSpan(span, errorToSpanStatus(error), undefined));
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function endSpanOnAbort(span: InProgressSpan) {
|
|
290
|
-
addAttribute(span.attributes, WEB_REQUEST_CANCELLED, true);
|
|
291
|
-
sendSpan(endSpan(span, undefined, undefined));
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function determinePropagatorTypes(url: string): PropagatorType[] {
|
|
295
|
-
const matchingTypes: PropagatorType[] = [];
|
|
296
|
-
const isUrlSameOrigin = isSameOrigin(url);
|
|
297
|
-
|
|
298
|
-
// For same-origin requests, always include traceparent + all configured propagators
|
|
299
|
-
if (isUrlSameOrigin) {
|
|
300
|
-
// Always add traceparent for same-origin requests
|
|
301
|
-
matchingTypes.push("traceparent");
|
|
302
|
-
|
|
303
|
-
// Add all other configured propagator types for same-origin requests
|
|
304
|
-
if (vars.propagators) {
|
|
305
|
-
for (const propagator of vars.propagators) {
|
|
306
|
-
if (propagator.type !== "traceparent" && !matchingTypes.includes(propagator.type)) {
|
|
307
|
-
matchingTypes.push(propagator.type);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
return matchingTypes;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// For cross-origin requests, use new propagators config if available
|
|
315
|
-
if (vars.propagators) {
|
|
316
|
-
for (const propagator of vars.propagators) {
|
|
317
|
-
if (matchesAny(propagator.match, url)) {
|
|
318
|
-
// Avoid duplicates
|
|
319
|
-
if (!matchingTypes.includes(propagator.type)) {
|
|
320
|
-
matchingTypes.push(propagator.type);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
return matchingTypes;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
return [];
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function addTraceContextHttpHeaders(
|
|
331
|
-
fn: (name: string, value: string) => void,
|
|
332
|
-
ctx: unknown,
|
|
333
|
-
span: InProgressSpan,
|
|
334
|
-
types: PropagatorType[]
|
|
335
|
-
) {
|
|
336
|
-
for (const type of types) {
|
|
337
|
-
if (type === "xray") {
|
|
338
|
-
addXRayTraceContextHttpHeaders(fn, ctx, span);
|
|
339
|
-
} else {
|
|
340
|
-
addW3CTraceContextHttpHeaders(fn, ctx, span);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
306
|
function responseCanHaveBody(response: Response) {
|
|
346
307
|
const status = response.status;
|
|
347
308
|
return status >= 200 && status != 204 && status != 205 && status != 304;
|