@dash0/sdk-web 0.22.1 → 0.23.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/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/utils/wrap.js +16 -3
- package/dist/modules/utils/wrap_test.js +31 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- 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/types/options.d.ts +1 -1
- package/dist/types/utils/wrap_test.d.ts +1 -0
- package/package.json +2 -1
- package/src/api/init.ts +4 -0
- package/src/api/init_test.ts +8 -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/types/options.ts +6 -1
- package/src/utils/wrap.ts +15 -3
- package/src/utils/wrap_test.ts +41 -0
package/dist/modules/api/init.js
CHANGED
|
@@ -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 () => {
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { debug, observeResourcePerformance, perf, win, setTimeout,
|
|
2
|
-
import { isUrlIgnored
|
|
3
|
-
import { addAttribute,
|
|
4
|
-
import { ERROR_TYPE, HTTP_REQUEST_METHOD, HTTP_REQUEST_METHOD_ORIGINAL, HTTP_RESPONSE_STATUS_CODE, SPAN_STATUS_ERROR, SPAN_STATUS_UNSET,
|
|
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
|
|
21
|
-
let
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
copyOfInit
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
58
|
-
|
|
30
|
+
request = new Request(input, copyOfInit);
|
|
31
|
+
if (body && copyOfInit) {
|
|
32
|
+
copyOfInit.body = body;
|
|
59
33
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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(
|
|
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
|
|
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
|
|
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 (
|
|
144
|
-
debug("unable to capture http headers
|
|
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 {
|
|
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
|
+
}
|