@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.
Files changed (53) hide show
  1. package/README.md +1 -1
  2. package/dist/dash0.iife.js +1 -1
  3. package/dist/dash0.iife.js.map +1 -1
  4. package/dist/dash0.js +1 -1
  5. package/dist/dash0.js.map +1 -1
  6. package/dist/dash0.umd.cjs +1 -1
  7. package/dist/dash0.umd.cjs.map +1 -1
  8. package/dist/modules/api/init.js +4 -0
  9. package/dist/modules/api/init_test.js +7 -0
  10. package/dist/modules/api/start-view.js +47 -0
  11. package/dist/modules/api/start-view_test.js +91 -0
  12. package/dist/modules/entrypoint/npm-package.js +1 -0
  13. package/dist/modules/entrypoint/npm-package_test.js +7 -0
  14. package/dist/modules/entrypoint/script.js +2 -0
  15. package/dist/modules/instrumentations/http/fetch.js +90 -119
  16. package/dist/modules/instrumentations/http/fetch_test.js +47 -0
  17. package/dist/modules/instrumentations/http/utils.js +67 -3
  18. package/dist/modules/instrumentations/http/xhr.js +340 -0
  19. package/dist/modules/instrumentations/http/xhr_test.js +705 -0
  20. package/dist/modules/instrumentations/navigation/event.js +42 -10
  21. package/dist/modules/instrumentations/navigation/event_test.js +128 -0
  22. package/dist/modules/utils/wrap.js +16 -3
  23. package/dist/modules/utils/wrap_test.js +31 -0
  24. package/dist/tsconfig.tsbuildinfo +1 -1
  25. package/dist/types/api/start-view.d.ts +33 -0
  26. package/dist/types/api/start-view_test.d.ts +1 -0
  27. package/dist/types/entrypoint/npm-package.d.ts +2 -0
  28. package/dist/types/entrypoint/npm-package_test.d.ts +1 -0
  29. package/dist/types/instrumentations/http/utils.d.ts +6 -1
  30. package/dist/types/instrumentations/http/xhr.d.ts +1 -0
  31. package/dist/types/instrumentations/http/xhr_test.d.ts +1 -0
  32. package/dist/types/instrumentations/navigation/event.d.ts +18 -0
  33. package/dist/types/instrumentations/navigation/event_test.d.ts +1 -0
  34. package/dist/types/types/options.d.ts +1 -1
  35. package/dist/types/utils/wrap_test.d.ts +1 -0
  36. package/package.json +3 -2
  37. package/src/api/init.ts +4 -0
  38. package/src/api/init_test.ts +8 -0
  39. package/src/api/start-view.ts +68 -0
  40. package/src/api/start-view_test.ts +125 -0
  41. package/src/entrypoint/npm-package.ts +2 -0
  42. package/src/entrypoint/npm-package_test.ts +8 -0
  43. package/src/entrypoint/script.ts +2 -0
  44. package/src/instrumentations/http/fetch.ts +120 -159
  45. package/src/instrumentations/http/fetch_test.ts +58 -0
  46. package/src/instrumentations/http/utils.ts +90 -3
  47. package/src/instrumentations/http/xhr.ts +431 -0
  48. package/src/instrumentations/http/xhr_test.ts +850 -0
  49. package/src/instrumentations/navigation/event.ts +63 -15
  50. package/src/instrumentations/navigation/event_test.ts +171 -0
  51. package/src/types/options.ts +6 -1
  52. package/src/utils/wrap.ts +15 -3
  53. package/src/utils/wrap_test.ts +41 -0
@@ -6,6 +6,7 @@ import { startWebVitalsInstrumentation } from "../instrumentations/web-vitals";
6
6
  import { startErrorInstrumentation } from "../instrumentations/errors";
7
7
  import { addAttribute } from "../utils/otel";
8
8
  import { instrumentFetch } from "../instrumentations/http/fetch";
9
+ import { instrumentXhr } from "../instrumentations/http/xhr";
9
10
  import { startNavigationInstrumentation } from "../instrumentations/navigation";
10
11
  import { initializeTabId } from "../utils/tab-id";
11
12
  import { pickFirstString } from "./browser-env";
@@ -77,6 +78,9 @@ export function init(opts) {
77
78
  if (isInstrumentationEnabled("@dash0/fetch", opts)) {
78
79
  instrumentFetch();
79
80
  }
81
+ if (isInstrumentationEnabled("@dash0/xhr", opts)) {
82
+ instrumentXhr();
83
+ }
80
84
  hasBeenInitialised = true;
81
85
  }
82
86
  function initializeResourceAttributes(opts) {
@@ -10,6 +10,9 @@ vi.mock("../instrumentations/errors", () => ({
10
10
  vi.mock("../instrumentations/http/fetch", () => ({
11
11
  instrumentFetch: vi.fn(),
12
12
  }));
13
+ vi.mock("../instrumentations/http/xhr", () => ({
14
+ instrumentXhr: vi.fn(),
15
+ }));
13
16
  vi.mock("../instrumentations/navigation", () => ({
14
17
  startNavigationInstrumentation: vi.fn(),
15
18
  }));
@@ -23,6 +26,7 @@ vi.mock("../utils", async () => {
23
26
  });
24
27
  import { startErrorInstrumentation } from "../instrumentations/errors";
25
28
  import { instrumentFetch } from "../instrumentations/http/fetch";
29
+ import { instrumentXhr } from "../instrumentations/http/xhr";
26
30
  import { startNavigationInstrumentation } from "../instrumentations/navigation";
27
31
  import { startWebVitalsInstrumentation } from "../instrumentations/web-vitals";
28
32
  describe("init", () => {
@@ -57,18 +61,21 @@ describe("init", () => {
57
61
  expect(startWebVitalsInstrumentation).toHaveBeenCalled();
58
62
  expect(startErrorInstrumentation).toHaveBeenCalled();
59
63
  expect(instrumentFetch).toHaveBeenCalled();
64
+ expect(instrumentXhr).toHaveBeenCalled();
60
65
  });
61
66
  const instrumentations = [
62
67
  "@dash0/navigation",
63
68
  "@dash0/web-vitals",
64
69
  "@dash0/error",
65
70
  "@dash0/fetch",
71
+ "@dash0/xhr",
66
72
  ];
67
73
  const instrumentationMocks = {
68
74
  "@dash0/navigation": startNavigationInstrumentation,
69
75
  "@dash0/web-vitals": startWebVitalsInstrumentation,
70
76
  "@dash0/error": startErrorInstrumentation,
71
77
  "@dash0/fetch": instrumentFetch,
78
+ "@dash0/xhr": instrumentXhr,
72
79
  };
73
80
  instrumentations.forEach((instrumentation) => {
74
81
  it(`should enable ${instrumentation} instrumentation when present in enabledInstrumentations array`, async () => {
@@ -0,0 +1,47 @@
1
+ import { transmitManualPageViewEvent } from "../instrumentations/navigation/event";
2
+ import { debug, nowNanos, win } from "../utils";
3
+ import { vars } from "../vars";
4
+ /**
5
+ * Manually records a page view, side-effect free: this never calls `history.pushState` /
6
+ * `history.replaceState` and never mutates `location`. Intended for single-page applications
7
+ * that own their own router and cannot let the SDK touch navigation state (e.g. Electron apps
8
+ * serving the whole app from one root URL, where automatic page-view tracking would report
9
+ * every screen as "/").
10
+ *
11
+ * The emitted event is indistinguishable from an automatic virtual page view downstream
12
+ * (same `browser.page_view` event name, same `type` value), with two differences: it is never
13
+ * accompanied by a `change_state` value, since no history mutation occurred, and the
14
+ * `pageViewInstrumentation`'s `generateMetadata` callback is not invoked for manual views —
15
+ * supply title and attributes directly instead.
16
+ *
17
+ * @param name The name of the view, e.g. "/settings". Transmitted as the page view's title.
18
+ * @param opts Additional page view details.
19
+ */
20
+ export function startView(name, opts) {
21
+ if (vars.endpoints.length === 0) {
22
+ debug("Dash0 SDK has not been initialized. Ignoring startView call.");
23
+ return;
24
+ }
25
+ // The script entrypoint forwards dash0("startView", ...) arguments without type checking,
26
+ // so malformed calls must degrade to a logged no-op instead of throwing. An uncaught throw
27
+ // here would abort the command-queue drain and drop all subsequently queued api calls.
28
+ if (typeof name !== "string" || name.length === 0) {
29
+ debug("startView requires a non-empty view name. Ignoring startView call.");
30
+ return;
31
+ }
32
+ let url;
33
+ if (opts?.url != null) {
34
+ try {
35
+ url = new URL(opts.url, win?.location.href);
36
+ }
37
+ catch (e) {
38
+ debug("Failed to parse startView url option. Falling back to the current location.", e);
39
+ }
40
+ }
41
+ transmitManualPageViewEvent({
42
+ timeUnixNano: nowNanos(),
43
+ title: name,
44
+ url,
45
+ attributes: opts?.attributes,
46
+ });
47
+ }
@@ -0,0 +1,91 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ vi.mock("../transport", () => ({
3
+ sendLog: vi.fn(),
4
+ }));
5
+ import { sendLog } from "../transport";
6
+ import { startView } from "./start-view";
7
+ import { vars } from "../vars";
8
+ const sendLogMock = sendLog;
9
+ describe("startView", () => {
10
+ beforeEach(() => {
11
+ sendLogMock.mockClear();
12
+ vars.endpoints = [{ url: "https://example.com", authToken: "auth_abc123" }];
13
+ vars.pageViewInstrumentation = { trackVirtualPageViews: true, includeParts: [] };
14
+ });
15
+ afterEach(() => {
16
+ vi.clearAllMocks();
17
+ vars.endpoints = [];
18
+ });
19
+ it("accepts a string shorthand and uses it as the title", () => {
20
+ startView("/settings");
21
+ expect(sendLogMock).toHaveBeenCalledTimes(1);
22
+ const log = sendLogMock.mock.calls[0][0];
23
+ const bodyValues = log.body?.kvlistValue?.values;
24
+ expect(bodyValues).toEqual(expect.arrayContaining([{ key: "title", value: { stringValue: "/settings" } }]));
25
+ });
26
+ it("accepts an options object with attributes", () => {
27
+ startView("/settings", { attributes: { "app.screen": "settings" } });
28
+ const log = sendLogMock.mock.calls[0][0];
29
+ const bodyValues = log.body?.kvlistValue?.values;
30
+ expect(bodyValues).toEqual(expect.arrayContaining([{ key: "title", value: { stringValue: "/settings" } }]));
31
+ expect(log.attributes).toEqual(expect.arrayContaining([{ key: "app.screen", value: { stringValue: "settings" } }]));
32
+ });
33
+ it("parses a relative url option and reflects it in page.url.path", () => {
34
+ startView("/settings", { url: "/settings" });
35
+ const log = sendLogMock.mock.calls[0][0];
36
+ expect(log.attributes).toEqual(expect.arrayContaining([{ key: "page.url.path", value: { stringValue: "/settings" } }]));
37
+ });
38
+ it("falls back to the current location on an invalid url", () => {
39
+ startView("/settings", { url: "http://" });
40
+ expect(sendLogMock).toHaveBeenCalledTimes(1);
41
+ const log = sendLogMock.mock.calls[0][0];
42
+ expect(log.attributes).toEqual(expect.arrayContaining([
43
+ // eslint-disable-next-line no-restricted-globals
44
+ { key: "page.url.full", value: { stringValue: window.location.href } },
45
+ // eslint-disable-next-line no-restricted-globals
46
+ { key: "page.url.domain", value: { stringValue: window.location.hostname } },
47
+ ]));
48
+ });
49
+ it("resolves an absolute cross-origin url override", () => {
50
+ startView("/settings", { url: "https://other-origin.example/path" });
51
+ const log = sendLogMock.mock.calls[0][0];
52
+ expect(log.attributes).toEqual(expect.arrayContaining([
53
+ { key: "page.url.full", value: { stringValue: "https://other-origin.example/path" } },
54
+ { key: "page.url.domain", value: { stringValue: "other-origin.example" } },
55
+ { key: "page.url.path", value: { stringValue: "/path" } },
56
+ ]));
57
+ });
58
+ it("does not touch history or location", () => {
59
+ // eslint-disable-next-line no-restricted-globals
60
+ const originalHref = window.location.href;
61
+ // eslint-disable-next-line no-restricted-globals
62
+ const originalLength = window.history.length;
63
+ startView("/settings");
64
+ // eslint-disable-next-line no-restricted-globals
65
+ expect(window.location.href).toBe(originalHref);
66
+ // eslint-disable-next-line no-restricted-globals
67
+ expect(window.history.length).toBe(originalLength);
68
+ });
69
+ it("ignores calls without a name, as they can arrive via the untyped script entrypoint", () => {
70
+ // @ts-expect-error deliberately calling without arguments, mirroring dash0("startView")
71
+ startView();
72
+ startView(undefined);
73
+ startView(null);
74
+ expect(sendLogMock).not.toHaveBeenCalled();
75
+ });
76
+ it("ignores calls with a non-string or empty name", () => {
77
+ startView(123);
78
+ startView({ name: "/settings" });
79
+ startView("");
80
+ expect(sendLogMock).not.toHaveBeenCalled();
81
+ });
82
+ it("tolerates a nullish options argument", () => {
83
+ startView("/settings", null);
84
+ expect(sendLogMock).toHaveBeenCalledTimes(1);
85
+ });
86
+ it("is a no-op before init (no endpoints configured)", () => {
87
+ vars.endpoints = [];
88
+ startView("/settings");
89
+ expect(sendLogMock).not.toHaveBeenCalled();
90
+ });
91
+ });
@@ -7,6 +7,7 @@ export * from "../api/events";
7
7
  export * from "../api/log-level";
8
8
  export { terminateSession } from "../api/session";
9
9
  export { reportError } from "../api/report-error";
10
+ export { startView } from "../api/start-view";
10
11
  export function init(opts) {
11
12
  debug(`${INIT_MESSAGE} (via package)`);
12
13
  initApi(opts);
@@ -0,0 +1,7 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import * as npmPackage from "./npm-package";
3
+ describe("npm-package entrypoint", () => {
4
+ it("exports startView", () => {
5
+ expect(typeof npmPackage.startView).toBe("function");
6
+ });
7
+ });
@@ -7,6 +7,7 @@ import { terminateSession } from "../api/session";
7
7
  import { addSignalAttribute, removeSignalAttribute } from "../api/attributes";
8
8
  import { sendEvent } from "../api/events";
9
9
  import { setActiveLogLevel } from "../api/log-level";
10
+ import { startView } from "../api/start-view";
10
11
  /**
11
12
  * All the APIs exposed through the script tag via `dash0('{{api name}}')`
12
13
  */
@@ -20,6 +21,7 @@ const scriptApis = {
20
21
  removeSignalAttribute,
21
22
  setActiveLogLevel,
22
23
  sendEvent,
24
+ startView,
23
25
  };
24
26
  init();
25
27
  function init() {
@@ -1,11 +1,11 @@
1
- import { debug, observeResourcePerformance, perf, win, setTimeout, isSameOrigin, wrap, parseUrl, clearTimeout, } from "../../utils";
2
- import { isUrlIgnored, matchesAny } from "../../utils/ignore-rules";
3
- import { addAttribute, setSpanStatus, addW3CTraceContextHttpHeaders, addXRayTraceContextHttpHeaders, endSpan, errorToSpanStatus, recordException, startSpan, } from "../../utils/otel";
4
- import { ERROR_TYPE, HTTP_REQUEST_METHOD, HTTP_REQUEST_METHOD_ORIGINAL, HTTP_RESPONSE_STATUS_CODE, SPAN_STATUS_ERROR, SPAN_STATUS_UNSET, WEB_REQUEST_CANCELLED, } from "../../semantic-conventions";
1
+ import { debug, observeResourcePerformance, perf, win, setTimeout, wrap, parseUrl, clearTimeout } from "../../utils";
2
+ import { isUrlIgnored } from "../../utils/ignore-rules";
3
+ import { addAttribute, endSpan, setSpanStatus, startSpan } from "../../utils/otel";
4
+ import { ERROR_TYPE, HTTP_REQUEST_METHOD, HTTP_REQUEST_METHOD_ORIGINAL, HTTP_RESPONSE_STATUS_CODE, SPAN_STATUS_ERROR, SPAN_STATUS_UNSET, } from "../../semantic-conventions";
5
5
  import { vars } from "../../vars";
6
6
  import { httpRequestHeaderKey, httpResponseHeaderKey } from "../../utils/otel/http";
7
7
  import { sendSpan } from "../../transport";
8
- import { addResourceNetworkEvents, addResourceSize, HTTP_METHOD_OTHER, isWellKnownHttpMethod } from "./utils";
8
+ import { addResourceNetworkEvents, addResourceSize, addTraceContextHttpHeaders, determinePropagatorTypes, endSpanOnAbort, endSpanOnError, HTTP_METHOD_OTHER, isWellKnownHttpMethod, } from "./utils";
9
9
  import { addCommonAttributes, addUrlAttributes } from "../../attributes";
10
10
  export function instrumentFetch() {
11
11
  if (!win || !win.fetch || !win.Request) {
@@ -17,76 +17,43 @@ export function instrumentFetch() {
17
17
  // eslint-disable-next-line no-restricted-globals -- only used as type here
18
18
  function wrapFetch(original) {
19
19
  return async function fetchWithInstrumentation(input, init) {
20
- let copyOfInit = init ? Object.assign({}, init) : init;
21
- let body = null;
22
- if (copyOfInit?.body) {
23
- body = copyOfInit.body;
24
- copyOfInit.body = undefined;
25
- }
26
- const request = new Request(input, copyOfInit);
27
- if (body && copyOfInit) {
28
- copyOfInit.body = body;
29
- }
30
- const url = request.url;
31
- if (isUrlIgnored(url)) {
32
- debug(`Not creating span for fetch call because the url is ignored, URL: ${url}`);
33
- return original(input instanceof Request ? request : input, init);
34
- }
35
- // https://fetch.spec.whatwg.org/#concept-request-method
36
- // We'll match methods case insensitive here to make the user experience a bit less painful
37
- const originalMethod = request.method ?? "GET";
38
- const isWellKnownMethod = isWellKnownHttpMethod(originalMethod);
39
- const isWellKnownMethodMatchingLeniently = isWellKnownHttpMethod(originalMethod.toUpperCase());
40
- const method = isWellKnownMethodMatchingLeniently ? originalMethod.toUpperCase() : HTTP_METHOD_OTHER;
41
- const span = startSpan(`HTTP ${method}`);
42
- addCommonAttributes(span.attributes);
43
- addUrlAttributes(span.attributes, url);
44
- addGraphQlProperties(input, init, span);
45
- addAttribute(span.attributes, HTTP_REQUEST_METHOD, method);
46
- if (!isWellKnownMethod) {
47
- addAttribute(span.attributes, HTTP_REQUEST_METHOD_ORIGINAL, originalMethod);
48
- }
49
- const propagatorTypes = determinePropagatorTypes(url);
50
- const shouldSetCorrelationHeaders = propagatorTypes.length > 0;
51
- if (shouldSetCorrelationHeaders) {
52
- if (copyOfInit?.headers) {
53
- // ensure we have a unified container for the headers
54
- copyOfInit.headers = new Headers(copyOfInit.headers);
55
- addTraceContextHttpHeaders(copyOfInit.headers.append, copyOfInit.headers, span, propagatorTypes);
20
+ let fetchInput = input;
21
+ let request;
22
+ let instrumentation;
23
+ try {
24
+ let copyOfInit = init ? Object.assign({}, init) : init;
25
+ let body = null;
26
+ if (copyOfInit?.body) {
27
+ body = copyOfInit.body;
28
+ copyOfInit.body = undefined;
56
29
  }
57
- else if (input instanceof Request) {
58
- addTraceContextHttpHeaders(request.headers.append, request.headers, span, propagatorTypes);
30
+ request = new Request(input, copyOfInit);
31
+ if (body && copyOfInit) {
32
+ copyOfInit.body = body;
59
33
  }
60
- else {
61
- if (!copyOfInit) {
62
- copyOfInit = {};
63
- }
64
- copyOfInit.headers = new Headers();
65
- addTraceContextHttpHeaders(copyOfInit.headers.append, copyOfInit.headers, span, propagatorTypes);
34
+ // Constructing the Request above disturbs the body of a Request input, so from here on the
35
+ // copy has to be handed to the original fetch in place of the input -- including on the
36
+ // ignored and instrumentation-failure paths below.
37
+ fetchInput = input instanceof Request ? request : input;
38
+ if (isUrlIgnored(request.url)) {
39
+ debug(`Not creating span for fetch call because the url is ignored, URL: ${request.url}`);
40
+ // Note: the rejection of the returned promise does not route through the catch below --
41
+ // only synchronous throws do, so the original fetch cannot be invoked twice.
42
+ return original(fetchInput, init);
66
43
  }
44
+ instrumentation = onFetchStart(input, init, request, copyOfInit);
67
45
  }
68
- tryCaptureHttpHeaders(request.headers, span, (k) => httpRequestHeaderKey(k));
69
- const performanceObserver = observeResourcePerformance({
70
- // We match on both fetch and XHR here to support polyfills
71
- resourceMatcher: ({ initiatorType, name }) => (initiatorType === "fetch" || initiatorType === "xmlhttprequest") && name === parseUrl(url).href,
72
- maxWaitForResourceMillis: vars.maxWaitForResourceTimingsMillis,
73
- maxToleranceForResourceTimingsMillis: vars.maxToleranceForResourceTimingsMillis,
74
- onEnd: ({ duration, resource }) => {
75
- if (resource) {
76
- addResourceNetworkEvents(span, resource);
77
- addResourceSize(span, resource);
78
- }
79
- // duration is millis we need to convert to nanos
80
- sendSpan(endSpan(span, undefined, duration * 1000000));
81
- },
82
- });
83
- performanceObserver.start();
46
+ catch (e) {
47
+ debug("failed to instrument fetch call", e);
48
+ return original(fetchInput, init);
49
+ }
50
+ const { copyOfInit, span, performanceObserver } = instrumentation;
84
51
  try {
85
- const origResponse = await original(input instanceof Request ? request : input, copyOfInit);
52
+ const origResponse = await original(fetchInput, copyOfInit);
86
53
  addResponseData(span, origResponse);
87
54
  return wrapResponse(origResponse, vars.maxToleranceForResourceTimingsMillis, () => performanceObserver.end(), (e) => {
88
55
  performanceObserver.cancel();
89
- if (request.signal?.aborted) {
56
+ if (request?.signal?.aborted) {
90
57
  endSpanOnAbort(span);
91
58
  }
92
59
  else {
@@ -96,7 +63,7 @@ function wrapFetch(original) {
96
63
  }
97
64
  catch (e) {
98
65
  performanceObserver.cancel();
99
- if (request.signal?.aborted) {
66
+ if (request?.signal?.aborted) {
100
67
  endSpanOnAbort(span);
101
68
  }
102
69
  else {
@@ -106,6 +73,59 @@ function wrapFetch(original) {
106
73
  }
107
74
  };
108
75
  }
76
+ function onFetchStart(input, init, request, copyOfInit) {
77
+ const url = request.url;
78
+ // https://fetch.spec.whatwg.org/#concept-request-method
79
+ // We'll match methods case insensitive here to make the user experience a bit less painful
80
+ const originalMethod = request.method ?? "GET";
81
+ const isWellKnownMethod = isWellKnownHttpMethod(originalMethod);
82
+ const isWellKnownMethodMatchingLeniently = isWellKnownHttpMethod(originalMethod.toUpperCase());
83
+ const method = isWellKnownMethodMatchingLeniently ? originalMethod.toUpperCase() : HTTP_METHOD_OTHER;
84
+ const span = startSpan(`HTTP ${method}`);
85
+ addCommonAttributes(span.attributes);
86
+ addUrlAttributes(span.attributes, url);
87
+ addGraphQlProperties(input, init, span);
88
+ addAttribute(span.attributes, HTTP_REQUEST_METHOD, method);
89
+ if (!isWellKnownMethod) {
90
+ addAttribute(span.attributes, HTTP_REQUEST_METHOD_ORIGINAL, originalMethod);
91
+ }
92
+ const propagatorTypes = determinePropagatorTypes(url);
93
+ const shouldSetCorrelationHeaders = propagatorTypes.length > 0;
94
+ if (shouldSetCorrelationHeaders) {
95
+ if (copyOfInit?.headers) {
96
+ // ensure we have a unified container for the headers
97
+ copyOfInit.headers = new Headers(copyOfInit.headers);
98
+ addTraceContextHttpHeaders(copyOfInit.headers.append, copyOfInit.headers, span, propagatorTypes);
99
+ }
100
+ else if (input instanceof Request) {
101
+ addTraceContextHttpHeaders(request.headers.append, request.headers, span, propagatorTypes);
102
+ }
103
+ else {
104
+ if (!copyOfInit) {
105
+ copyOfInit = {};
106
+ }
107
+ copyOfInit.headers = new Headers();
108
+ addTraceContextHttpHeaders(copyOfInit.headers.append, copyOfInit.headers, span, propagatorTypes);
109
+ }
110
+ }
111
+ tryCaptureHttpHeaders(request.headers, span, (k) => httpRequestHeaderKey(k));
112
+ const performanceObserver = observeResourcePerformance({
113
+ // We match on both fetch and XHR here to support polyfills
114
+ resourceMatcher: ({ initiatorType, name }) => (initiatorType === "fetch" || initiatorType === "xmlhttprequest") && name === parseUrl(url).href,
115
+ maxWaitForResourceMillis: vars.maxWaitForResourceTimingsMillis,
116
+ maxToleranceForResourceTimingsMillis: vars.maxToleranceForResourceTimingsMillis,
117
+ onEnd: ({ duration, resource }) => {
118
+ if (resource) {
119
+ addResourceNetworkEvents(span, resource);
120
+ addResourceSize(span, resource);
121
+ }
122
+ // duration is millis we need to convert to nanos
123
+ sendSpan(endSpan(span, undefined, duration * 1000000));
124
+ },
125
+ });
126
+ performanceObserver.start();
127
+ return { copyOfInit, span, performanceObserver };
128
+ }
109
129
  // @ts-expect-error -- WIP
110
130
  // eslint-disable-next-line @typescript-eslint/no-unused-vars -- WIP
111
131
  function addGraphQlProperties(input, init, span) {
@@ -140,8 +160,8 @@ function tryCaptureHttpHeaders(headers, span, getAttributeKey) {
140
160
  }
141
161
  });
142
162
  }
143
- catch (_e) {
144
- debug("unable to capture http headers due to CORS policy");
163
+ catch (e) {
164
+ debug("unable to capture http headers", e);
145
165
  }
146
166
  }
147
167
  function addResponseData(span, response) {
@@ -230,55 +250,6 @@ function wrapResponse(originalResponse, readTimeoutMs, onDone, onError) {
230
250
  headers: originalResponse.headers,
231
251
  });
232
252
  }
233
- function endSpanOnError(span, error) {
234
- recordException(span, error);
235
- sendSpan(endSpan(span, errorToSpanStatus(error), undefined));
236
- }
237
- function endSpanOnAbort(span) {
238
- addAttribute(span.attributes, WEB_REQUEST_CANCELLED, true);
239
- sendSpan(endSpan(span, undefined, undefined));
240
- }
241
- function determinePropagatorTypes(url) {
242
- const matchingTypes = [];
243
- const isUrlSameOrigin = isSameOrigin(url);
244
- // For same-origin requests, always include traceparent + all configured propagators
245
- if (isUrlSameOrigin) {
246
- // Always add traceparent for same-origin requests
247
- matchingTypes.push("traceparent");
248
- // Add all other configured propagator types for same-origin requests
249
- if (vars.propagators) {
250
- for (const propagator of vars.propagators) {
251
- if (propagator.type !== "traceparent" && !matchingTypes.includes(propagator.type)) {
252
- matchingTypes.push(propagator.type);
253
- }
254
- }
255
- }
256
- return matchingTypes;
257
- }
258
- // For cross-origin requests, use new propagators config if available
259
- if (vars.propagators) {
260
- for (const propagator of vars.propagators) {
261
- if (matchesAny(propagator.match, url)) {
262
- // Avoid duplicates
263
- if (!matchingTypes.includes(propagator.type)) {
264
- matchingTypes.push(propagator.type);
265
- }
266
- }
267
- }
268
- return matchingTypes;
269
- }
270
- return [];
271
- }
272
- function addTraceContextHttpHeaders(fn, ctx, span, types) {
273
- for (const type of types) {
274
- if (type === "xray") {
275
- addXRayTraceContextHttpHeaders(fn, ctx, span);
276
- }
277
- else {
278
- addW3CTraceContextHttpHeaders(fn, ctx, span);
279
- }
280
- }
281
- }
282
253
  function responseCanHaveBody(response) {
283
254
  const status = response.status;
284
255
  return status >= 200 && status != 204 && status != 205 && status != 304;
@@ -22,6 +22,7 @@ describe("fetch test", () => {
22
22
  afterEach(() => {
23
23
  vi.resetAllMocks();
24
24
  vars.propagators = undefined;
25
+ vars.ignoreUrls = [];
25
26
  });
26
27
  it("should inject traceparent header for cross-origin requests", async () => {
27
28
  vars.propagators = [
@@ -171,6 +172,27 @@ describe("fetch test", () => {
171
172
  expect(fetchHeaders.get("traceparent")).not.toBeNull();
172
173
  expect(fetchHeaders.get("X-Amzn-Trace-Id")).toBeNull();
173
174
  });
175
+ // SDK-internal errors (e.g. config typos) must degrade to an uninstrumented fetch call, never
176
+ // to a rejected promise the page did not cause.
177
+ it("falls back to an uninstrumented fetch when ignoreUrls contains plain strings instead of RegExps", async () => {
178
+ vars.ignoreUrls = ["/health"];
179
+ instrumentFetch();
180
+ // eslint-disable-next-line no-restricted-globals
181
+ await expect(fetch("http://localhost:3000/health")).resolves.toBeDefined();
182
+ expect(fetchMock).toHaveBeenCalledOnce();
183
+ expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:3000/health");
184
+ expect(fetchMock.mock.calls[0][1]).toBeUndefined();
185
+ expect(sendSpan).not.toHaveBeenCalled();
186
+ });
187
+ it("falls back to an uninstrumented fetch when a propagator match contains plain strings instead of RegExps", async () => {
188
+ vars.propagators = [{ type: "traceparent", match: ["http://foo.bar/"] }];
189
+ instrumentFetch();
190
+ // eslint-disable-next-line no-restricted-globals
191
+ await expect(fetch("http://foo.bar/foo")).resolves.toBeDefined();
192
+ expect(fetchMock).toHaveBeenCalledOnce();
193
+ expect(fetchMock.mock.calls[0][1]).toBeUndefined();
194
+ expect(sendSpan).not.toHaveBeenCalled();
195
+ });
174
196
  describe("aborted requests", () => {
175
197
  const sendSpanMock = sendSpan;
176
198
  const NativeRequest = Request;
@@ -268,6 +290,31 @@ describe("fetch test", () => {
268
290
  expect(span.status?.message).toBe("network down");
269
291
  expect(hasAttribute(span, "dash0.web.request.cancelled", { boolValue: true })).toBe(false);
270
292
  expect(span.events.some((e) => e.name === "exception")).toBe(true);
293
+ expect(hasAttribute(span, "error.type", { stringValue: "TypeError" })).toBe(true);
294
+ });
295
+ it("sets error.type from the exception name when reading the response body fails", async () => {
296
+ const body = new ReadableStream({
297
+ start(streamController) {
298
+ streamController.enqueue(new Uint8Array([0x68, 0x69]));
299
+ },
300
+ pull(streamController) {
301
+ streamController.error(new TypeError("network down"));
302
+ },
303
+ });
304
+ fetchMock.mockImplementation(() => Promise.resolve(new Response(body, { status: 200 })));
305
+ instrumentFetch();
306
+ // eslint-disable-next-line no-restricted-globals
307
+ const response = await fetch("http://localhost:3000/api/test");
308
+ const reader = response.body.getReader();
309
+ await reader.read();
310
+ await expect(reader.read()).rejects.toBeInstanceOf(TypeError);
311
+ expect(sendSpanMock).toHaveBeenCalledTimes(1);
312
+ const span = lastSpan();
313
+ expect(span.status?.code).toBe(2);
314
+ expect(span.status?.message).toBe("network down");
315
+ expect(hasAttribute(span, "dash0.web.request.cancelled", { boolValue: true })).toBe(false);
316
+ expect(span.events.some((e) => e.name === "exception")).toBe(true);
317
+ expect(hasAttribute(span, "error.type", { stringValue: "TypeError" })).toBe(true);
271
318
  });
272
319
  });
273
320
  });
@@ -1,6 +1,9 @@
1
- import { addAttribute, addSpanEvent } from "../../utils/otel";
2
- import { domHRTimestampToNanos, hasKey, PerformanceTimingNames } from "../../utils";
3
- import { HTTP_RESPONSE_BODY_SIZE } from "../../semantic-conventions";
1
+ import { addAttribute, addSpanEvent, addW3CTraceContextHttpHeaders, addXRayTraceContextHttpHeaders, endSpan, errorToSpanStatus, recordException, } from "../../utils/otel";
2
+ import { domHRTimestampToNanos, hasKey, isSameOrigin, PerformanceTimingNames } from "../../utils";
3
+ import { matchesAny } from "../../utils/ignore-rules";
4
+ import { ERROR_TYPE, HTTP_RESPONSE_BODY_SIZE, WEB_REQUEST_CANCELLED } from "../../semantic-conventions";
5
+ import { vars } from "../../vars";
6
+ import { sendSpan } from "../../transport";
4
7
  // SEE: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/attributes-registry/http.md?plain=1#L67
5
8
  const KNOWN_HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH"];
6
9
  export const HTTP_METHOD_OTHER = "_OTHER";
@@ -33,3 +36,64 @@ export function addResourceSize(span, resource) {
33
36
  addAttribute(span.attributes, HTTP_RESPONSE_BODY_SIZE, encodedLength);
34
37
  }
35
38
  }
39
+ // Sets error.type alongside the recorded exception so failed fetch and XHR spans are equally
40
+ // queryable by error.type. The value is the exception name (e.g. TypeError) -- XHR's synthetic
41
+ // failure exceptions carry their failure kind ("error"/"timeout") as the name, so both
42
+ // instrumentations converge here. Note the shapes still differ for cases inherent to the APIs:
43
+ // a fetch that resolves with status 0 (e.g. opaque responses) never reaches this function and
44
+ // instead gets error.type = response.type plus http.response.status_code "0".
45
+ export function endSpanOnError(span, error) {
46
+ recordException(span, error);
47
+ const errorType = typeof error === "object" && error ? (error.name ?? (error.code != null ? String(error.code) : "error")) : "error";
48
+ addAttribute(span.attributes, ERROR_TYPE, errorType);
49
+ sendSpan(endSpan(span, errorToSpanStatus(error), undefined));
50
+ }
51
+ // Cancellations are benign: no error status, no error.type. This also covers fetch calls aborted
52
+ // by AbortSignal.timeout() -- the signal is aborted by the time the rejection is handled, so a
53
+ // fetch timeout surfaces as a cancellation while an XHR timeout is an ERROR span with
54
+ // error.type = "timeout". That asymmetry is inherent to the two APIs.
55
+ export function endSpanOnAbort(span) {
56
+ addAttribute(span.attributes, WEB_REQUEST_CANCELLED, true);
57
+ sendSpan(endSpan(span, undefined, undefined));
58
+ }
59
+ export function determinePropagatorTypes(url) {
60
+ const matchingTypes = [];
61
+ const isUrlSameOrigin = isSameOrigin(url);
62
+ // For same-origin requests, always include traceparent + all configured propagators
63
+ if (isUrlSameOrigin) {
64
+ // Always add traceparent for same-origin requests
65
+ matchingTypes.push("traceparent");
66
+ // Add all other configured propagator types for same-origin requests
67
+ if (vars.propagators) {
68
+ for (const propagator of vars.propagators) {
69
+ if (propagator.type !== "traceparent" && !matchingTypes.includes(propagator.type)) {
70
+ matchingTypes.push(propagator.type);
71
+ }
72
+ }
73
+ }
74
+ return matchingTypes;
75
+ }
76
+ // For cross-origin requests, use new propagators config if available
77
+ if (vars.propagators) {
78
+ for (const propagator of vars.propagators) {
79
+ if (matchesAny(propagator.match, url)) {
80
+ // Avoid duplicates
81
+ if (!matchingTypes.includes(propagator.type)) {
82
+ matchingTypes.push(propagator.type);
83
+ }
84
+ }
85
+ }
86
+ return matchingTypes;
87
+ }
88
+ return [];
89
+ }
90
+ export function addTraceContextHttpHeaders(fn, ctx, span, types) {
91
+ for (const type of types) {
92
+ if (type === "xray") {
93
+ addXRayTraceContextHttpHeaders(fn, ctx, span);
94
+ }
95
+ else {
96
+ addW3CTraceContextHttpHeaders(fn, ctx, span);
97
+ }
98
+ }
99
+ }