@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
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { addEventListener, debug, observeResourcePerformance, parseUrl, removeEventListener, win, wrap, } from "../../utils";
|
|
2
|
+
import { isUrlIgnored } from "../../utils/ignore-rules";
|
|
3
|
+
import { addAttribute, endSpan, recordException, 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
|
+
import { vars } from "../../vars";
|
|
6
|
+
import { httpRequestHeaderKey, httpResponseHeaderKey } from "../../utils/otel/http";
|
|
7
|
+
import { sendSpan } from "../../transport";
|
|
8
|
+
import { addResourceNetworkEvents, addResourceSize, addTraceContextHttpHeaders, determinePropagatorTypes, endSpanOnAbort, endSpanOnError, HTTP_METHOD_OTHER, isWellKnownHttpMethod, } from "./utils";
|
|
9
|
+
import { addCommonAttributes, addUrlAttributes } from "../../attributes";
|
|
10
|
+
const XHR_STATE = Symbol("dash0XhrState");
|
|
11
|
+
// The un-wrapped setRequestHeader, captured when wrapping. Trace context headers are injected
|
|
12
|
+
// through it so the SDK's own headers neither land in capturedRequestHeaders nor trip the
|
|
13
|
+
// already-traced detection below.
|
|
14
|
+
let originalSetRequestHeader;
|
|
15
|
+
export function instrumentXhr() {
|
|
16
|
+
if (!win || !win.XMLHttpRequest) {
|
|
17
|
+
debug("Browser does not support XMLHttpRequest, skipping instrumentation");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const proto = win.XMLHttpRequest.prototype;
|
|
21
|
+
wrap(proto, "open", wrapOpen);
|
|
22
|
+
wrap(proto, "setRequestHeader", wrapSetRequestHeader);
|
|
23
|
+
wrap(proto, "send", wrapSend);
|
|
24
|
+
}
|
|
25
|
+
function wrapOpen(original) {
|
|
26
|
+
return function (method, url, ...rest) {
|
|
27
|
+
let openArgs = [method, url, ...rest];
|
|
28
|
+
try {
|
|
29
|
+
// Pass the pre-coerced URL to the native method so a side-effectful custom toString runs
|
|
30
|
+
// once, not twice. The method arg stays untouched -- its coercion is SDK bookkeeping only.
|
|
31
|
+
openArgs = [method, onOpen(this, method, url), ...rest];
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
// Clear stale state from a previous open() on a reused instance so send() doesn't
|
|
35
|
+
// attribute the new request to old state.
|
|
36
|
+
this[XHR_STATE] = undefined;
|
|
37
|
+
debug("failed to instrument XMLHttpRequest.open", e);
|
|
38
|
+
}
|
|
39
|
+
return original.apply(this, openArgs);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function onOpen(xhr, method, url) {
|
|
43
|
+
// Per spec, open() during an in-flight request terminates the fetch without firing
|
|
44
|
+
// abort/loadend, so onLoadEnd never runs for the previous request. Clean it up here (before
|
|
45
|
+
// anything below can throw) or its listeners, performance observer and span would leak on
|
|
46
|
+
// every cancel-by-reopen cycle -- and its stale loadend listener would end the old span with
|
|
47
|
+
// the next request's status.
|
|
48
|
+
const previousState = xhr[XHR_STATE];
|
|
49
|
+
if (previousState?.span && !previousState.completed) {
|
|
50
|
+
cleanupAbandonedRequest(xhr, previousState);
|
|
51
|
+
}
|
|
52
|
+
const stringUrl = String(url);
|
|
53
|
+
// Resolve relative URLs so ignore rules, propagator matching and url.* attributes see the same
|
|
54
|
+
// absolute URL the fetch instrumentation matches against (Request resolves it there). Only the
|
|
55
|
+
// SDK bookkeeping uses the resolved form -- the native open() still receives stringUrl.
|
|
56
|
+
const resolvedUrl = parseUrl(stringUrl).href;
|
|
57
|
+
const originalMethod = String(method ?? "GET");
|
|
58
|
+
const isWellKnownMethodMatchingLeniently = isWellKnownHttpMethod(originalMethod.toUpperCase());
|
|
59
|
+
const normalizedMethod = isWellKnownMethodMatchingLeniently ? originalMethod.toUpperCase() : HTTP_METHOD_OTHER;
|
|
60
|
+
// A new open() call on a reused XHR instance resets state -- any prior span for this instance
|
|
61
|
+
// has already been sent (completed requests) or was just ended as cancelled above, and this
|
|
62
|
+
// open() is treated as the start of a brand-new request with its own span.
|
|
63
|
+
xhr[XHR_STATE] = {
|
|
64
|
+
method: normalizedMethod,
|
|
65
|
+
originalMethod,
|
|
66
|
+
isWellKnownMethod: isWellKnownHttpMethod(originalMethod),
|
|
67
|
+
url: resolvedUrl,
|
|
68
|
+
ignored: isUrlIgnored(resolvedUrl),
|
|
69
|
+
propagatorTypes: determinePropagatorTypes(resolvedUrl),
|
|
70
|
+
completed: false,
|
|
71
|
+
};
|
|
72
|
+
return stringUrl;
|
|
73
|
+
}
|
|
74
|
+
function wrapSetRequestHeader(original) {
|
|
75
|
+
// wrap() only invokes this factory when it actually wraps (it skips already-instrumented
|
|
76
|
+
// targets), so this can never capture the SDK's own wrapper on double instrumentation.
|
|
77
|
+
originalSetRequestHeader = original;
|
|
78
|
+
return function (name, value) {
|
|
79
|
+
original.call(this, name, value);
|
|
80
|
+
try {
|
|
81
|
+
onSetRequestHeader(this, name, value);
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
debug("failed to instrument XMLHttpRequest.setRequestHeader", e);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function onSetRequestHeader(xhr, name, value) {
|
|
89
|
+
const state = xhr[XHR_STATE];
|
|
90
|
+
if (!state || state.ignored)
|
|
91
|
+
return;
|
|
92
|
+
// Match against the lowercased name so a regex behaves the same here as for fetch,
|
|
93
|
+
// where Headers iteration yields lowercased names.
|
|
94
|
+
const lowerName = name.toLowerCase();
|
|
95
|
+
// A trace correlation header showing up here means someone else is already tracing this
|
|
96
|
+
// request -- the SDK injects its own headers via the un-wrapped setRequestHeader, so it never
|
|
97
|
+
// trips this. The typical source is a fetch polyfill built on XHR: the fetch instrumentation
|
|
98
|
+
// has already created a span and injected headers for the logical request, and the polyfill
|
|
99
|
+
// replays them onto the underlying XHR. send() skips span creation and injection for such
|
|
100
|
+
// requests -- a second injection would combine into one invalid comma-joined header value,
|
|
101
|
+
// and a second span would double-report the request (the fetch span's resource matcher
|
|
102
|
+
// already accepts initiatorType "xmlhttprequest" to cover polyfills). Note this only
|
|
103
|
+
// detects the polyfill case when the fetch side actually injected headers (a propagator
|
|
104
|
+
// matched the URL) -- without that, the polyfill case still yields two spans.
|
|
105
|
+
if (lowerName === "traceparent" || lowerName === "x-amzn-trace-id") {
|
|
106
|
+
state.alreadyTraced = true;
|
|
107
|
+
}
|
|
108
|
+
// Filter at capture time like the fetch instrumentation -- never retain unmatched
|
|
109
|
+
// (potentially sensitive) headers on the page-reachable XHR instance.
|
|
110
|
+
if (vars.headersToCapture.length === 0)
|
|
111
|
+
return;
|
|
112
|
+
if (!vars.headersToCapture.some((rxp) => rxp.test(lowerName)))
|
|
113
|
+
return;
|
|
114
|
+
const headers = (state.capturedRequestHeaders ??= {});
|
|
115
|
+
// Native XHR combines repeated setRequestHeader() calls for the same name
|
|
116
|
+
// (case-insensitively) into a single "a, b" value -- mirror that.
|
|
117
|
+
headers[lowerName] = lowerName in headers ? `${headers[lowerName]}, ${value}` : value;
|
|
118
|
+
}
|
|
119
|
+
function wrapSend(original) {
|
|
120
|
+
return function (body) {
|
|
121
|
+
const state = this[XHR_STATE];
|
|
122
|
+
if (!state || state.ignored) {
|
|
123
|
+
if (state?.ignored) {
|
|
124
|
+
debug(`Not creating span for XMLHttpRequest because the url is ignored, URL: ${state.url}`);
|
|
125
|
+
}
|
|
126
|
+
return original.call(this, body);
|
|
127
|
+
}
|
|
128
|
+
if (state.alreadyTraced) {
|
|
129
|
+
debug(`Not creating span for XMLHttpRequest because a trace correlation header is already present, URL: ${state.url}`);
|
|
130
|
+
return original.call(this, body);
|
|
131
|
+
}
|
|
132
|
+
// send() on an already-sent request throws InvalidStateError natively. Don't create a second
|
|
133
|
+
// span and set of listeners for it -- that would overwrite the in-flight request's state and
|
|
134
|
+
// attribute its response to the wrong span.
|
|
135
|
+
if (state.span && !state.completed) {
|
|
136
|
+
return original.call(this, body);
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
onSend(this, state);
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
cleanupFailedSend(this, state);
|
|
143
|
+
debug("failed to instrument XMLHttpRequest.send", e);
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
return original.call(this, body);
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
// Synchronous XHR reports network errors and timeouts by making send() throw -- per spec
|
|
150
|
+
// no loadend fires for sync failures, so onLoadEnd never runs and this is the only place
|
|
151
|
+
// the request can be finalized.
|
|
152
|
+
endSpanOnSyncSendError(this, state, e);
|
|
153
|
+
throw e;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function endSpanOnSyncSendError(xhr, state, error) {
|
|
158
|
+
if (state.completed)
|
|
159
|
+
return;
|
|
160
|
+
state.completed = true;
|
|
161
|
+
try {
|
|
162
|
+
for (const [eventType, listener] of state.listeners ?? []) {
|
|
163
|
+
removeEventListener(xhr, eventType, listener);
|
|
164
|
+
}
|
|
165
|
+
state.performanceObserver?.cancel();
|
|
166
|
+
const span = state.span;
|
|
167
|
+
if (!span)
|
|
168
|
+
return;
|
|
169
|
+
const failureKind = state.failureKind ?? (error?.name === "TimeoutError" ? "timeout" : "error");
|
|
170
|
+
recordException(span, error ?? { name: failureKind, message: `XMLHttpRequest failed: ${state.url}` });
|
|
171
|
+
addAttribute(span.attributes, ERROR_TYPE, failureKind);
|
|
172
|
+
sendSpan(endSpan(span, { code: SPAN_STATUS_ERROR, message: `XMLHttpRequest failed: ${state.url}` }, undefined));
|
|
173
|
+
}
|
|
174
|
+
catch (_e) {
|
|
175
|
+
// Best-effort only -- never mask the page's original exception from send().
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function onSend(xhr, state) {
|
|
179
|
+
const span = startSpan(`HTTP ${state.method}`);
|
|
180
|
+
addCommonAttributes(span.attributes);
|
|
181
|
+
addUrlAttributes(span.attributes, state.url);
|
|
182
|
+
addAttribute(span.attributes, HTTP_REQUEST_METHOD, state.method);
|
|
183
|
+
if (!state.isWellKnownMethod) {
|
|
184
|
+
addAttribute(span.attributes, HTTP_REQUEST_METHOD_ORIGINAL, state.originalMethod);
|
|
185
|
+
}
|
|
186
|
+
state.span = span;
|
|
187
|
+
if (state.propagatorTypes.length > 0) {
|
|
188
|
+
try {
|
|
189
|
+
addTraceContextHttpHeaders((name, value) => (originalSetRequestHeader ?? xhr.setRequestHeader).call(xhr, name, value), xhr, span, state.propagatorTypes);
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
// setRequestHeader throws InvalidStateError if called before open() succeeded, or after
|
|
193
|
+
// send(). This should not normally happen since we only reach here from within send()
|
|
194
|
+
// itself with state populated by a prior open(), but guard defensively.
|
|
195
|
+
debug("failed to inject trace context headers on XMLHttpRequest", e);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Entries were already filtered against vars.headersToCapture (and lowercased) at
|
|
199
|
+
// setRequestHeader() time.
|
|
200
|
+
if (state.capturedRequestHeaders) {
|
|
201
|
+
for (const [name, value] of Object.entries(state.capturedRequestHeaders)) {
|
|
202
|
+
addAttribute(span.attributes, httpRequestHeaderKey(name), value);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const performanceObserver = observeResourcePerformance({
|
|
206
|
+
resourceMatcher: ({ initiatorType, name }) => initiatorType === "xmlhttprequest" && name === state.url,
|
|
207
|
+
maxWaitForResourceMillis: vars.maxWaitForResourceTimingsMillis,
|
|
208
|
+
maxToleranceForResourceTimingsMillis: vars.maxToleranceForResourceTimingsMillis,
|
|
209
|
+
onEnd: ({ duration, resource }) => {
|
|
210
|
+
if (resource) {
|
|
211
|
+
addResourceNetworkEvents(span, resource);
|
|
212
|
+
addResourceSize(span, resource);
|
|
213
|
+
}
|
|
214
|
+
sendSpan(endSpan(span, undefined, duration * 1000000));
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
state.performanceObserver = performanceObserver;
|
|
218
|
+
performanceObserver.start();
|
|
219
|
+
// Keep references to the per-request listeners so onLoadEnd can remove them again -- loadend
|
|
220
|
+
// fires for every request outcome, so cleanup there prevents listeners (and their state/span
|
|
221
|
+
// closures) from accumulating on reused XHR instances.
|
|
222
|
+
state.listeners = [
|
|
223
|
+
[
|
|
224
|
+
"error",
|
|
225
|
+
() => {
|
|
226
|
+
state.failureKind = "error";
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
[
|
|
230
|
+
"timeout",
|
|
231
|
+
() => {
|
|
232
|
+
state.failureKind = "timeout";
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
[
|
|
236
|
+
"abort",
|
|
237
|
+
() => {
|
|
238
|
+
state.failureKind = "abort";
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
["loadend", () => onLoadEnd(xhr, state)],
|
|
242
|
+
];
|
|
243
|
+
for (const [eventType, listener] of state.listeners) {
|
|
244
|
+
addEventListener(xhr, eventType, listener);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function cleanupAbandonedRequest(xhr, state) {
|
|
248
|
+
// Mark completed first so a stray late event stays a no-op even if listener removal fails.
|
|
249
|
+
state.completed = true;
|
|
250
|
+
try {
|
|
251
|
+
for (const [eventType, listener] of state.listeners ?? []) {
|
|
252
|
+
removeEventListener(xhr, eventType, listener);
|
|
253
|
+
}
|
|
254
|
+
state.performanceObserver?.cancel();
|
|
255
|
+
if (state.span) {
|
|
256
|
+
// Report the request as cancelled, matching how fetch reports aborted requests.
|
|
257
|
+
endSpanOnAbort(state.span);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
catch (_e) {
|
|
261
|
+
// Best-effort cleanup only -- never let it throw into the page's open() call.
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function cleanupFailedSend(xhr, state) {
|
|
265
|
+
try {
|
|
266
|
+
state.performanceObserver?.cancel();
|
|
267
|
+
for (const [eventType, listener] of state.listeners ?? []) {
|
|
268
|
+
removeEventListener(xhr, eventType, listener);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
catch (_e) {
|
|
272
|
+
// Best-effort cleanup only -- never let it throw into the page's send() call.
|
|
273
|
+
}
|
|
274
|
+
// Drop the span so a stray loadend for this request doesn't emit a half-initialized span.
|
|
275
|
+
state.span = undefined;
|
|
276
|
+
}
|
|
277
|
+
function onLoadEnd(xhr, state) {
|
|
278
|
+
if (state.completed)
|
|
279
|
+
return;
|
|
280
|
+
state.completed = true;
|
|
281
|
+
// The request cycle is over -- remove the per-request listeners so they don't pile up on reused
|
|
282
|
+
// XHR instances.
|
|
283
|
+
for (const [eventType, listener] of state.listeners ?? []) {
|
|
284
|
+
removeEventListener(xhr, eventType, listener);
|
|
285
|
+
}
|
|
286
|
+
const span = state.span;
|
|
287
|
+
if (!span)
|
|
288
|
+
return;
|
|
289
|
+
const performanceObserver = state.performanceObserver;
|
|
290
|
+
if (state.failureKind === "abort") {
|
|
291
|
+
performanceObserver?.cancel();
|
|
292
|
+
endSpanOnAbort(span);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
let status = 0;
|
|
296
|
+
try {
|
|
297
|
+
status = xhr.status;
|
|
298
|
+
}
|
|
299
|
+
catch (_e) {
|
|
300
|
+
// Reading .status can throw in some environments if accessed at the wrong readyState.
|
|
301
|
+
status = 0;
|
|
302
|
+
}
|
|
303
|
+
if (state.failureKind === "error" || state.failureKind === "timeout" || status === 0) {
|
|
304
|
+
performanceObserver?.cancel();
|
|
305
|
+
const failureKind = state.failureKind ?? "error";
|
|
306
|
+
// The failure kind doubles as the exception name, so endSpanOnError derives
|
|
307
|
+
// error.type = failureKind from it.
|
|
308
|
+
endSpanOnError(span, { name: failureKind, message: `XMLHttpRequest failed: ${state.url}` });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
setSpanStatus(span, status >= 200 && status < 400 ? SPAN_STATUS_UNSET : SPAN_STATUS_ERROR);
|
|
312
|
+
addAttribute(span.attributes, HTTP_RESPONSE_STATUS_CODE, String(status));
|
|
313
|
+
tryCaptureResponseHeaders(xhr, span);
|
|
314
|
+
performanceObserver?.end();
|
|
315
|
+
}
|
|
316
|
+
function tryCaptureResponseHeaders(xhr, span) {
|
|
317
|
+
try {
|
|
318
|
+
if (!vars.headersToCapture.length)
|
|
319
|
+
return;
|
|
320
|
+
const raw = xhr.getAllResponseHeaders();
|
|
321
|
+
if (!raw)
|
|
322
|
+
return;
|
|
323
|
+
raw
|
|
324
|
+
.split(/\r?\n/)
|
|
325
|
+
.filter((line) => line.length > 0)
|
|
326
|
+
.forEach((line) => {
|
|
327
|
+
const separatorIndex = line.indexOf(":");
|
|
328
|
+
if (separatorIndex === -1)
|
|
329
|
+
return;
|
|
330
|
+
const name = line.substring(0, separatorIndex).trim();
|
|
331
|
+
const value = line.substring(separatorIndex + 1).trim();
|
|
332
|
+
if (vars.headersToCapture.some((rxp) => rxp.test(name))) {
|
|
333
|
+
addAttribute(span.attributes, httpResponseHeaderKey(name), value);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
catch (e) {
|
|
338
|
+
debug("unable to capture http response headers", e);
|
|
339
|
+
}
|
|
340
|
+
}
|