@dash0/sdk-web 0.18.5 → 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,6 @@
1
1
  import { vars } from "../vars";
2
2
  import { DEPLOYMENT_ENVIRONMENT_NAME, DEPLOYMENT_ID, DEPLOYMENT_NAME, PAGE_LOAD_ID, SERVICE_NAME, SERVICE_NAMESPACE, SERVICE_VERSION, USER_AGENT, } from "../semantic-conventions";
3
- import { fetch, generateUniqueId, PAGE_LOAD_ID_BYTES, warn, debug, perf, nav, win, NO_VALUE_FALLBACK, pick, loc, } from "../utils";
3
+ import { fetch, generateUniqueId, isSafeServiceName, PAGE_LOAD_ID_BYTES, warn, debug, perf, nav, win, NO_VALUE_FALLBACK, pick, loc, } from "../utils";
4
4
  import { trackSessions } from "./session";
5
5
  import { startWebVitalsInstrumentation } from "../instrumentations/web-vitals";
6
6
  import { startErrorInstrumentation } from "../instrumentations/errors";
@@ -23,10 +23,15 @@ export function init(opts) {
23
23
  debug("Stopping Dash0 Web SDK initialization. This browser does not support the necessary APIs.");
24
24
  return;
25
25
  }
26
- if (!opts.serviceName.trim()) {
26
+ const trimmedServiceName = opts.serviceName.trim();
27
+ if (!trimmedServiceName) {
27
28
  debug("Missing or empty serviceName value. Falling back to location.hostname.");
28
29
  opts.serviceName = loc?.hostname ?? "unknown";
29
30
  }
31
+ else if (opts.rejectSuspiciousServiceName !== false && !isSafeServiceName(trimmedServiceName)) {
32
+ debug("serviceName contains disallowed characters. Falling back to location.hostname.");
33
+ opts.serviceName = loc?.hostname ?? "unknown";
34
+ }
30
35
  vars.endpoints = opts.endpoint instanceof Array ? opts.endpoint : [opts.endpoint];
31
36
  if (vars.endpoints.length === 0) {
32
37
  warn("No telemetry endpoint configured. Aborting Dash0 Web SDK initialization process.");
@@ -236,6 +236,64 @@ describe("init", () => {
236
236
  const serviceNameAttr = vars.resource.attributes.find((attr) => attr.key === SERVICE_NAME);
237
237
  expect(serviceNameAttr?.value.stringValue).toBe("test-hostname.example.com");
238
238
  });
239
+ it.each([
240
+ ["single quote", "evil';DROP TABLE users;--"],
241
+ ["double quote", 'svc"name'],
242
+ ["semicolon", "svc;injected"],
243
+ ["open brace", "svc${tpl}"],
244
+ ["close brace", "svc}name"],
245
+ ["less-than", "<script>"],
246
+ ["greater-than", "svc>name"],
247
+ ["embedded newline", "svc\nname"],
248
+ ["NUL byte", "svc\x00name"],
249
+ ])("should fallback to location.hostname by default when serviceName contains %s", async (_label, suspicious) => {
250
+ init({
251
+ ...baseOptions,
252
+ serviceName: suspicious,
253
+ });
254
+ const serviceNameAttr = vars.resource.attributes.find((attr) => attr.key === SERVICE_NAME);
255
+ expect(serviceNameAttr?.value.stringValue).toBe("test-hostname.example.com");
256
+ });
257
+ it.each([
258
+ ["single quote", "evil';DROP TABLE users;--"],
259
+ ["double quote", 'svc"name'],
260
+ ["semicolon", "svc;injected"],
261
+ ["embedded newline", "svc\nname"],
262
+ ])("should also fallback when rejectSuspiciousServiceName is explicitly true and serviceName contains %s", async (_label, suspicious) => {
263
+ init({
264
+ ...baseOptions,
265
+ serviceName: suspicious,
266
+ rejectSuspiciousServiceName: true,
267
+ });
268
+ const serviceNameAttr = vars.resource.attributes.find((attr) => attr.key === SERVICE_NAME);
269
+ expect(serviceNameAttr?.value.stringValue).toBe("test-hostname.example.com");
270
+ });
271
+ it.each([
272
+ ["single quote", "evil';DROP TABLE users;--"],
273
+ ["semicolon", "svc;injected"],
274
+ ["less-than", "<script>"],
275
+ ])("should keep suspicious serviceName unchanged when rejectSuspiciousServiceName is explicitly false (%s)", async (_label, suspicious) => {
276
+ init({
277
+ ...baseOptions,
278
+ serviceName: suspicious,
279
+ rejectSuspiciousServiceName: false,
280
+ });
281
+ const serviceNameAttr = vars.resource.attributes.find((attr) => attr.key === SERVICE_NAME);
282
+ expect(serviceNameAttr?.value.stringValue).toBe(suspicious);
283
+ });
284
+ it.each([
285
+ ["forward slash", "myteam/myservice"],
286
+ ["backslash", "myteam\\myservice"],
287
+ ["spaces", "my service"],
288
+ ["dots and hyphens", "svc-name.v2"],
289
+ ])("should keep serviceName with allowed characters like %s under the default configuration", async (_label, allowed) => {
290
+ init({
291
+ ...baseOptions,
292
+ serviceName: allowed,
293
+ });
294
+ const serviceNameAttr = vars.resource.attributes.find((attr) => attr.key === SERVICE_NAME);
295
+ expect(serviceNameAttr?.value.stringValue).toBe(allowed);
296
+ });
239
297
  it("should fallback to 'unknown' when serviceName is empty and location.hostname is not available", async () => {
240
298
  vi.resetModules();
241
299
  // Mock utils module with loc as undefined
@@ -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";
@@ -14,4 +14,5 @@ export * from "./math";
14
14
  export * from "./origin";
15
15
  export * from "./url";
16
16
  export * from "./pick";
17
+ export * from "./sanitize";
17
18
  export * from "./wrap";
@@ -0,0 +1,12 @@
1
+ const SUSPICIOUS_CHARS = /['"<>{};\x00-\x1F\x7F]/;
2
+ /**
3
+ * Returns true if the value is safe to use as a `service.name` resource attribute.
4
+ *
5
+ * Rejects values containing characters commonly used in injection payloads:
6
+ * quotes, angle brackets, braces, semicolons, and C0 control characters / DEL.
7
+ * Forward and back slashes are intentionally permitted so names like
8
+ * `myteam/myservice` remain valid.
9
+ */
10
+ export function isSafeServiceName(value) {
11
+ return !SUSPICIOUS_CHARS.test(value);
12
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isSafeServiceName } from "./sanitize";
3
+ describe("isSafeServiceName", () => {
4
+ describe("accepts safe values", () => {
5
+ it.each([
6
+ "my-service",
7
+ "my_service",
8
+ "my.service",
9
+ "MyService123",
10
+ "myteam/myservice",
11
+ "myteam\\myservice",
12
+ "service with spaces",
13
+ "service-name.v2",
14
+ "résumé-service",
15
+ "サービス",
16
+ "a",
17
+ ])("accepts %j", (value) => {
18
+ expect(isSafeServiceName(value)).toBe(true);
19
+ });
20
+ it("accepts the empty string (empty/whitespace handling lives elsewhere)", () => {
21
+ expect(isSafeServiceName("")).toBe(true);
22
+ });
23
+ });
24
+ describe("rejects suspicious characters", () => {
25
+ it.each([
26
+ ["single quote", "evil';DROP TABLE"],
27
+ ["double quote", 'say "hi"'],
28
+ ["semicolon", "a;b"],
29
+ ["open brace", "${injected}"],
30
+ ["close brace", "trailing}"],
31
+ ["less-than", "<script>"],
32
+ ["greater-than", "value>other"],
33
+ ])("rejects %s", (_label, value) => {
34
+ expect(isSafeServiceName(value)).toBe(false);
35
+ });
36
+ it("rejects NUL", () => {
37
+ expect(isSafeServiceName("a\x00b")).toBe(false);
38
+ });
39
+ it("rejects embedded newline (log injection)", () => {
40
+ expect(isSafeServiceName("line1\nline2")).toBe(false);
41
+ });
42
+ it("rejects embedded carriage return", () => {
43
+ expect(isSafeServiceName("a\rb")).toBe(false);
44
+ });
45
+ it("rejects embedded tab", () => {
46
+ expect(isSafeServiceName("a\tb")).toBe(false);
47
+ });
48
+ it("rejects DEL", () => {
49
+ expect(isSafeServiceName("a\x7Fb")).toBe(false);
50
+ });
51
+ });
52
+ });