@dash0/sdk-web 0.22.0 → 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 +25 -1
- 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 +5 -5
- package/src/api/init.ts +27 -1
- 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/src/api/init.ts
CHANGED
|
@@ -29,8 +29,8 @@ 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
|
-
import { merge } from "ts-deepmerge";
|
|
34
34
|
import { initializeTabId } from "../utils/tab-id";
|
|
35
35
|
import { InitOptions, InstrumentationName } from "../types/options";
|
|
36
36
|
import { BrowserBuildEnv, pickFirstString } from "./browser-env";
|
|
@@ -121,6 +121,9 @@ export function init(opts: InitOptions) {
|
|
|
121
121
|
if (isInstrumentationEnabled("@dash0/fetch", opts)) {
|
|
122
122
|
instrumentFetch();
|
|
123
123
|
}
|
|
124
|
+
if (isInstrumentationEnabled("@dash0/xhr", opts)) {
|
|
125
|
+
instrumentXhr();
|
|
126
|
+
}
|
|
124
127
|
|
|
125
128
|
hasBeenInitialised = true;
|
|
126
129
|
}
|
|
@@ -285,3 +288,26 @@ function isInstrumentationEnabled(name: InstrumentationName, opts: InitOptions):
|
|
|
285
288
|
|
|
286
289
|
return instrumentations.includes(name);
|
|
287
290
|
}
|
|
291
|
+
|
|
292
|
+
function merge<T extends Record<string, unknown>>(target: T, source: Partial<T>): T {
|
|
293
|
+
const result = { ...target };
|
|
294
|
+
for (const key of Object.keys(source) as Array<keyof T>) {
|
|
295
|
+
const srcVal = source[key];
|
|
296
|
+
const dstVal = target[key];
|
|
297
|
+
if (srcVal !== undefined) {
|
|
298
|
+
if (
|
|
299
|
+
srcVal !== null &&
|
|
300
|
+
typeof srcVal === "object" &&
|
|
301
|
+
!Array.isArray(srcVal) &&
|
|
302
|
+
typeof dstVal === "object" &&
|
|
303
|
+
dstVal !== null &&
|
|
304
|
+
!Array.isArray(dstVal)
|
|
305
|
+
) {
|
|
306
|
+
result[key] = { ...dstVal, ...srcVal } as T[keyof T];
|
|
307
|
+
} else {
|
|
308
|
+
result[key] = srcVal as T[keyof T];
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return result;
|
|
313
|
+
}
|
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) => {
|
|
@@ -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;
|
|
@@ -27,6 +27,7 @@ describe("fetch test", () => {
|
|
|
27
27
|
afterEach(() => {
|
|
28
28
|
vi.resetAllMocks();
|
|
29
29
|
vars.propagators = undefined;
|
|
30
|
+
vars.ignoreUrls = [];
|
|
30
31
|
});
|
|
31
32
|
|
|
32
33
|
it("should inject traceparent header for cross-origin requests", async () => {
|
|
@@ -195,6 +196,34 @@ describe("fetch test", () => {
|
|
|
195
196
|
expect(fetchHeaders.get("X-Amzn-Trace-Id")).toBeNull();
|
|
196
197
|
});
|
|
197
198
|
|
|
199
|
+
// SDK-internal errors (e.g. config typos) must degrade to an uninstrumented fetch call, never
|
|
200
|
+
// to a rejected promise the page did not cause.
|
|
201
|
+
|
|
202
|
+
it("falls back to an uninstrumented fetch when ignoreUrls contains plain strings instead of RegExps", async () => {
|
|
203
|
+
vars.ignoreUrls = ["/health"] as unknown as RegExp[];
|
|
204
|
+
instrumentFetch();
|
|
205
|
+
|
|
206
|
+
// eslint-disable-next-line no-restricted-globals
|
|
207
|
+
await expect(fetch("http://localhost:3000/health")).resolves.toBeDefined();
|
|
208
|
+
|
|
209
|
+
expect(fetchMock).toHaveBeenCalledOnce();
|
|
210
|
+
expect(fetchMock.mock.calls[0]![0]).toBe("http://localhost:3000/health");
|
|
211
|
+
expect(fetchMock.mock.calls[0]![1]).toBeUndefined();
|
|
212
|
+
expect(sendSpan).not.toHaveBeenCalled();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("falls back to an uninstrumented fetch when a propagator match contains plain strings instead of RegExps", async () => {
|
|
216
|
+
vars.propagators = [{ type: "traceparent", match: ["http://foo.bar/"] as unknown as RegExp[] }];
|
|
217
|
+
instrumentFetch();
|
|
218
|
+
|
|
219
|
+
// eslint-disable-next-line no-restricted-globals
|
|
220
|
+
await expect(fetch("http://foo.bar/foo")).resolves.toBeDefined();
|
|
221
|
+
|
|
222
|
+
expect(fetchMock).toHaveBeenCalledOnce();
|
|
223
|
+
expect(fetchMock.mock.calls[0]![1]).toBeUndefined();
|
|
224
|
+
expect(sendSpan).not.toHaveBeenCalled();
|
|
225
|
+
});
|
|
226
|
+
|
|
198
227
|
describe("aborted requests", () => {
|
|
199
228
|
const sendSpanMock = sendSpan as unknown as ReturnType<typeof vi.fn>;
|
|
200
229
|
const NativeRequest = Request;
|
|
@@ -309,6 +338,35 @@ describe("fetch test", () => {
|
|
|
309
338
|
expect(span.status?.message).toBe("network down");
|
|
310
339
|
expect(hasAttribute(span, "dash0.web.request.cancelled", { boolValue: true })).toBe(false);
|
|
311
340
|
expect(span.events.some((e) => e.name === "exception")).toBe(true);
|
|
341
|
+
expect(hasAttribute(span, "error.type", { stringValue: "TypeError" })).toBe(true);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("sets error.type from the exception name when reading the response body fails", async () => {
|
|
345
|
+
const body = new ReadableStream({
|
|
346
|
+
start(streamController) {
|
|
347
|
+
streamController.enqueue(new Uint8Array([0x68, 0x69]));
|
|
348
|
+
},
|
|
349
|
+
pull(streamController) {
|
|
350
|
+
streamController.error(new TypeError("network down"));
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
fetchMock.mockImplementation(() => Promise.resolve(new Response(body, { status: 200 })));
|
|
355
|
+
|
|
356
|
+
instrumentFetch();
|
|
357
|
+
// eslint-disable-next-line no-restricted-globals
|
|
358
|
+
const response = await fetch("http://localhost:3000/api/test");
|
|
359
|
+
const reader = response.body!.getReader();
|
|
360
|
+
await reader.read();
|
|
361
|
+
await expect(reader.read()).rejects.toBeInstanceOf(TypeError);
|
|
362
|
+
|
|
363
|
+
expect(sendSpanMock).toHaveBeenCalledTimes(1);
|
|
364
|
+
const span = lastSpan();
|
|
365
|
+
expect(span.status?.code).toBe(2);
|
|
366
|
+
expect(span.status?.message).toBe("network down");
|
|
367
|
+
expect(hasAttribute(span, "dash0.web.request.cancelled", { boolValue: true })).toBe(false);
|
|
368
|
+
expect(span.events.some((e) => e.name === "exception")).toBe(true);
|
|
369
|
+
expect(hasAttribute(span, "error.type", { stringValue: "TypeError" })).toBe(true);
|
|
312
370
|
});
|
|
313
371
|
});
|
|
314
372
|
});
|
|
@@ -1,6 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
addAttribute,
|
|
3
|
+
addSpanEvent,
|
|
4
|
+
addW3CTraceContextHttpHeaders,
|
|
5
|
+
addXRayTraceContextHttpHeaders,
|
|
6
|
+
endSpan,
|
|
7
|
+
errorToSpanStatus,
|
|
8
|
+
Exception,
|
|
9
|
+
InProgressSpan,
|
|
10
|
+
recordException,
|
|
11
|
+
} from "../../utils/otel";
|
|
12
|
+
import { domHRTimestampToNanos, hasKey, isSameOrigin, PerformanceTimingNames } from "../../utils";
|
|
13
|
+
import { matchesAny } from "../../utils/ignore-rules";
|
|
14
|
+
import { ERROR_TYPE, HTTP_RESPONSE_BODY_SIZE, WEB_REQUEST_CANCELLED } from "../../semantic-conventions";
|
|
15
|
+
import { vars, PropagatorType } from "../../vars";
|
|
16
|
+
import { sendSpan } from "../../transport";
|
|
4
17
|
|
|
5
18
|
// SEE: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/attributes-registry/http.md?plain=1#L67
|
|
6
19
|
const KNOWN_HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH"];
|
|
@@ -47,3 +60,77 @@ export function addResourceSize(span: InProgressSpan, resource: PerformanceResou
|
|
|
47
60
|
addAttribute(span.attributes, HTTP_RESPONSE_BODY_SIZE, encodedLength);
|
|
48
61
|
}
|
|
49
62
|
}
|
|
63
|
+
|
|
64
|
+
// Sets error.type alongside the recorded exception so failed fetch and XHR spans are equally
|
|
65
|
+
// queryable by error.type. The value is the exception name (e.g. TypeError) -- XHR's synthetic
|
|
66
|
+
// failure exceptions carry their failure kind ("error"/"timeout") as the name, so both
|
|
67
|
+
// instrumentations converge here. Note the shapes still differ for cases inherent to the APIs:
|
|
68
|
+
// a fetch that resolves with status 0 (e.g. opaque responses) never reaches this function and
|
|
69
|
+
// instead gets error.type = response.type plus http.response.status_code "0".
|
|
70
|
+
export function endSpanOnError(span: InProgressSpan, error: Exception) {
|
|
71
|
+
recordException(span, error);
|
|
72
|
+
const errorType =
|
|
73
|
+
typeof error === "object" && error ? (error.name ?? (error.code != null ? String(error.code) : "error")) : "error";
|
|
74
|
+
addAttribute(span.attributes, ERROR_TYPE, errorType);
|
|
75
|
+
sendSpan(endSpan(span, errorToSpanStatus(error), undefined));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Cancellations are benign: no error status, no error.type. This also covers fetch calls aborted
|
|
79
|
+
// by AbortSignal.timeout() -- the signal is aborted by the time the rejection is handled, so a
|
|
80
|
+
// fetch timeout surfaces as a cancellation while an XHR timeout is an ERROR span with
|
|
81
|
+
// error.type = "timeout". That asymmetry is inherent to the two APIs.
|
|
82
|
+
export function endSpanOnAbort(span: InProgressSpan) {
|
|
83
|
+
addAttribute(span.attributes, WEB_REQUEST_CANCELLED, true);
|
|
84
|
+
sendSpan(endSpan(span, undefined, undefined));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function determinePropagatorTypes(url: string): PropagatorType[] {
|
|
88
|
+
const matchingTypes: PropagatorType[] = [];
|
|
89
|
+
const isUrlSameOrigin = isSameOrigin(url);
|
|
90
|
+
|
|
91
|
+
// For same-origin requests, always include traceparent + all configured propagators
|
|
92
|
+
if (isUrlSameOrigin) {
|
|
93
|
+
// Always add traceparent for same-origin requests
|
|
94
|
+
matchingTypes.push("traceparent");
|
|
95
|
+
|
|
96
|
+
// Add all other configured propagator types for same-origin requests
|
|
97
|
+
if (vars.propagators) {
|
|
98
|
+
for (const propagator of vars.propagators) {
|
|
99
|
+
if (propagator.type !== "traceparent" && !matchingTypes.includes(propagator.type)) {
|
|
100
|
+
matchingTypes.push(propagator.type);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return matchingTypes;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// For cross-origin requests, use new propagators config if available
|
|
108
|
+
if (vars.propagators) {
|
|
109
|
+
for (const propagator of vars.propagators) {
|
|
110
|
+
if (matchesAny(propagator.match, url)) {
|
|
111
|
+
// Avoid duplicates
|
|
112
|
+
if (!matchingTypes.includes(propagator.type)) {
|
|
113
|
+
matchingTypes.push(propagator.type);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return matchingTypes;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function addTraceContextHttpHeaders(
|
|
124
|
+
fn: (name: string, value: string) => void,
|
|
125
|
+
ctx: unknown,
|
|
126
|
+
span: InProgressSpan,
|
|
127
|
+
types: PropagatorType[]
|
|
128
|
+
) {
|
|
129
|
+
for (const type of types) {
|
|
130
|
+
if (type === "xray") {
|
|
131
|
+
addXRayTraceContextHttpHeaders(fn, ctx, span);
|
|
132
|
+
} else {
|
|
133
|
+
addW3CTraceContextHttpHeaders(fn, ctx, span);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|