@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,7 +1,7 @@
1
1
  import { debug, observeResourcePerformance, perf, win, setTimeout, isSameOrigin, wrap, parseUrl, clearTimeout, } from "../../utils";
2
2
  import { isUrlIgnored, matchesAny } from "../../utils/ignore-rules";
3
3
  import { addAttribute, setSpanStatus, addW3CTraceContextHttpHeaders, addXRayTraceContextHttpHeaders, endSpan, errorToSpanStatus, recordException, startSpan, } from "../../utils/otel";
4
- import { ERROR_TYPE, HTTP_REQUEST_METHOD, HTTP_REQUEST_METHOD_ORIGINAL, HTTP_RESPONSE_STATUS_CODE, SPAN_STATUS_ERROR, SPAN_STATUS_UNSET, } from "../../semantic-conventions";
4
+ import { ERROR_TYPE, HTTP_REQUEST_METHOD, HTTP_REQUEST_METHOD_ORIGINAL, HTTP_RESPONSE_STATUS_CODE, SPAN_STATUS_ERROR, SPAN_STATUS_UNSET, WEB_REQUEST_CANCELLED, } from "../../semantic-conventions";
5
5
  import { vars } from "../../vars";
6
6
  import { httpRequestHeaderKey, httpResponseHeaderKey } from "../../utils/otel/http";
7
7
  import { sendSpan } from "../../transport";
@@ -86,12 +86,22 @@ function wrapFetch(original) {
86
86
  addResponseData(span, origResponse);
87
87
  return wrapResponse(origResponse, vars.maxToleranceForResourceTimingsMillis, () => performanceObserver.end(), (e) => {
88
88
  performanceObserver.cancel();
89
- endSpanOnError(span, e);
89
+ if (request.signal?.aborted) {
90
+ endSpanOnAbort(span);
91
+ }
92
+ else {
93
+ endSpanOnError(span, e);
94
+ }
90
95
  });
91
96
  }
92
97
  catch (e) {
93
98
  performanceObserver.cancel();
94
- endSpanOnError(span, e);
99
+ if (request.signal?.aborted) {
100
+ endSpanOnAbort(span);
101
+ }
102
+ else {
103
+ endSpanOnError(span, e);
104
+ }
95
105
  throw e;
96
106
  }
97
107
  };
@@ -224,6 +234,10 @@ function endSpanOnError(span, error) {
224
234
  recordException(span, error);
225
235
  sendSpan(endSpan(span, errorToSpanStatus(error), undefined));
226
236
  }
237
+ function endSpanOnAbort(span) {
238
+ addAttribute(span.attributes, WEB_REQUEST_CANCELLED, true);
239
+ sendSpan(endSpan(span, undefined, undefined));
240
+ }
227
241
  function determinePropagatorTypes(url) {
228
242
  const matchingTypes = [];
229
243
  const isUrlSameOrigin = isSameOrigin(url);
@@ -1,6 +1,10 @@
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
+ vi.mock("../../transport", () => ({
6
+ sendSpan: vi.fn(),
7
+ }));
4
8
  describe("fetch test", () => {
5
9
  let fetchMock;
6
10
  beforeEach(() => {
@@ -167,4 +171,103 @@ describe("fetch test", () => {
167
171
  expect(fetchHeaders.get("traceparent")).not.toBeNull();
168
172
  expect(fetchHeaders.get("X-Amzn-Trace-Id")).toBeNull();
169
173
  });
174
+ describe("aborted requests", () => {
175
+ const sendSpanMock = sendSpan;
176
+ const NativeRequest = Request;
177
+ const lastSpan = () => {
178
+ const calls = sendSpanMock.mock.calls;
179
+ return calls[calls.length - 1][0];
180
+ };
181
+ const hasAttribute = (span, key, expectedValue) => span.attributes.some((a) => a.key === key && JSON.stringify(a.value) === JSON.stringify(expectedValue));
182
+ beforeEach(() => {
183
+ sendSpanMock.mockClear();
184
+ // jsdom's Request rejects its own AbortSignal instances (known jsdom bug).
185
+ // Stub Request with a minimal implementation that preserves signals verbatim.
186
+ class FakeRequest {
187
+ url;
188
+ method;
189
+ headers;
190
+ signal;
191
+ constructor(input, init) {
192
+ if (input instanceof FakeRequest) {
193
+ this.url = input.url;
194
+ this.method = init?.method ?? input.method;
195
+ this.headers = new Headers(init?.headers ?? input.headers);
196
+ this.signal = init?.signal ?? input.signal;
197
+ }
198
+ else {
199
+ this.url = String(input);
200
+ this.method = init?.method ?? "GET";
201
+ this.headers = new Headers(init?.headers);
202
+ this.signal = init?.signal ?? undefined;
203
+ }
204
+ }
205
+ }
206
+ vi.stubGlobal("Request", FakeRequest);
207
+ });
208
+ afterEach(() => {
209
+ vi.stubGlobal("Request", NativeRequest);
210
+ });
211
+ it("marks fetch as cancelled when the abort signal fires before the response arrives", async () => {
212
+ const controller = new AbortController();
213
+ fetchMock.mockImplementation((_input, init) => new Promise((_resolve, reject) => {
214
+ const signal = init?.signal;
215
+ if (signal?.aborted) {
216
+ reject(new DOMException("aborted", "AbortError"));
217
+ return;
218
+ }
219
+ signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
220
+ }));
221
+ instrumentFetch();
222
+ // eslint-disable-next-line no-restricted-globals
223
+ const pending = fetch("http://localhost:3000/api/test", { signal: controller.signal });
224
+ controller.abort();
225
+ await expect(pending).rejects.toBeInstanceOf(DOMException);
226
+ expect(sendSpanMock).toHaveBeenCalledTimes(1);
227
+ const span = lastSpan();
228
+ expect(span.status?.code).toBe(0);
229
+ expect(span.status?.message).toBeUndefined();
230
+ expect(hasAttribute(span, "dash0.web.request.cancelled", { boolValue: true })).toBe(true);
231
+ expect(span.events.find((e) => e.name === "exception")).toBeUndefined();
232
+ });
233
+ it("marks fetch as cancelled when the abort signal fires while reading the response body", async () => {
234
+ const controller = new AbortController();
235
+ const body = new ReadableStream({
236
+ start(streamController) {
237
+ streamController.enqueue(new Uint8Array([0x68, 0x69]));
238
+ controller.signal.addEventListener("abort", () => {
239
+ streamController.error(new DOMException("aborted", "AbortError"));
240
+ });
241
+ },
242
+ });
243
+ fetchMock.mockImplementation(() => Promise.resolve(new Response(body, { status: 200 })));
244
+ instrumentFetch();
245
+ // eslint-disable-next-line no-restricted-globals
246
+ const response = await fetch("http://localhost:3000/api/test", { signal: controller.signal });
247
+ const reader = response.body.getReader();
248
+ await reader.read();
249
+ controller.abort();
250
+ await expect(reader.read()).rejects.toBeInstanceOf(DOMException);
251
+ expect(sendSpanMock).toHaveBeenCalledTimes(1);
252
+ const span = lastSpan();
253
+ // status was set from the 200 response and should not be overwritten
254
+ expect(span.status?.code).toBe(0);
255
+ expect(span.status?.message).toBeUndefined();
256
+ expect(hasAttribute(span, "dash0.web.request.cancelled", { boolValue: true })).toBe(true);
257
+ expect(hasAttribute(span, "http.response.status_code", { stringValue: "200" })).toBe(true);
258
+ expect(span.events.find((e) => e.name === "exception")).toBeUndefined();
259
+ });
260
+ it("still treats non-abort rejections as failures", async () => {
261
+ fetchMock.mockImplementation(() => Promise.reject(new TypeError("network down")));
262
+ instrumentFetch();
263
+ // eslint-disable-next-line no-restricted-globals
264
+ await expect(fetch("http://localhost:3000/api/test")).rejects.toBeInstanceOf(TypeError);
265
+ expect(sendSpanMock).toHaveBeenCalledTimes(1);
266
+ const span = lastSpan();
267
+ expect(span.status?.code).toBe(2);
268
+ expect(span.status?.message).toBe("network down");
269
+ expect(hasAttribute(span, "dash0.web.request.cancelled", { boolValue: true })).toBe(false);
270
+ expect(span.events.some((e) => e.name === "exception")).toBe(true);
271
+ });
272
+ });
170
273
  });
@@ -9,6 +9,7 @@ export const DEPLOYMENT_ID = "deployment.id";
9
9
  export const EVENT_NAME = "event.name";
10
10
  export const WEB_EVENT_TITLE = "dash0.web.event.title";
11
11
  export const WEB_EVENT_ID = "dash0.web.event.id";
12
+ export const WEB_REQUEST_CANCELLED = "dash0.web.request.cancelled";
12
13
  export const PAGE_LOAD_ID = "page.load.id";
13
14
  export const SESSION_ID = "session.id";
14
15
  export const USER_AGENT = "user_agent.original";