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