@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.
@@ -0,0 +1,705 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { vars } from "../../vars";
3
+ import { instrumentXhr } from "./xhr";
4
+ import { doc } from "../../utils/globals";
5
+ import { sendSpan } from "../../transport";
6
+ vi.mock("../../transport", () => ({
7
+ sendSpan: vi.fn(),
8
+ }));
9
+ /**
10
+ * A controllable stand-in for the browser's XMLHttpRequest. jsdom's own XHR implementation does
11
+ * not give deterministic control over readyState transitions, response headers, or event timing,
12
+ * so we install this fake via vi.stubGlobal and drive it explicitly from each test.
13
+ */
14
+ class FakeXMLHttpRequest extends EventTarget {
15
+ static UNSENT = 0;
16
+ static OPENED = 1;
17
+ static HEADERS_RECEIVED = 2;
18
+ static LOADING = 3;
19
+ static DONE = 4;
20
+ readyState = FakeXMLHttpRequest.UNSENT;
21
+ status = 0;
22
+ statusText = "";
23
+ responseHeaders = {};
24
+ requestHeaders = {};
25
+ method;
26
+ url;
27
+ async;
28
+ sentBody;
29
+ onreadystatechange = null;
30
+ open(method, url, async) {
31
+ this.method = method;
32
+ this.url = url;
33
+ this.async = async ?? true;
34
+ this.readyState = FakeXMLHttpRequest.OPENED;
35
+ this.requestHeaders = {};
36
+ this.status = 0;
37
+ }
38
+ setRequestHeader(name, value) {
39
+ // Per the XHR spec, repeated setRequestHeader() calls with the same name combine the values.
40
+ // Modeling this makes doubled header injection observable as a comma-joined value.
41
+ const existing = this.requestHeaders[name];
42
+ this.requestHeaders[name] = existing ? `${existing}, ${value}` : value;
43
+ }
44
+ /**
45
+ * When set, send() throws this error synchronously without firing any events, modeling the
46
+ * spec's sync-XHR error handling: a network error or timeout on open(..., false) makes send()
47
+ * throw and loadend never fires.
48
+ */
49
+ syncSendError;
50
+ send(body) {
51
+ this.sentBody = body;
52
+ if (this.syncSendError) {
53
+ this.readyState = FakeXMLHttpRequest.DONE;
54
+ throw this.syncSendError;
55
+ }
56
+ }
57
+ getAllResponseHeaders() {
58
+ return Object.entries(this.responseHeaders)
59
+ .map(([k, v]) => `${k}: ${v}\r\n`)
60
+ .join("");
61
+ }
62
+ // --- Test-only helpers to drive the fake through a request lifecycle ---
63
+ respond(status, headers = {}) {
64
+ this.status = status;
65
+ this.statusText = String(status);
66
+ this.responseHeaders = headers;
67
+ this.readyState = FakeXMLHttpRequest.DONE;
68
+ this.dispatchEvent(new Event("loadend"));
69
+ }
70
+ triggerError() {
71
+ this.status = 0;
72
+ this.readyState = FakeXMLHttpRequest.DONE;
73
+ this.dispatchEvent(new Event("error"));
74
+ this.dispatchEvent(new Event("loadend"));
75
+ }
76
+ triggerTimeout() {
77
+ this.status = 0;
78
+ this.readyState = FakeXMLHttpRequest.DONE;
79
+ this.dispatchEvent(new Event("timeout"));
80
+ this.dispatchEvent(new Event("loadend"));
81
+ }
82
+ triggerAbort() {
83
+ this.status = 0;
84
+ this.readyState = FakeXMLHttpRequest.DONE;
85
+ this.dispatchEvent(new Event("abort"));
86
+ this.dispatchEvent(new Event("loadend"));
87
+ }
88
+ }
89
+ describe("xhr test", () => {
90
+ let NativeXHR;
91
+ beforeEach(() => {
92
+ NativeXHR = globalThis.XMLHttpRequest;
93
+ vi.stubGlobal("XMLHttpRequest", FakeXMLHttpRequest);
94
+ vi.stubGlobal("location", { origin: "http://localhost:3000", href: "http://localhost:3000/" });
95
+ // Let the async resource-timing wait resolve on the next tick. See the comment on the first
96
+ // success-path test below for why the success path is asynchronous.
97
+ vars.maxWaitForResourceTimingsMillis = 0;
98
+ });
99
+ afterEach(() => {
100
+ vi.stubGlobal("XMLHttpRequest", NativeXHR);
101
+ vi.resetAllMocks();
102
+ vars.propagators = undefined;
103
+ vars.headersToCapture = [];
104
+ vars.ignoreUrls = [];
105
+ vars.maxWaitForResourceTimingsMillis = 10000;
106
+ });
107
+ it("should inject traceparent header for same-origin requests", () => {
108
+ vars.propagators = [{ type: "traceparent", match: [] }];
109
+ instrumentXhr();
110
+ const xhr = new XMLHttpRequest();
111
+ xhr.open("GET", "/api/test");
112
+ xhr.send();
113
+ expect(xhr.requestHeaders["traceparent"]).toBeDefined();
114
+ });
115
+ it("should inject traceparent header for matching cross-origin requests", () => {
116
+ vars.propagators = [{ type: "traceparent", match: [new RegExp("http://foo.bar/")] }];
117
+ instrumentXhr();
118
+ const xhr = new XMLHttpRequest();
119
+ xhr.open("GET", "http://foo.bar/foo");
120
+ xhr.send();
121
+ expect(xhr.requestHeaders["traceparent"]).toBeDefined();
122
+ });
123
+ it("should inject xray header in X-Ray format for matching cross-origin requests", () => {
124
+ vars.propagators = [{ type: "xray", match: [new RegExp("http://foo.bar/")] }];
125
+ instrumentXhr();
126
+ const xhr = new XMLHttpRequest();
127
+ xhr.open("GET", "http://foo.bar/foo");
128
+ xhr.send();
129
+ expect(xhr.requestHeaders["X-Amzn-Trace-Id"]).toMatch(/^Root=1-[0-9a-f]{8}-[0-9a-f]{24};Parent=[0-9a-f]{16};Sampled=1$/);
130
+ });
131
+ it("should inject no headers for non-matching cross-origin requests", () => {
132
+ vars.propagators = [];
133
+ instrumentXhr();
134
+ const xhr = new XMLHttpRequest();
135
+ xhr.open("GET", "http://foo.bar/foo");
136
+ xhr.send();
137
+ expect(xhr.requestHeaders["traceparent"]).toBeUndefined();
138
+ expect(xhr.requestHeaders["X-Amzn-Trace-Id"]).toBeUndefined();
139
+ });
140
+ it("should not create a span or inject headers for ignored URLs", () => {
141
+ vars.ignoreUrls = [/you-cant-see-this/];
142
+ vars.propagators = [{ type: "traceparent", match: [] }];
143
+ instrumentXhr();
144
+ const xhr = new XMLHttpRequest();
145
+ xhr.open("GET", "/you-cant-see-this");
146
+ xhr.send();
147
+ expect(xhr.requestHeaders["traceparent"]).toBeUndefined();
148
+ expect(sendSpan).not.toHaveBeenCalled();
149
+ });
150
+ // Ignore rules must match against the resolved absolute URL -- the same form the fetch
151
+ // instrumentation matches against -- so origin-anchored regexes apply uniformly to relative
152
+ // XHR URLs.
153
+ it("should apply origin-anchored ignore regexes to relative URLs", () => {
154
+ const origin = new URL(doc.baseURI).origin;
155
+ vars.ignoreUrls = [new RegExp(`^${origin}/you-cant-see-this`)];
156
+ vars.propagators = [{ type: "traceparent", match: [] }];
157
+ instrumentXhr();
158
+ const xhr = new XMLHttpRequest();
159
+ xhr.open("GET", "/you-cant-see-this");
160
+ xhr.send();
161
+ expect(xhr.requestHeaders["traceparent"]).toBeUndefined();
162
+ expect(sendSpan).not.toHaveBeenCalled();
163
+ // The page's own request must still have gone through with the original relative URL.
164
+ expect(xhr.url).toBe("/you-cant-see-this");
165
+ });
166
+ it("records the resolved absolute URL as url.full for relative request URLs", async () => {
167
+ instrumentXhr();
168
+ const xhr = new XMLHttpRequest();
169
+ xhr.open("GET", "/api/test");
170
+ xhr.send();
171
+ xhr.respond(200);
172
+ const sendSpanMock = sendSpan;
173
+ await vi.waitFor(() => expect(sendSpanMock).toHaveBeenCalledTimes(1));
174
+ const span = sendSpanMock.mock.calls[0][0];
175
+ expect(span.attributes).toContainEqual({
176
+ key: "url.full",
177
+ value: { stringValue: new URL("/api/test", doc.baseURI).href },
178
+ });
179
+ // The native open() must still have received the page's original relative URL.
180
+ expect(xhr.url).toBe("/api/test");
181
+ });
182
+ // The tests below that drive a request to *successful* completion must await sendSpan
183
+ // asynchronously: the success path routes span completion through observeResourcePerformance,
184
+ // which resolves asynchronously. jsdom's PerformanceObserver never emits resource entries, so
185
+ // onEnd only fires after the maxWaitForResourceTimingsMillis timeout -- held at 0 in these tests
186
+ // (see beforeEach) to keep them fast. The error/timeout/abort paths bypass the observer and
187
+ // remain synchronous.
188
+ it("should capture matching request headers as span attributes", async () => {
189
+ vars.headersToCapture = [/x-test-header/];
190
+ instrumentXhr();
191
+ const xhr = new XMLHttpRequest();
192
+ xhr.open("GET", "/api/test");
193
+ xhr.setRequestHeader("x-test-header", "hello");
194
+ xhr.send();
195
+ xhr.respond(200);
196
+ const sendSpanMock = sendSpan;
197
+ await vi.waitFor(() => expect(sendSpanMock).toHaveBeenCalledTimes(1));
198
+ const span = sendSpanMock.mock.calls[0][0];
199
+ expect(span.attributes).toContainEqual({
200
+ key: "http.request.header.x-test-header",
201
+ value: { stringValue: "hello" },
202
+ });
203
+ });
204
+ it("does not retain headers set while headersToCapture is empty", async () => {
205
+ vars.headersToCapture = [];
206
+ instrumentXhr();
207
+ const xhr = new XMLHttpRequest();
208
+ xhr.open("GET", "/api/test");
209
+ xhr.setRequestHeader("x-test-header", "hello");
210
+ // Nothing may have been stored above -- enabling capture afterwards must not resurface it.
211
+ vars.headersToCapture = [/x-test-header/];
212
+ xhr.send();
213
+ xhr.respond(200);
214
+ const sendSpanMock = sendSpan;
215
+ await vi.waitFor(() => expect(sendSpanMock).toHaveBeenCalledTimes(1));
216
+ const span = sendSpanMock.mock.calls[0][0];
217
+ expect(span.attributes).not.toContainEqual(expect.objectContaining({ key: "http.request.header.x-test-header" }));
218
+ });
219
+ it("combines repeated setRequestHeader calls case-insensitively into a single attribute", async () => {
220
+ vars.headersToCapture = [/x-test-header/];
221
+ instrumentXhr();
222
+ const xhr = new XMLHttpRequest();
223
+ xhr.open("GET", "/api/test");
224
+ xhr.setRequestHeader("X-Test-Header", "a");
225
+ xhr.setRequestHeader("x-test-header", "b");
226
+ xhr.send();
227
+ xhr.respond(200);
228
+ const sendSpanMock = sendSpan;
229
+ await vi.waitFor(() => expect(sendSpanMock).toHaveBeenCalledTimes(1));
230
+ const span = sendSpanMock.mock.calls[0][0];
231
+ const headerAttributes = span.attributes.filter((attr) => attr.key === "http.request.header.x-test-header");
232
+ expect(headerAttributes).toEqual([
233
+ {
234
+ key: "http.request.header.x-test-header",
235
+ value: { stringValue: "a, b" },
236
+ },
237
+ ]);
238
+ });
239
+ it("matches capture regexes against the lowercased header name, like fetch", async () => {
240
+ // Headers iteration yields lowercased names for fetch, so a case-sensitive regex written
241
+ // against the original casing never matches there -- XHR must behave the same.
242
+ vars.headersToCapture = [/^X-Test/];
243
+ instrumentXhr();
244
+ const xhr = new XMLHttpRequest();
245
+ xhr.open("GET", "/api/test");
246
+ xhr.setRequestHeader("X-Test-Header", "hello");
247
+ xhr.send();
248
+ xhr.respond(200);
249
+ const sendSpanMock = sendSpan;
250
+ await vi.waitFor(() => expect(sendSpanMock).toHaveBeenCalledTimes(1));
251
+ const span = sendSpanMock.mock.calls[0][0];
252
+ expect(span.attributes).not.toContainEqual(expect.objectContaining({ key: "http.request.header.x-test-header" }));
253
+ });
254
+ it("does not capture the SDK's own injected trace context headers as span attributes", async () => {
255
+ vars.headersToCapture = [/.*/];
256
+ vars.propagators = [
257
+ { type: "traceparent", match: [] },
258
+ { type: "xray", match: [] },
259
+ ];
260
+ instrumentXhr();
261
+ const xhr = new XMLHttpRequest();
262
+ xhr.open("GET", "/api/test");
263
+ xhr.send();
264
+ // Injection itself must still happen ...
265
+ expect(xhr.requestHeaders["traceparent"]).toBeDefined();
266
+ expect(xhr.requestHeaders["X-Amzn-Trace-Id"]).toBeDefined();
267
+ xhr.respond(200);
268
+ const sendSpanMock = sendSpan;
269
+ await vi.waitFor(() => expect(sendSpanMock).toHaveBeenCalledTimes(1));
270
+ const span = sendSpanMock.mock.calls[0][0];
271
+ // ... but like fetch, the SDK-injected headers must not surface as request-header attributes,
272
+ // even under a catch-all capture pattern.
273
+ expect(span.attributes).not.toContainEqual(expect.objectContaining({ key: "http.request.header.traceparent" }));
274
+ expect(span.attributes).not.toContainEqual(expect.objectContaining({ key: "http.request.header.x-amzn-trace-id" }));
275
+ });
276
+ it("skips span creation and injection when a traceparent header is already present (fetch polyfill over XHR)", async () => {
277
+ vars.propagators = [{ type: "traceparent", match: [] }];
278
+ instrumentXhr();
279
+ // Models a fetch polyfill built on XHR: the fetch instrumentation already created a span and
280
+ // injected trace context for this logical request, and the polyfill replays the headers onto
281
+ // the underlying XHR.
282
+ const existing = "00-4efaaf4d1e8720b39541901950019ee5-53995c3f42cd8ad8-01";
283
+ const xhr = new XMLHttpRequest();
284
+ xhr.open("GET", "/api/test");
285
+ xhr.setRequestHeader("traceparent", existing);
286
+ xhr.send();
287
+ xhr.respond(200);
288
+ // No second injection -- the XHR spec would combine the values into one invalid
289
+ // comma-joined traceparent.
290
+ expect(xhr.requestHeaders["traceparent"]).toBe(existing);
291
+ // And no second span for the same logical request.
292
+ await new Promise((resolve) => setTimeout(resolve, 5));
293
+ expect(sendSpan).not.toHaveBeenCalled();
294
+ });
295
+ it("detects already-present correlation headers case-insensitively and for X-Ray", async () => {
296
+ vars.propagators = [{ type: "traceparent", match: [] }];
297
+ instrumentXhr();
298
+ const xhr = new XMLHttpRequest();
299
+ xhr.open("GET", "/api/test");
300
+ xhr.setRequestHeader("X-Amzn-Trace-Id", "Root=1-4efaaf4d-1e8720b39541901950019ee5");
301
+ xhr.send();
302
+ xhr.respond(200);
303
+ expect(xhr.requestHeaders["traceparent"]).toBeUndefined();
304
+ const xhr2 = new XMLHttpRequest();
305
+ xhr2.open("GET", "/api/test");
306
+ xhr2.setRequestHeader("Traceparent", "00-4efaaf4d1e8720b39541901950019ee5-53995c3f42cd8ad8-01");
307
+ xhr2.send();
308
+ xhr2.respond(200);
309
+ expect(xhr2.requestHeaders["traceparent"]).toBeUndefined();
310
+ await new Promise((resolve) => setTimeout(resolve, 5));
311
+ expect(sendSpan).not.toHaveBeenCalled();
312
+ });
313
+ it("instruments the next request on a reused instance after skipping an already-traced one", async () => {
314
+ vars.propagators = [{ type: "traceparent", match: [] }];
315
+ instrumentXhr();
316
+ const xhr = new XMLHttpRequest();
317
+ xhr.open("GET", "/api/first");
318
+ xhr.setRequestHeader("traceparent", "00-4efaaf4d1e8720b39541901950019ee5-53995c3f42cd8ad8-01");
319
+ xhr.send();
320
+ xhr.respond(200);
321
+ expect(sendSpan).not.toHaveBeenCalled();
322
+ // open() resets the per-request state, so the next request must be traced normally.
323
+ xhr.open("GET", "/api/second");
324
+ xhr.send();
325
+ expect(xhr.requestHeaders["traceparent"]).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
326
+ xhr.respond(200);
327
+ const sendSpanMock = sendSpan;
328
+ await vi.waitFor(() => expect(sendSpanMock).toHaveBeenCalledTimes(1));
329
+ });
330
+ it("normalizes well-known methods to uppercase and records HTTP_METHOD_OTHER for unknown methods", async () => {
331
+ instrumentXhr();
332
+ const xhr = new XMLHttpRequest();
333
+ xhr.open("get", "/api/test");
334
+ xhr.send();
335
+ xhr.respond(200);
336
+ const sendSpanMock = sendSpan;
337
+ await vi.waitFor(() => expect(sendSpanMock).toHaveBeenCalledTimes(1));
338
+ const span = sendSpanMock.mock.calls[0][0];
339
+ expect(span.name).toBe("HTTP GET");
340
+ expect(span.attributes).toContainEqual({ key: "http.request.method", value: { stringValue: "GET" } });
341
+ const xhr2 = new XMLHttpRequest();
342
+ xhr2.open("FROBNICATE", "/api/test");
343
+ xhr2.send();
344
+ xhr2.respond(200);
345
+ await vi.waitFor(() => expect(sendSpanMock).toHaveBeenCalledTimes(2));
346
+ const otherSpan = sendSpanMock.mock.calls[1][0];
347
+ expect(otherSpan.name).toBe("HTTP _OTHER");
348
+ expect(otherSpan.attributes).toContainEqual({ key: "http.request.method", value: { stringValue: "_OTHER" } });
349
+ expect(otherSpan.attributes).toContainEqual({
350
+ key: "http.request.method_original",
351
+ value: { stringValue: "FROBNICATE" },
352
+ });
353
+ });
354
+ it("is safe to call instrumentXhr() twice (double-instrumentation guard)", async () => {
355
+ vars.propagators = [{ type: "traceparent", match: [] }];
356
+ instrumentXhr();
357
+ instrumentXhr();
358
+ const xhr = new XMLHttpRequest();
359
+ xhr.open("GET", "/api/test");
360
+ xhr.send();
361
+ // Double-wrapping would inject traceparent twice, and the XHR spec combines repeated
362
+ // setRequestHeader() values -- the backend would receive one invalid comma-joined header.
363
+ // A single well-formed value proves injection ran exactly once.
364
+ expect(xhr.requestHeaders["traceparent"]).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
365
+ xhr.respond(200);
366
+ const sendSpanMock = sendSpan;
367
+ await vi.waitFor(() => expect(sendSpanMock).toHaveBeenCalledTimes(1));
368
+ expect(sendSpanMock).toHaveBeenCalledTimes(1);
369
+ });
370
+ it("ends the span with status UNSET and captures response headers on a successful response", async () => {
371
+ vars.headersToCapture = [/x-response-header/];
372
+ instrumentXhr();
373
+ const xhr = new XMLHttpRequest();
374
+ xhr.open("GET", "/api/test");
375
+ xhr.send();
376
+ xhr.respond(200, { "x-response-header": "yes", "content-type": "text/plain" });
377
+ const sendSpanMock = sendSpan;
378
+ await vi.waitFor(() => expect(sendSpanMock).toHaveBeenCalledTimes(1));
379
+ expect(sendSpanMock).toHaveBeenCalledTimes(1);
380
+ const span = sendSpanMock.mock.calls[0][0];
381
+ expect(span.status?.code).toBe(0);
382
+ expect(span.attributes).toContainEqual({
383
+ key: "http.response.status_code",
384
+ value: { stringValue: "200" },
385
+ });
386
+ expect(span.attributes).toContainEqual({
387
+ key: "http.response.header.x-response-header",
388
+ value: { stringValue: "yes" },
389
+ });
390
+ expect(span.attributes).not.toContainEqual(expect.objectContaining({ key: "http.response.header.content-type" }));
391
+ });
392
+ it("marks the span as errored (status code ERROR) for a 4xx/5xx response", async () => {
393
+ instrumentXhr();
394
+ const xhr = new XMLHttpRequest();
395
+ xhr.open("GET", "/api/test");
396
+ xhr.send();
397
+ xhr.respond(500);
398
+ const sendSpanMock = sendSpan;
399
+ await vi.waitFor(() => expect(sendSpanMock).toHaveBeenCalledTimes(1));
400
+ const span = sendSpanMock.mock.calls[0][0];
401
+ expect(span.status?.code).toBe(2);
402
+ expect(span.attributes).toContainEqual({
403
+ key: "http.response.status_code",
404
+ value: { stringValue: "500" },
405
+ });
406
+ });
407
+ it("records an exception and marks the span as errored on a network error", () => {
408
+ instrumentXhr();
409
+ const xhr = new XMLHttpRequest();
410
+ xhr.open("GET", "/api/test");
411
+ xhr.send();
412
+ xhr.triggerError();
413
+ const sendSpanMock = sendSpan;
414
+ expect(sendSpanMock).toHaveBeenCalledTimes(1);
415
+ const span = sendSpanMock.mock.calls[0][0];
416
+ expect(span.status?.code).toBe(2);
417
+ expect(span.events.some((e) => e.name === "exception")).toBe(true);
418
+ expect(span.attributes).toContainEqual({ key: "error.type", value: { stringValue: "error" } });
419
+ });
420
+ it("records an exception and marks the span as errored on a timeout", () => {
421
+ instrumentXhr();
422
+ const xhr = new XMLHttpRequest();
423
+ xhr.open("GET", "/api/test");
424
+ xhr.send();
425
+ xhr.triggerTimeout();
426
+ const sendSpanMock = sendSpan;
427
+ const span = sendSpanMock.mock.calls[0][0];
428
+ expect(span.status?.code).toBe(2);
429
+ expect(span.events.some((e) => e.name === "exception")).toBe(true);
430
+ expect(span.attributes).toContainEqual({ key: "error.type", value: { stringValue: "timeout" } });
431
+ });
432
+ it("ends the span as an error and rethrows unchanged when a synchronous send() throws", () => {
433
+ instrumentXhr();
434
+ const xhr = new XMLHttpRequest();
435
+ const removeListenerSpy = vi.spyOn(xhr, "removeEventListener");
436
+ xhr.open("GET", "/api/test", false);
437
+ const networkError = new DOMException("A network error occurred.", "NetworkError");
438
+ xhr.syncSendError = networkError;
439
+ let caught;
440
+ try {
441
+ xhr.send();
442
+ }
443
+ catch (e) {
444
+ caught = e;
445
+ }
446
+ expect(caught).toBe(networkError);
447
+ const sendSpanMock = sendSpan;
448
+ expect(sendSpanMock).toHaveBeenCalledTimes(1);
449
+ const span = sendSpanMock.mock.calls[0][0];
450
+ expect(span.status?.code).toBe(2);
451
+ expect(span.attributes).toContainEqual({ key: "error.type", value: { stringValue: "error" } });
452
+ expect(span.events.some((e) => e.name === "exception")).toBe(true);
453
+ // The per-request listeners must not leak -- loadend never fires for sync failures.
454
+ expect(removeListenerSpy).toHaveBeenCalledTimes(4);
455
+ expect(removeListenerSpy.mock.calls.map((c) => c[0]).sort()).toEqual(["abort", "error", "loadend", "timeout"]);
456
+ });
457
+ it("classifies a synchronous send() TimeoutError as a timeout", () => {
458
+ instrumentXhr();
459
+ const xhr = new XMLHttpRequest();
460
+ xhr.open("GET", "/api/test", false);
461
+ xhr.syncSendError = new DOMException("The request timed out.", "TimeoutError");
462
+ expect(() => xhr.send()).toThrow();
463
+ const sendSpanMock = sendSpan;
464
+ expect(sendSpanMock).toHaveBeenCalledTimes(1);
465
+ const span = sendSpanMock.mock.calls[0][0];
466
+ expect(span.status?.code).toBe(2);
467
+ expect(span.attributes).toContainEqual({ key: "error.type", value: { stringValue: "timeout" } });
468
+ });
469
+ it("still instruments a subsequent request on the same instance after a synchronous send() failure", async () => {
470
+ instrumentXhr();
471
+ const xhr = new XMLHttpRequest();
472
+ xhr.open("GET", "/api/test", false);
473
+ xhr.syncSendError = new DOMException("A network error occurred.", "NetworkError");
474
+ expect(() => xhr.send()).toThrow();
475
+ xhr.syncSendError = undefined;
476
+ xhr.open("GET", "/api/test");
477
+ xhr.send();
478
+ xhr.respond(200);
479
+ const sendSpanMock = sendSpan;
480
+ await vi.waitFor(() => expect(sendSpanMock).toHaveBeenCalledTimes(2));
481
+ const secondSpan = sendSpanMock.mock.calls[1][0];
482
+ expect(secondSpan.attributes).toContainEqual({
483
+ key: "http.response.status_code",
484
+ value: { stringValue: "200" },
485
+ });
486
+ });
487
+ it("marks the span as cancelled (not failed) on abort", () => {
488
+ instrumentXhr();
489
+ const xhr = new XMLHttpRequest();
490
+ xhr.open("GET", "/api/test");
491
+ xhr.send();
492
+ xhr.triggerAbort();
493
+ const sendSpanMock = sendSpan;
494
+ expect(sendSpanMock).toHaveBeenCalledTimes(1);
495
+ const span = sendSpanMock.mock.calls[0][0];
496
+ expect(span.status?.code).toBe(0);
497
+ expect(span.attributes).toContainEqual({
498
+ key: "dash0.web.request.cancelled",
499
+ value: { boolValue: true },
500
+ });
501
+ expect(span.events.find((e) => e.name === "exception")).toBeUndefined();
502
+ });
503
+ it("only completes a span once even if multiple terminal events fire", async () => {
504
+ instrumentXhr();
505
+ const xhr = new XMLHttpRequest();
506
+ xhr.open("GET", "/api/test");
507
+ xhr.send();
508
+ xhr.respond(200);
509
+ // Simulate a spurious extra loadend (some browsers/polyfills have done this historically)
510
+ xhr.dispatchEvent(new Event("loadend"));
511
+ const sendSpanMock = sendSpan;
512
+ await vi.waitFor(() => expect(sendSpanMock).toHaveBeenCalledTimes(1));
513
+ expect(sendSpanMock).toHaveBeenCalledTimes(1);
514
+ });
515
+ it("treats a reused XHR instance's second open() as a fresh request with its own span", async () => {
516
+ instrumentXhr();
517
+ const xhr = new XMLHttpRequest();
518
+ xhr.open("GET", "/api/first");
519
+ xhr.send();
520
+ xhr.respond(200);
521
+ xhr.open("GET", "/api/second");
522
+ xhr.send();
523
+ xhr.respond(201);
524
+ const sendSpanMock = sendSpan;
525
+ await vi.waitFor(() => expect(sendSpanMock).toHaveBeenCalledTimes(2));
526
+ expect(sendSpanMock).toHaveBeenCalledTimes(2);
527
+ const firstSpan = sendSpanMock.mock.calls[0][0];
528
+ const secondSpan = sendSpanMock.mock.calls[1][0];
529
+ expect(firstSpan.spanId).not.toBe(secondSpan.spanId);
530
+ expect(secondSpan.attributes).toContainEqual({
531
+ key: "http.response.status_code",
532
+ value: { stringValue: "201" },
533
+ });
534
+ });
535
+ it("ends the previous span as cancelled when open() is called while a request is in flight", async () => {
536
+ instrumentXhr();
537
+ const xhr = new XMLHttpRequest();
538
+ xhr.open("GET", "/api/first");
539
+ xhr.send();
540
+ // Reopen before the first request completes -- per spec this terminates the in-flight fetch
541
+ // without firing abort/loadend.
542
+ xhr.open("GET", "/api/second");
543
+ const sendSpanMock = sendSpan;
544
+ expect(sendSpanMock).toHaveBeenCalledTimes(1);
545
+ const firstSpan = sendSpanMock.mock.calls[0][0];
546
+ expect(firstSpan.attributes).toContainEqual({
547
+ key: "url.full",
548
+ value: { stringValue: "http://localhost:3000/api/first" },
549
+ });
550
+ expect(firstSpan.attributes).toContainEqual({
551
+ key: "dash0.web.request.cancelled",
552
+ value: { boolValue: true },
553
+ });
554
+ expect(firstSpan.attributes.find((a) => a.key === "http.response.status_code")).toBeUndefined();
555
+ xhr.send();
556
+ xhr.respond(200);
557
+ await vi.waitFor(() => expect(sendSpanMock).toHaveBeenCalledTimes(2));
558
+ // The second request's completion must end its own span -- the first request's stale loadend
559
+ // listener must not have re-ended the cancelled span with the new request's status.
560
+ const secondSpan = sendSpanMock.mock.calls[1][0];
561
+ expect(secondSpan.spanId).not.toBe(firstSpan.spanId);
562
+ expect(secondSpan.attributes).toContainEqual({
563
+ key: "url.full",
564
+ value: { stringValue: "http://localhost:3000/api/second" },
565
+ });
566
+ expect(secondSpan.attributes).toContainEqual({
567
+ key: "http.response.status_code",
568
+ value: { stringValue: "200" },
569
+ });
570
+ expect(sendSpanMock).toHaveBeenCalledTimes(2);
571
+ });
572
+ it("does not create a second span when send() is called twice", async () => {
573
+ instrumentXhr();
574
+ const xhr = new XMLHttpRequest();
575
+ const addListenerSpy = vi.spyOn(xhr, "addEventListener");
576
+ xhr.open("GET", "/api/test");
577
+ xhr.send();
578
+ // A second send() on an in-flight request throws InvalidStateError natively; the SDK must not
579
+ // create a second span or attach a second set of listeners for it.
580
+ xhr.send();
581
+ xhr.respond(200);
582
+ const sendSpanMock = sendSpan;
583
+ await vi.waitFor(() => expect(sendSpanMock).toHaveBeenCalledTimes(1));
584
+ expect(sendSpanMock).toHaveBeenCalledTimes(1);
585
+ expect(addListenerSpy).toHaveBeenCalledTimes(4);
586
+ const span = sendSpanMock.mock.calls[0][0];
587
+ expect(span.attributes).toContainEqual({
588
+ key: "http.response.status_code",
589
+ value: { stringValue: "200" },
590
+ });
591
+ });
592
+ it("removes the per-request listeners once the request completes", async () => {
593
+ instrumentXhr();
594
+ const xhr = new XMLHttpRequest();
595
+ const removeListenerSpy = vi.spyOn(xhr, "removeEventListener");
596
+ xhr.open("GET", "/api/test");
597
+ xhr.send();
598
+ xhr.respond(200);
599
+ const sendSpanMock = sendSpan;
600
+ await vi.waitFor(() => expect(sendSpanMock).toHaveBeenCalledTimes(1));
601
+ expect(removeListenerSpy).toHaveBeenCalledTimes(4);
602
+ expect(removeListenerSpy.mock.calls.map((c) => c[0]).sort()).toEqual(["abort", "error", "loadend", "timeout"]);
603
+ // Spurious late events after completion must not affect the already-sent span...
604
+ xhr.dispatchEvent(new Event("error"));
605
+ xhr.dispatchEvent(new Event("loadend"));
606
+ expect(sendSpanMock).toHaveBeenCalledTimes(1);
607
+ // ...and a subsequent request cycle on the same instance still produces exactly one more span.
608
+ xhr.open("GET", "/api/test");
609
+ xhr.send();
610
+ xhr.respond(200);
611
+ await vi.waitFor(() => expect(sendSpanMock).toHaveBeenCalledTimes(2));
612
+ });
613
+ // The tests below verify that SDK-internal errors never escape into the page's synchronous
614
+ // open()/send() calls -- a misconfigured SDK must degrade to "no telemetry", never to a
615
+ // page-wide XHR outage.
616
+ it("does not break the page's XHR when ignoreUrls contains plain strings instead of RegExps", () => {
617
+ vars.ignoreUrls = ["/health"];
618
+ instrumentXhr();
619
+ const xhr = new XMLHttpRequest();
620
+ expect(() => {
621
+ xhr.open("GET", "/health");
622
+ xhr.send("payload");
623
+ }).not.toThrow();
624
+ // The native methods must still have run...
625
+ expect(xhr.url).toBe("/health");
626
+ expect(xhr.sentBody).toBe("payload");
627
+ // ...while the request goes untracked.
628
+ xhr.respond(200);
629
+ expect(sendSpan).not.toHaveBeenCalled();
630
+ });
631
+ it("does not break the page's XHR when a propagator match contains plain strings instead of RegExps", () => {
632
+ vars.propagators = [{ type: "traceparent", match: ["http://foo.bar/"] }];
633
+ instrumentXhr();
634
+ const xhr = new XMLHttpRequest();
635
+ expect(() => {
636
+ xhr.open("GET", "http://foo.bar/foo");
637
+ xhr.send();
638
+ }).not.toThrow();
639
+ expect(xhr.url).toBe("http://foo.bar/foo");
640
+ xhr.respond(200);
641
+ expect(sendSpan).not.toHaveBeenCalled();
642
+ });
643
+ it("does not break the page's XHR when a header capture matcher throws", async () => {
644
+ vars.headersToCapture = [
645
+ {
646
+ test: () => {
647
+ throw new Error("boom");
648
+ },
649
+ },
650
+ ];
651
+ instrumentXhr();
652
+ const xhr = new XMLHttpRequest();
653
+ xhr.open("GET", "/api/test");
654
+ // The matcher throws inside the wrapped setRequestHeader -- the page's call must succeed
655
+ // and the header must still reach the request.
656
+ expect(() => xhr.setRequestHeader("x-test-header", "hello")).not.toThrow();
657
+ expect(() => xhr.send("payload")).not.toThrow();
658
+ expect(xhr.requestHeaders["x-test-header"]).toBe("hello");
659
+ expect(xhr.sentBody).toBe("payload");
660
+ xhr.respond(200);
661
+ // The request is still tracked normally -- only the header capture is skipped.
662
+ const sendSpanMock = sendSpan;
663
+ await vi.waitFor(() => expect(sendSpanMock).toHaveBeenCalledTimes(1));
664
+ const span = sendSpanMock.mock.calls[0][0];
665
+ expect(span.attributes).not.toContainEqual(expect.objectContaining({ key: "http.request.header.x-test-header" }));
666
+ });
667
+ it("accepts a non-string method just like native XHR does", async () => {
668
+ instrumentXhr();
669
+ const xhr = new XMLHttpRequest();
670
+ expect(() => {
671
+ xhr.open(123, "/api/test");
672
+ xhr.send();
673
+ }).not.toThrow();
674
+ xhr.respond(200);
675
+ const sendSpanMock = sendSpan;
676
+ await vi.waitFor(() => expect(sendSpanMock).toHaveBeenCalledTimes(1));
677
+ const span = sendSpanMock.mock.calls[0][0];
678
+ expect(span.name).toBe("HTTP _OTHER");
679
+ expect(span.attributes).toContainEqual({ key: "http.request.method_original", value: { stringValue: "123" } });
680
+ });
681
+ it("evaluates a custom URL object's toString only once across SDK and native open()", () => {
682
+ instrumentXhr();
683
+ const toString = vi.fn(() => "/api/test");
684
+ const xhr = new XMLHttpRequest();
685
+ xhr.open("GET", { toString });
686
+ expect(toString).toHaveBeenCalledTimes(1);
687
+ expect(xhr.url).toBe("/api/test");
688
+ });
689
+ it("does not throw out of init when the page locked the XMLHttpRequest prototype", () => {
690
+ class LockedXhr extends EventTarget {
691
+ open() { }
692
+ setRequestHeader() { }
693
+ send() { }
694
+ }
695
+ for (const method of ["open", "setRequestHeader", "send"]) {
696
+ Object.defineProperty(LockedXhr.prototype, method, {
697
+ value: LockedXhr.prototype[method],
698
+ writable: false,
699
+ configurable: false,
700
+ });
701
+ }
702
+ vi.stubGlobal("XMLHttpRequest", LockedXhr);
703
+ expect(() => instrumentXhr()).not.toThrow();
704
+ });
705
+ });