@dash0/sdk-web 0.13.5 → 0.14.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,417 @@
1
+ import { expect, describe, it, beforeEach, afterEach } from "vitest";
2
+ import { addUrlAttributes, UrlAttributeScrubber } from "./url";
3
+ import { KeyValue } from "../types/otlp";
4
+ import { vars } from "../vars";
5
+ import { identity } from "../utils/fn";
6
+ import { URL_DOMAIN, URL_FRAGMENT, URL_FULL, URL_PATH, URL_QUERY, URL_SCHEME } from "../semantic-conventions";
7
+
8
+ describe("addUrlAttributes", () => {
9
+ let attributes: KeyValue[];
10
+ let originalScrubber: UrlAttributeScrubber;
11
+
12
+ beforeEach(() => {
13
+ attributes = [];
14
+ originalScrubber = vars.urlAttributeScrubber;
15
+ vars.urlAttributeScrubber = identity;
16
+ });
17
+
18
+ afterEach(() => {
19
+ vars.urlAttributeScrubber = originalScrubber;
20
+ });
21
+
22
+ describe("URL parsing and attribute extraction", () => {
23
+ it("extracts all URL components for complete URL", () => {
24
+ const url = "https://example.com:8080/path/to/resource?param1=value1&param2=value2#section1";
25
+
26
+ addUrlAttributes(attributes, url);
27
+
28
+ expect(attributes).toEqual([
29
+ {
30
+ key: URL_FULL,
31
+ value: { stringValue: "https://example.com:8080/path/to/resource?param1=value1&param2=value2#section1" },
32
+ },
33
+ { key: URL_PATH, value: { stringValue: "/path/to/resource" } },
34
+ { key: URL_DOMAIN, value: { stringValue: "example.com" } },
35
+ { key: URL_SCHEME, value: { stringValue: "https" } },
36
+ { key: URL_FRAGMENT, value: { stringValue: "section1" } },
37
+ { key: URL_QUERY, value: { stringValue: "param1=value1&param2=value2" } },
38
+ ]);
39
+ });
40
+
41
+ it("handles URL with only required components", () => {
42
+ const url = "https://example.com";
43
+
44
+ addUrlAttributes(attributes, url);
45
+
46
+ expect(attributes).toEqual([
47
+ { key: URL_FULL, value: { stringValue: "https://example.com/" } },
48
+ { key: URL_PATH, value: { stringValue: "/" } },
49
+ { key: URL_DOMAIN, value: { stringValue: "example.com" } },
50
+ { key: URL_SCHEME, value: { stringValue: "https" } },
51
+ ]);
52
+ });
53
+
54
+ it("handles URL with path but no query or fragment", () => {
55
+ const url = "http://example.com/some/path";
56
+
57
+ addUrlAttributes(attributes, url);
58
+
59
+ expect(attributes).toEqual([
60
+ { key: URL_FULL, value: { stringValue: "http://example.com/some/path" } },
61
+ { key: URL_PATH, value: { stringValue: "/some/path" } },
62
+ { key: URL_DOMAIN, value: { stringValue: "example.com" } },
63
+ { key: URL_SCHEME, value: { stringValue: "http" } },
64
+ ]);
65
+ });
66
+
67
+ it("handles URL with query but no fragment", () => {
68
+ const url = "https://example.com/path?query=test";
69
+
70
+ addUrlAttributes(attributes, url);
71
+
72
+ expect(attributes).toEqual([
73
+ { key: URL_FULL, value: { stringValue: "https://example.com/path?query=test" } },
74
+ { key: URL_PATH, value: { stringValue: "/path" } },
75
+ { key: URL_DOMAIN, value: { stringValue: "example.com" } },
76
+ { key: URL_SCHEME, value: { stringValue: "https" } },
77
+ { key: URL_QUERY, value: { stringValue: "query=test" } },
78
+ ]);
79
+ });
80
+
81
+ it("handles URL with fragment but no query", () => {
82
+ const url = "https://example.com/path#section";
83
+
84
+ addUrlAttributes(attributes, url);
85
+
86
+ expect(attributes).toEqual([
87
+ { key: URL_FULL, value: { stringValue: "https://example.com/path#section" } },
88
+ { key: URL_PATH, value: { stringValue: "/path" } },
89
+ { key: URL_DOMAIN, value: { stringValue: "example.com" } },
90
+ { key: URL_SCHEME, value: { stringValue: "https" } },
91
+ { key: URL_FRAGMENT, value: { stringValue: "section" } },
92
+ ]);
93
+ });
94
+
95
+ it("accepts URL object as input", () => {
96
+ const urlObject = new URL("https://example.com/path?query=test#fragment");
97
+
98
+ addUrlAttributes(attributes, urlObject);
99
+
100
+ expect(attributes).toEqual([
101
+ { key: URL_FULL, value: { stringValue: "https://example.com/path?query=test#fragment" } },
102
+ { key: URL_PATH, value: { stringValue: "/path" } },
103
+ { key: URL_DOMAIN, value: { stringValue: "example.com" } },
104
+ { key: URL_SCHEME, value: { stringValue: "https" } },
105
+ { key: URL_FRAGMENT, value: { stringValue: "fragment" } },
106
+ { key: URL_QUERY, value: { stringValue: "query=test" } },
107
+ ]);
108
+ });
109
+ });
110
+
111
+ describe("credential redaction functionality", () => {
112
+ it("redacts username and password from URL", () => {
113
+ const url = "https://user:pass@example.com/path";
114
+
115
+ addUrlAttributes(attributes, url);
116
+
117
+ expect(attributes).toEqual([
118
+ { key: URL_FULL, value: { stringValue: "https://REDACTED:REDACTED@example.com/path" } },
119
+ { key: URL_PATH, value: { stringValue: "/path" } },
120
+ { key: URL_DOMAIN, value: { stringValue: "example.com" } },
121
+ { key: URL_SCHEME, value: { stringValue: "https" } },
122
+ ]);
123
+ });
124
+
125
+ it("redacts username when password is not present", () => {
126
+ const url = "https://user@example.com/path";
127
+
128
+ addUrlAttributes(attributes, url);
129
+
130
+ expect(attributes).toEqual([
131
+ { key: URL_FULL, value: { stringValue: "https://REDACTED@example.com/path" } },
132
+ { key: URL_PATH, value: { stringValue: "/path" } },
133
+ { key: URL_DOMAIN, value: { stringValue: "example.com" } },
134
+ { key: URL_SCHEME, value: { stringValue: "https" } },
135
+ ]);
136
+ });
137
+
138
+ it("handles special characters in credentials", () => {
139
+ const url = "https://user%40domain:p%40ssw0rd@example.com/path";
140
+
141
+ addUrlAttributes(attributes, url);
142
+
143
+ expect(attributes).toEqual([
144
+ { key: URL_FULL, value: { stringValue: "https://REDACTED:REDACTED@example.com/path" } },
145
+ { key: URL_PATH, value: { stringValue: "/path" } },
146
+ { key: URL_DOMAIN, value: { stringValue: "example.com" } },
147
+ { key: URL_SCHEME, value: { stringValue: "https" } },
148
+ ]);
149
+ });
150
+ });
151
+
152
+ describe("URL attribute scrubber integration", () => {
153
+ it("applies custom scrubber to URL attributes", () => {
154
+ const customScrubber: UrlAttributeScrubber = (attrs) => ({
155
+ ...attrs,
156
+ [URL_PATH]: "REDACTED",
157
+ [URL_QUERY]: undefined,
158
+ });
159
+ vars.urlAttributeScrubber = customScrubber;
160
+
161
+ const url = "https://example.com/sensitive/path?secret=value#fragment";
162
+
163
+ addUrlAttributes(attributes, url);
164
+
165
+ expect(attributes).toEqual([
166
+ { key: URL_FULL, value: { stringValue: "https://example.com/sensitive/path?secret=value#fragment" } },
167
+ { key: URL_PATH, value: { stringValue: "REDACTED" } },
168
+ { key: URL_DOMAIN, value: { stringValue: "example.com" } },
169
+ { key: URL_SCHEME, value: { stringValue: "https" } },
170
+ { key: URL_FRAGMENT, value: { stringValue: "fragment" } },
171
+ ]);
172
+ });
173
+
174
+ it("applies scrubber that removes all optional attributes", () => {
175
+ const restrictiveScrubber: UrlAttributeScrubber = (attrs) => ({
176
+ [URL_FULL]: attrs[URL_FULL],
177
+ });
178
+ vars.urlAttributeScrubber = restrictiveScrubber;
179
+
180
+ const url = "https://example.com/path?query=test#fragment";
181
+
182
+ addUrlAttributes(attributes, url);
183
+
184
+ expect(attributes).toEqual([
185
+ { key: URL_FULL, value: { stringValue: "https://example.com/path?query=test#fragment" } },
186
+ ]);
187
+ });
188
+
189
+ it("handles scrubber that throws an error", () => {
190
+ const errorScrubber: UrlAttributeScrubber = () => {
191
+ throw new Error("Scrubber error");
192
+ };
193
+ vars.urlAttributeScrubber = errorScrubber;
194
+
195
+ const url = "https://example.com/path";
196
+
197
+ addUrlAttributes(attributes, url);
198
+
199
+ expect(attributes).toHaveLength(0);
200
+ });
201
+
202
+ it("applies identity scrubber correctly", () => {
203
+ vars.urlAttributeScrubber = identity;
204
+
205
+ const url = "https://example.com/path?query=test";
206
+
207
+ addUrlAttributes(attributes, url);
208
+
209
+ expect(attributes).toHaveLength(5);
210
+ expect(attributes.find((attr) => attr.key === URL_FULL)?.value).toEqual({
211
+ stringValue: "https://example.com/path?query=test",
212
+ });
213
+ expect(attributes.find((attr) => attr.key === URL_PATH)?.value).toEqual({ stringValue: "/path" });
214
+ expect(attributes.find((attr) => attr.key === URL_DOMAIN)?.value).toEqual({ stringValue: "example.com" });
215
+ expect(attributes.find((attr) => attr.key === URL_SCHEME)?.value).toEqual({ stringValue: "https" });
216
+ expect(attributes.find((attr) => attr.key === URL_QUERY)?.value).toEqual({ stringValue: "query=test" });
217
+ });
218
+ });
219
+
220
+ describe("error handling for invalid URLs", () => {
221
+ it("handles invalid URL with identity scrubber by adding full URL", () => {
222
+ vars.urlAttributeScrubber = identity;
223
+ const invalidUrl = "not-a-valid-url";
224
+
225
+ addUrlAttributes(attributes, invalidUrl);
226
+
227
+ // Invalid URLs still parse but create fallback attributes
228
+ expect(attributes.length).toBeGreaterThan(0);
229
+ // The actual behavior might be different based on the parsing logic
230
+ const fullAttr = attributes.find((attr) => attr.key === URL_FULL);
231
+ expect(fullAttr).toBeDefined();
232
+ });
233
+
234
+ it("handles invalid URL with custom scrubber by dropping attributes", () => {
235
+ const customScrubber: UrlAttributeScrubber = (attrs) => attrs;
236
+ vars.urlAttributeScrubber = customScrubber;
237
+ const invalidUrl = "not-a-valid-url";
238
+
239
+ addUrlAttributes(attributes, invalidUrl);
240
+
241
+ // With custom scrubber, invalid URLs still get parsed with fallback
242
+ expect(attributes.length).toBeGreaterThan(0);
243
+ });
244
+
245
+ it("handles empty string URL with identity scrubber", () => {
246
+ vars.urlAttributeScrubber = identity;
247
+ const emptyUrl = "";
248
+
249
+ addUrlAttributes(attributes, emptyUrl);
250
+
251
+ // Empty URLs still parse but create fallback attributes
252
+ expect(attributes.length).toBeGreaterThan(0);
253
+ expect(attributes.find((attr) => attr.key === URL_FULL)?.value).toEqual({
254
+ stringValue: "http://localhost:3000/",
255
+ });
256
+ });
257
+
258
+ it("handles relative URL that can be parsed with base", () => {
259
+ // This should work if there's a document.baseURI or location.href available
260
+ const relativeUrl = "/relative/path?query=test";
261
+
262
+ // This might throw or might work depending on environment
263
+ addUrlAttributes(attributes, relativeUrl);
264
+
265
+ // We expect either parsed attributes or fallback behavior
266
+ expect(attributes.length).toBeGreaterThanOrEqual(0);
267
+ });
268
+ });
269
+
270
+ describe("prefix functionality for attributes", () => {
271
+ it("applies string prefix to attribute keys", () => {
272
+ const url = "https://example.com/path";
273
+ const prefix = "page";
274
+
275
+ addUrlAttributes(attributes, url, prefix);
276
+
277
+ expect(attributes).toEqual([
278
+ { key: "page.url.full", value: { stringValue: "https://example.com/path" } },
279
+ { key: "page.url.path", value: { stringValue: "/path" } },
280
+ { key: "page.url.domain", value: { stringValue: "example.com" } },
281
+ { key: "page.url.scheme", value: { stringValue: "https" } },
282
+ ]);
283
+ });
284
+
285
+ it("applies array prefix to attribute keys", () => {
286
+ const url = "https://example.com/path?query=test";
287
+ const prefix = ["http", "request"];
288
+
289
+ addUrlAttributes(attributes, url, prefix);
290
+
291
+ expect(attributes).toEqual([
292
+ { key: "http.request.url.full", value: { stringValue: "https://example.com/path?query=test" } },
293
+ { key: "http.request.url.path", value: { stringValue: "/path" } },
294
+ { key: "http.request.url.domain", value: { stringValue: "example.com" } },
295
+ { key: "http.request.url.scheme", value: { stringValue: "https" } },
296
+ { key: "http.request.url.query", value: { stringValue: "query=test" } },
297
+ ]);
298
+ });
299
+
300
+ it("handles empty string prefix", () => {
301
+ const url = "https://example.com/path";
302
+ const prefix = "";
303
+
304
+ addUrlAttributes(attributes, url, prefix);
305
+
306
+ expect(attributes).toEqual([
307
+ { key: "url.full", value: { stringValue: "https://example.com/path" } },
308
+ { key: "url.path", value: { stringValue: "/path" } },
309
+ { key: "url.domain", value: { stringValue: "example.com" } },
310
+ { key: "url.scheme", value: { stringValue: "https" } },
311
+ ]);
312
+ });
313
+
314
+ it("handles undefined prefix", () => {
315
+ const url = "https://example.com/path";
316
+
317
+ addUrlAttributes(attributes, url, undefined);
318
+
319
+ expect(attributes).toEqual([
320
+ { key: URL_FULL, value: { stringValue: "https://example.com/path" } },
321
+ { key: URL_PATH, value: { stringValue: "/path" } },
322
+ { key: URL_DOMAIN, value: { stringValue: "example.com" } },
323
+ { key: URL_SCHEME, value: { stringValue: "https" } },
324
+ ]);
325
+ });
326
+
327
+ it("applies prefix with error handling fallback", () => {
328
+ vars.urlAttributeScrubber = identity;
329
+ const invalidUrl = "invalid-url";
330
+ const prefix = "page";
331
+
332
+ addUrlAttributes(attributes, invalidUrl, prefix);
333
+
334
+ // With prefix and identity scrubber, fallback should include prefix
335
+ expect(attributes.length).toBeGreaterThan(0);
336
+ // The actual behavior might be different based on the parsing logic
337
+ const fullAttr = attributes.find((attr) => attr.key === "page.url.full");
338
+ expect(fullAttr).toBeDefined();
339
+ });
340
+ });
341
+
342
+ describe("edge cases and corner cases", () => {
343
+ it("handles URL with port number", () => {
344
+ const url = "https://example.com:8080/path";
345
+
346
+ addUrlAttributes(attributes, url);
347
+
348
+ expect(attributes).toEqual([
349
+ { key: URL_FULL, value: { stringValue: "https://example.com:8080/path" } },
350
+ { key: URL_PATH, value: { stringValue: "/path" } },
351
+ { key: URL_DOMAIN, value: { stringValue: "example.com" } },
352
+ { key: URL_SCHEME, value: { stringValue: "https" } },
353
+ ]);
354
+ });
355
+
356
+ it("handles URL with IP address", () => {
357
+ const url = "http://192.168.1.1:8080/api";
358
+
359
+ addUrlAttributes(attributes, url);
360
+
361
+ expect(attributes).toEqual([
362
+ { key: URL_FULL, value: { stringValue: "http://192.168.1.1:8080/api" } },
363
+ { key: URL_PATH, value: { stringValue: "/api" } },
364
+ { key: URL_DOMAIN, value: { stringValue: "192.168.1.1" } },
365
+ { key: URL_SCHEME, value: { stringValue: "http" } },
366
+ ]);
367
+ });
368
+
369
+ it("handles URL with IPv6 address", () => {
370
+ const url = "http://[::1]:8080/path";
371
+
372
+ addUrlAttributes(attributes, url);
373
+
374
+ expect(attributes).toEqual([
375
+ { key: URL_FULL, value: { stringValue: "http://[::1]:8080/path" } },
376
+ { key: URL_PATH, value: { stringValue: "/path" } },
377
+ { key: URL_DOMAIN, value: { stringValue: "[::1]" } },
378
+ { key: URL_SCHEME, value: { stringValue: "http" } },
379
+ ]);
380
+ });
381
+
382
+ it("handles URL with encoded characters", () => {
383
+ const url = "https://example.com/path%20with%20spaces?query=value%20with%20spaces#fragment%20with%20spaces";
384
+
385
+ addUrlAttributes(attributes, url);
386
+
387
+ expect(attributes).toEqual([
388
+ {
389
+ key: URL_FULL,
390
+ value: {
391
+ stringValue:
392
+ "https://example.com/path%20with%20spaces?query=value%20with%20spaces#fragment%20with%20spaces",
393
+ },
394
+ },
395
+ { key: URL_PATH, value: { stringValue: "/path%20with%20spaces" } },
396
+ { key: URL_DOMAIN, value: { stringValue: "example.com" } },
397
+ { key: URL_SCHEME, value: { stringValue: "https" } },
398
+ { key: URL_FRAGMENT, value: { stringValue: "fragment%20with%20spaces" } },
399
+ { key: URL_QUERY, value: { stringValue: "query=value%20with%20spaces" } },
400
+ ]);
401
+ });
402
+
403
+ it("handles different protocol schemes", () => {
404
+ const protocols = ["ftp", "ws", "wss", "file"];
405
+
406
+ protocols.forEach((protocol) => {
407
+ attributes = []; // Reset attributes for each test
408
+ const url = `${protocol}://example.com/path`;
409
+
410
+ addUrlAttributes(attributes, url);
411
+
412
+ const schemeAttr = attributes.find((attr) => attr.key === URL_SCHEME);
413
+ expect(schemeAttr?.value).toEqual({ stringValue: protocol });
414
+ });
415
+ });
416
+ });
417
+ });
@@ -1,5 +1,6 @@
1
1
  import { debug, INIT_MESSAGE } from "../utils";
2
- import { init as initApi, InitOptions } from "../api/init";
2
+ import { init as initApi } from "../api/init";
3
+ import { InitOptions } from "../types/options";
3
4
 
4
5
  export * from "../api/identify";
5
6
  export * from "../api/debug";
@@ -0,0 +1,56 @@
1
+ import { AttributeValueType } from "../utils/otel";
2
+ import { AnyValue } from "./otlp";
3
+ import { Endpoint, Vars } from "../vars";
4
+
5
+ export type InstrumentationName = "@dash0/navigation" | "@dash0/web-vitals" | "@dash0/error" | "@dash0/fetch";
6
+
7
+ export type InitOptions = {
8
+ serviceName: string;
9
+ serviceVersion?: string;
10
+ environment?: string;
11
+ deploymentName?: string;
12
+ deploymentId?: string;
13
+
14
+ /**
15
+ * Additional attributes to include with transmitted signals
16
+ */
17
+ additionalSignalAttributes?: Record<string, AttributeValueType | AnyValue>;
18
+
19
+ /**
20
+ * OTLP endpoints to which the generated telemetry should be sent to.
21
+ */
22
+ endpoint: Endpoint | Endpoint[];
23
+
24
+ /**
25
+ * Which instrumentations to enable. Defaults to undefined, which means all instrumentations.
26
+ */
27
+ enabledInstrumentations?: InstrumentationName[];
28
+
29
+ /**
30
+ * The session inactivity timeout. Session inactivity is the maximum
31
+ * allowed time to pass between two page loads before the session is considered
32
+ * to be expired. Also think of cache time-to-idle configuration options.
33
+ */
34
+ sessionInactivityTimeoutMillis?: number;
35
+
36
+ /**
37
+ * The default session termination timeout. Session termination is the maximum
38
+ * allowed time to pass since session start before the session is considered
39
+ * to be expired. Also think of cache time-to-live configuration options.
40
+ */
41
+ sessionTerminationTimeoutMillis?: number;
42
+ } & Partial<
43
+ Pick<
44
+ Vars,
45
+ | "ignoreUrls"
46
+ | "ignoreErrorMessages"
47
+ | "wrapEventHandlers"
48
+ | "wrapTimers"
49
+ | "propagateTraceHeadersCorsURLs"
50
+ | "maxWaitForResourceTimingsMillis"
51
+ | "maxToleranceForResourceTimingsMillis"
52
+ | "headersToCapture"
53
+ | "urlAttributeScrubber"
54
+ | "pageViewInstrumentation"
55
+ >
56
+ >;
package/src/utils/fn.ts CHANGED
@@ -1,3 +1,7 @@
1
1
  export function noop() {
2
2
  // This function is intentionally empty.
3
3
  }
4
+
5
+ export function identity<T>(a: T) {
6
+ return a;
7
+ }
package/src/vars.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { AttributeValueType } from "./utils/otel";
2
2
  import { AnyValue, InstrumentationScope, KeyValue, Resource } from "./types/otlp";
3
+ import { UrlAttributeScrubber } from "./attributes";
4
+ import { identity } from "./utils";
3
5
 
4
6
  export type Endpoint = {
5
7
  /**
@@ -138,6 +140,16 @@ export type Vars = {
138
140
  */
139
141
  headersToCapture: RegExp[];
140
142
 
143
+ /**
144
+ * Allows the application of a custom scrubbing function to url attributes before they are applied to signals.
145
+ * This is invoked for each url processed for inclusion in signal attributes. For example this applies both to `page.url.*`
146
+ * and `url.*` attribute namespaces.
147
+ * Sensitive parts of the url attributes should be replaced with `REDACTED`,
148
+ * avoid partially or fully dropping attributes to preserve telemetry quality.
149
+ * Note: basic auth credentials in urls are automatically redacted before this is invoked.
150
+ */
151
+ urlAttributeScrubber: UrlAttributeScrubber;
152
+
141
153
  pageViewInstrumentation: PageViewInstrumentationSettings;
142
154
  };
143
155
 
@@ -160,6 +172,7 @@ export const vars: Vars = {
160
172
  maxWaitForResourceTimingsMillis: 10000,
161
173
  maxToleranceForResourceTimingsMillis: 50,
162
174
  headersToCapture: [],
175
+ urlAttributeScrubber: identity,
163
176
  pageViewInstrumentation: {
164
177
  trackVirtualPageViews: true,
165
178
  includeParts: [],