@dash0/sdk-web 0.19.0 → 0.20.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.
@@ -1,6 +1,12 @@
1
1
  import { expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import { vars } from "../../vars";
3
3
  import { instrumentFetch } from "./fetch";
4
+ import { sendSpan } from "../../transport";
5
+ import type { Span } from "../../types/otlp";
6
+
7
+ vi.mock("../../transport", () => ({
8
+ sendSpan: vi.fn(),
9
+ }));
4
10
 
5
11
  describe("fetch test", () => {
6
12
  let fetchMock: any;
@@ -188,4 +194,121 @@ describe("fetch test", () => {
188
194
  expect(fetchHeaders.get("traceparent")).not.toBeNull();
189
195
  expect(fetchHeaders.get("X-Amzn-Trace-Id")).toBeNull();
190
196
  });
197
+
198
+ describe("aborted requests", () => {
199
+ const sendSpanMock = sendSpan as unknown as ReturnType<typeof vi.fn>;
200
+ const NativeRequest = Request;
201
+
202
+ const lastSpan = (): Span => {
203
+ const calls = sendSpanMock.mock.calls;
204
+ return calls[calls.length - 1]![0] as Span;
205
+ };
206
+
207
+ const hasAttribute = (span: Span, key: string, expectedValue: unknown) =>
208
+ span.attributes.some((a) => a.key === key && JSON.stringify(a.value) === JSON.stringify(expectedValue));
209
+
210
+ beforeEach(() => {
211
+ sendSpanMock.mockClear();
212
+ // jsdom's Request rejects its own AbortSignal instances (known jsdom bug).
213
+ // Stub Request with a minimal implementation that preserves signals verbatim.
214
+ class FakeRequest {
215
+ url: string;
216
+ method: string;
217
+ headers: Headers;
218
+ signal: AbortSignal | undefined;
219
+ constructor(input: string | FakeRequest, init?: RequestInit) {
220
+ if (input instanceof FakeRequest) {
221
+ this.url = input.url;
222
+ this.method = init?.method ?? input.method;
223
+ this.headers = new Headers(init?.headers ?? input.headers);
224
+ this.signal = init?.signal ?? input.signal;
225
+ } else {
226
+ this.url = String(input);
227
+ this.method = init?.method ?? "GET";
228
+ this.headers = new Headers(init?.headers);
229
+ this.signal = init?.signal ?? undefined;
230
+ }
231
+ }
232
+ }
233
+ vi.stubGlobal("Request", FakeRequest);
234
+ });
235
+
236
+ afterEach(() => {
237
+ vi.stubGlobal("Request", NativeRequest);
238
+ });
239
+
240
+ it("marks fetch as cancelled when the abort signal fires before the response arrives", async () => {
241
+ const controller = new AbortController();
242
+ fetchMock.mockImplementation(
243
+ (_input: RequestInfo, init?: RequestInit) =>
244
+ new Promise((_resolve, reject) => {
245
+ const signal = init?.signal;
246
+ if (signal?.aborted) {
247
+ reject(new DOMException("aborted", "AbortError"));
248
+ return;
249
+ }
250
+ signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
251
+ })
252
+ );
253
+
254
+ instrumentFetch();
255
+ // eslint-disable-next-line no-restricted-globals
256
+ const pending = fetch("http://localhost:3000/api/test", { signal: controller.signal });
257
+ controller.abort();
258
+ await expect(pending).rejects.toBeInstanceOf(DOMException);
259
+
260
+ expect(sendSpanMock).toHaveBeenCalledTimes(1);
261
+ const span = lastSpan();
262
+ expect(span.status?.code).toBe(0);
263
+ expect(span.status?.message).toBeUndefined();
264
+ expect(hasAttribute(span, "dash0.web.request.cancelled", { boolValue: true })).toBe(true);
265
+ expect(span.events.find((e) => e.name === "exception")).toBeUndefined();
266
+ });
267
+
268
+ it("marks fetch as cancelled when the abort signal fires while reading the response body", async () => {
269
+ const controller = new AbortController();
270
+ const body = new ReadableStream({
271
+ start(streamController) {
272
+ streamController.enqueue(new Uint8Array([0x68, 0x69]));
273
+ controller.signal.addEventListener("abort", () => {
274
+ streamController.error(new DOMException("aborted", "AbortError"));
275
+ });
276
+ },
277
+ });
278
+
279
+ fetchMock.mockImplementation(() => Promise.resolve(new Response(body, { status: 200 })));
280
+
281
+ instrumentFetch();
282
+ // eslint-disable-next-line no-restricted-globals
283
+ const response = await fetch("http://localhost:3000/api/test", { signal: controller.signal });
284
+ const reader = response.body!.getReader();
285
+ await reader.read();
286
+ controller.abort();
287
+ await expect(reader.read()).rejects.toBeInstanceOf(DOMException);
288
+
289
+ expect(sendSpanMock).toHaveBeenCalledTimes(1);
290
+ const span = lastSpan();
291
+ // status was set from the 200 response and should not be overwritten
292
+ expect(span.status?.code).toBe(0);
293
+ expect(span.status?.message).toBeUndefined();
294
+ expect(hasAttribute(span, "dash0.web.request.cancelled", { boolValue: true })).toBe(true);
295
+ expect(hasAttribute(span, "http.response.status_code", { stringValue: "200" })).toBe(true);
296
+ expect(span.events.find((e) => e.name === "exception")).toBeUndefined();
297
+ });
298
+
299
+ it("still treats non-abort rejections as failures", async () => {
300
+ fetchMock.mockImplementation(() => Promise.reject(new TypeError("network down")));
301
+
302
+ instrumentFetch();
303
+ // eslint-disable-next-line no-restricted-globals
304
+ await expect(fetch("http://localhost:3000/api/test")).rejects.toBeInstanceOf(TypeError);
305
+
306
+ expect(sendSpanMock).toHaveBeenCalledTimes(1);
307
+ const span = lastSpan();
308
+ expect(span.status?.code).toBe(2);
309
+ expect(span.status?.message).toBe("network down");
310
+ expect(hasAttribute(span, "dash0.web.request.cancelled", { boolValue: true })).toBe(false);
311
+ expect(span.events.some((e) => e.name === "exception")).toBe(true);
312
+ });
313
+ });
191
314
  });
@@ -10,6 +10,7 @@ export const DEPLOYMENT_ID = "deployment.id";
10
10
  export const EVENT_NAME = "event.name";
11
11
  export const WEB_EVENT_TITLE = "dash0.web.event.title";
12
12
  export const WEB_EVENT_ID = "dash0.web.event.id";
13
+ export const WEB_REQUEST_CANCELLED = "dash0.web.request.cancelled";
13
14
  export const PAGE_LOAD_ID = "page.load.id";
14
15
  export const SESSION_ID = "session.id";
15
16
  export const USER_AGENT = "user_agent.original";