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