autotel-web 1.1.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.
- package/LICENSE +21 -0
- package/README.md +921 -0
- package/dist/index.d.ts +310 -0
- package/dist/index.js +391 -0
- package/dist/index.js.map +1 -0
- package/package.json +69 -0
- package/src/functional.ts +197 -0
- package/src/index.ts +70 -0
- package/src/init.privacy.test.ts +179 -0
- package/src/init.ts +415 -0
- package/src/privacy.test.ts +404 -0
- package/src/privacy.ts +261 -0
- package/src/traceparent.test.ts +49 -0
- package/src/traceparent.ts +105 -0
package/src/init.ts
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal browser SDK initialization
|
|
3
|
+
*
|
|
4
|
+
* Patches fetch() and XMLHttpRequest to automatically inject W3C traceparent headers.
|
|
5
|
+
* NO OpenTelemetry dependencies - just native browser APIs.
|
|
6
|
+
*
|
|
7
|
+
* Bundle size: ~2-5KB gzipped
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createTraceparent } from './traceparent';
|
|
11
|
+
import { PrivacyManager, PrivacyConfig, getDenialReason } from './privacy';
|
|
12
|
+
|
|
13
|
+
export interface AutotelWebConfig {
|
|
14
|
+
/**
|
|
15
|
+
* Service name for the browser application
|
|
16
|
+
* Used only for logging/debugging - not sent in headers
|
|
17
|
+
*/
|
|
18
|
+
service: string;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Enable debug logging to console
|
|
22
|
+
* @default false
|
|
23
|
+
*/
|
|
24
|
+
debug?: boolean;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Enable automatic traceparent injection on fetch calls
|
|
28
|
+
* @default true
|
|
29
|
+
*/
|
|
30
|
+
instrumentFetch?: boolean;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Enable automatic traceparent injection on XMLHttpRequest
|
|
34
|
+
* @default true
|
|
35
|
+
*/
|
|
36
|
+
instrumentXHR?: boolean;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Privacy controls for traceparent header injection
|
|
40
|
+
*
|
|
41
|
+
* Configure origin filtering and privacy signal respecting (DNT, GPC)
|
|
42
|
+
* to ensure compliance with GDPR, CCPA, and user privacy preferences.
|
|
43
|
+
*
|
|
44
|
+
* @example Basic origin filtering
|
|
45
|
+
* ```typescript
|
|
46
|
+
* {
|
|
47
|
+
* privacy: {
|
|
48
|
+
* allowedOrigins: ['api.myapp.com'], // Only inject on API calls
|
|
49
|
+
* respectDoNotTrack: true // Respect user's DNT setting
|
|
50
|
+
* }
|
|
51
|
+
* }
|
|
52
|
+
* ```
|
|
53
|
+
*
|
|
54
|
+
* @example Block third-party analytics
|
|
55
|
+
* ```typescript
|
|
56
|
+
* {
|
|
57
|
+
* privacy: {
|
|
58
|
+
* blockedOrigins: ['analytics.google.com', 'facebook.com']
|
|
59
|
+
* }
|
|
60
|
+
* }
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
privacy?: PrivacyConfig;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let isInitialized = false;
|
|
67
|
+
let config: AutotelWebConfig | undefined;
|
|
68
|
+
let privacyManager: PrivacyManager | undefined;
|
|
69
|
+
let originalFetch: typeof window.fetch | undefined;
|
|
70
|
+
let originalXHROpen: typeof XMLHttpRequest.prototype.open | undefined;
|
|
71
|
+
let originalXHRSetRequestHeader: typeof XMLHttpRequest.prototype.setRequestHeader | undefined;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Initialize autotel-web
|
|
75
|
+
*
|
|
76
|
+
* Patches fetch() and XMLHttpRequest to auto-inject traceparent headers.
|
|
77
|
+
*
|
|
78
|
+
* **SSR-safe:** Safe to call in SSR environments (checks for window).
|
|
79
|
+
* **Call once:** Subsequent calls are ignored.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```typescript
|
|
83
|
+
* import { init } from 'autotel-web'
|
|
84
|
+
*
|
|
85
|
+
* init({ service: 'my-frontend-app' })
|
|
86
|
+
*
|
|
87
|
+
* // Now all fetch/XHR calls include traceparent headers!
|
|
88
|
+
* fetch('/api/users') // <-- traceparent header automatically injected
|
|
89
|
+
* ```
|
|
90
|
+
*
|
|
91
|
+
* @example With React (client-only)
|
|
92
|
+
* ```typescript
|
|
93
|
+
* import { useEffect } from 'react'
|
|
94
|
+
* import { init } from 'autotel-web'
|
|
95
|
+
*
|
|
96
|
+
* function App() {
|
|
97
|
+
* useEffect(() => {
|
|
98
|
+
* init({ service: 'my-spa' })
|
|
99
|
+
* }, [])
|
|
100
|
+
*
|
|
101
|
+
* return <div>...</div>
|
|
102
|
+
* }
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
export function init(userConfig: AutotelWebConfig): void {
|
|
106
|
+
// SSR-safe: do nothing on the server
|
|
107
|
+
if (typeof window === 'undefined') {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (isInitialized) {
|
|
112
|
+
if (userConfig.debug) {
|
|
113
|
+
console.warn('[autotel-web] Already initialized. Skipping.');
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Validate configuration
|
|
119
|
+
validateConfig(userConfig);
|
|
120
|
+
|
|
121
|
+
config = userConfig;
|
|
122
|
+
|
|
123
|
+
// Initialize privacy manager if privacy config provided
|
|
124
|
+
if (config.privacy) {
|
|
125
|
+
privacyManager = new PrivacyManager(config.privacy);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Patch fetch
|
|
129
|
+
if (config.instrumentFetch !== false) {
|
|
130
|
+
patchFetch();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Patch XHR
|
|
134
|
+
if (config.instrumentXHR !== false) {
|
|
135
|
+
patchXMLHttpRequest();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
isInitialized = true;
|
|
139
|
+
|
|
140
|
+
if (config.debug) {
|
|
141
|
+
console.log('[autotel-web] Initialized successfully', {
|
|
142
|
+
service: config.service,
|
|
143
|
+
instrumentFetch: config.instrumentFetch !== false,
|
|
144
|
+
instrumentXHR: config.instrumentXHR !== false,
|
|
145
|
+
privacyEnabled: !!config.privacy,
|
|
146
|
+
privacyConfig: config.privacy
|
|
147
|
+
? {
|
|
148
|
+
allowedOrigins: config.privacy.allowedOrigins?.length ?? 0,
|
|
149
|
+
blockedOrigins: config.privacy.blockedOrigins?.length ?? 0,
|
|
150
|
+
respectDoNotTrack: config.privacy.respectDoNotTrack ?? false,
|
|
151
|
+
respectGPC: config.privacy.respectGPC ?? false,
|
|
152
|
+
}
|
|
153
|
+
: null,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Patch fetch() to auto-inject traceparent headers
|
|
160
|
+
*/
|
|
161
|
+
function patchFetch(): void {
|
|
162
|
+
// Always get the current window.fetch as the original
|
|
163
|
+
// This allows tests to set up mocks before calling init()
|
|
164
|
+
originalFetch = window.fetch.bind(window);
|
|
165
|
+
|
|
166
|
+
window.fetch = function (
|
|
167
|
+
input: RequestInfo | URL,
|
|
168
|
+
init?: RequestInit
|
|
169
|
+
): Promise<Response> {
|
|
170
|
+
// Get URL string for logging and privacy checks
|
|
171
|
+
const url =
|
|
172
|
+
typeof input === 'string'
|
|
173
|
+
? input
|
|
174
|
+
: input instanceof URL
|
|
175
|
+
? input.toString()
|
|
176
|
+
: input.url;
|
|
177
|
+
|
|
178
|
+
// Create headers object
|
|
179
|
+
const headers = new Headers(init?.headers);
|
|
180
|
+
|
|
181
|
+
// Only inject if traceparent doesn't already exist
|
|
182
|
+
if (!headers.has('traceparent')) {
|
|
183
|
+
// Check privacy controls
|
|
184
|
+
if (privacyManager && !privacyManager.shouldInjectTraceparent(url)) {
|
|
185
|
+
if (config?.debug) {
|
|
186
|
+
const reason = getDenialReason(privacyManager, url);
|
|
187
|
+
console.log(
|
|
188
|
+
'[autotel-web] Skipped traceparent on fetch (privacy):',
|
|
189
|
+
url,
|
|
190
|
+
reason
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
// Inject traceparent header
|
|
195
|
+
headers.set('traceparent', createTraceparent());
|
|
196
|
+
|
|
197
|
+
if (config?.debug) {
|
|
198
|
+
console.log(
|
|
199
|
+
'[autotel-web] Injected traceparent on fetch:',
|
|
200
|
+
url,
|
|
201
|
+
headers.get('traceparent')
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Call original fetch with updated headers
|
|
208
|
+
// originalFetch is always defined here because patchFetch() sets it before patching
|
|
209
|
+
return originalFetch!(input, { ...init, headers });
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Patch XMLHttpRequest to auto-inject traceparent headers
|
|
215
|
+
*/
|
|
216
|
+
function patchXMLHttpRequest(): void {
|
|
217
|
+
// Always get the current prototypes as the originals
|
|
218
|
+
// This allows tests to set up mocks before calling init()
|
|
219
|
+
originalXHROpen = XMLHttpRequest.prototype.open;
|
|
220
|
+
originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
|
|
221
|
+
|
|
222
|
+
// Track which XHR instances have traceparent set
|
|
223
|
+
const xhrHasTraceparent = new WeakSet<XMLHttpRequest>();
|
|
224
|
+
|
|
225
|
+
// Patch setRequestHeader to track manual traceparent headers
|
|
226
|
+
XMLHttpRequest.prototype.setRequestHeader = function (
|
|
227
|
+
name: string,
|
|
228
|
+
value: string
|
|
229
|
+
): void {
|
|
230
|
+
if (name.toLowerCase() === 'traceparent') {
|
|
231
|
+
xhrHasTraceparent.add(this);
|
|
232
|
+
}
|
|
233
|
+
// originalXHRSetRequestHeader is always defined here because patchXMLHttpRequest() sets it before patching
|
|
234
|
+
return originalXHRSetRequestHeader!.call(this, name, value);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// Patch open to inject traceparent after headers are ready
|
|
238
|
+
XMLHttpRequest.prototype.open = function (
|
|
239
|
+
method: string,
|
|
240
|
+
url: string | URL,
|
|
241
|
+
async: boolean = true,
|
|
242
|
+
username?: string | null,
|
|
243
|
+
password?: string | null
|
|
244
|
+
): void {
|
|
245
|
+
// Call original open
|
|
246
|
+
// originalXHROpen is always defined here because patchXMLHttpRequest() sets it before patching
|
|
247
|
+
const result = originalXHROpen!.call(this, method, url, async, username, password);
|
|
248
|
+
|
|
249
|
+
// Convert URL to string for logging and privacy checks
|
|
250
|
+
const urlStr = typeof url === 'string' ? url : url.toString();
|
|
251
|
+
|
|
252
|
+
// Listen for readyState change to inject header at the right time
|
|
253
|
+
const xhr = this;
|
|
254
|
+
const originalOnReadyStateChange = xhr.onreadystatechange;
|
|
255
|
+
|
|
256
|
+
xhr.onreadystatechange = function (event: Event) {
|
|
257
|
+
// OPENED state (1) - headers can now be set
|
|
258
|
+
if (xhr.readyState === XMLHttpRequest.OPENED) {
|
|
259
|
+
// Only inject if not already set
|
|
260
|
+
if (!xhrHasTraceparent.has(xhr)) {
|
|
261
|
+
// Check privacy controls
|
|
262
|
+
if (privacyManager && !privacyManager.shouldInjectTraceparent(urlStr)) {
|
|
263
|
+
if (config?.debug) {
|
|
264
|
+
const reason = getDenialReason(privacyManager, urlStr);
|
|
265
|
+
console.log(
|
|
266
|
+
'[autotel-web] Skipped traceparent on XHR (privacy):',
|
|
267
|
+
urlStr,
|
|
268
|
+
reason
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
// Inject traceparent header
|
|
273
|
+
try {
|
|
274
|
+
const traceparent = createTraceparent();
|
|
275
|
+
// originalXHRSetRequestHeader is always defined here because patchXMLHttpRequest() sets it before patching
|
|
276
|
+
originalXHRSetRequestHeader!.call(xhr, 'traceparent', traceparent);
|
|
277
|
+
|
|
278
|
+
if (config?.debug) {
|
|
279
|
+
console.log(
|
|
280
|
+
'[autotel-web] Injected traceparent on XHR:',
|
|
281
|
+
urlStr,
|
|
282
|
+
traceparent
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
} catch (error) {
|
|
286
|
+
// Silently ignore if setRequestHeader fails
|
|
287
|
+
if (config?.debug) {
|
|
288
|
+
console.warn(
|
|
289
|
+
'[autotel-web] Failed to inject traceparent on XHR:',
|
|
290
|
+
error
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Call original handler if it exists
|
|
299
|
+
if (originalOnReadyStateChange) {
|
|
300
|
+
return originalOnReadyStateChange.call(xhr, event);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
return result;
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Validate configuration at initialization time
|
|
310
|
+
* Catches common misconfigurations early
|
|
311
|
+
*/
|
|
312
|
+
function validateConfig(userConfig: AutotelWebConfig): void {
|
|
313
|
+
// Validate service name
|
|
314
|
+
if (!userConfig.service || typeof userConfig.service !== 'string') {
|
|
315
|
+
throw new Error('[autotel-web] service name is required and must be a string');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (userConfig.service.length === 0) {
|
|
319
|
+
throw new Error('[autotel-web] service name cannot be empty');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (userConfig.service.length > 255) {
|
|
323
|
+
console.warn(
|
|
324
|
+
'[autotel-web] service name is very long (> 255 chars). Consider using a shorter name.'
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Validate privacy config if provided
|
|
329
|
+
if (userConfig.privacy) {
|
|
330
|
+
const { allowedOrigins, blockedOrigins } = userConfig.privacy;
|
|
331
|
+
|
|
332
|
+
// Warn if both allowlist and blocklist are empty
|
|
333
|
+
if (
|
|
334
|
+
(!allowedOrigins || allowedOrigins.length === 0) &&
|
|
335
|
+
(!blockedOrigins || blockedOrigins.length === 0) &&
|
|
336
|
+
!userConfig.privacy.respectDoNotTrack &&
|
|
337
|
+
!userConfig.privacy.respectGPC
|
|
338
|
+
) {
|
|
339
|
+
console.warn(
|
|
340
|
+
'[autotel-web] privacy config provided but all options are empty/disabled. This has no effect.'
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Warn about overlapping origins
|
|
345
|
+
if (allowedOrigins && blockedOrigins) {
|
|
346
|
+
const overlap = allowedOrigins.filter((allowed) =>
|
|
347
|
+
blockedOrigins.some((blocked) =>
|
|
348
|
+
allowed.toLowerCase().includes(blocked.toLowerCase())
|
|
349
|
+
)
|
|
350
|
+
);
|
|
351
|
+
if (overlap.length > 0) {
|
|
352
|
+
console.warn(
|
|
353
|
+
'[autotel-web] Some allowedOrigins match blockedOrigins. Blocklist takes precedence:',
|
|
354
|
+
overlap
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Validate origin format (warn if looks invalid)
|
|
360
|
+
const allOrigins = [
|
|
361
|
+
...(allowedOrigins ?? []),
|
|
362
|
+
...(blockedOrigins ?? []),
|
|
363
|
+
];
|
|
364
|
+
allOrigins.forEach((origin) => {
|
|
365
|
+
if (origin.includes('://')) {
|
|
366
|
+
console.warn(
|
|
367
|
+
`[autotel-web] Origin "${origin}" includes protocol (://) - this is usually not needed. Just use the domain name.`
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Reset initialization state (for testing)
|
|
376
|
+
* @internal
|
|
377
|
+
*/
|
|
378
|
+
export function resetForTesting(): void {
|
|
379
|
+
isInitialized = false;
|
|
380
|
+
config = undefined;
|
|
381
|
+
privacyManager = undefined;
|
|
382
|
+
|
|
383
|
+
// Restore original fetch/XHR if they were patched
|
|
384
|
+
// Then clear the stored originals so next test can set up fresh mocks
|
|
385
|
+
if (typeof window !== 'undefined') {
|
|
386
|
+
if (originalFetch) {
|
|
387
|
+
window.fetch = originalFetch;
|
|
388
|
+
originalFetch = undefined;
|
|
389
|
+
}
|
|
390
|
+
if (originalXHROpen) {
|
|
391
|
+
XMLHttpRequest.prototype.open = originalXHROpen;
|
|
392
|
+
originalXHROpen = undefined;
|
|
393
|
+
}
|
|
394
|
+
if (originalXHRSetRequestHeader) {
|
|
395
|
+
XMLHttpRequest.prototype.setRequestHeader = originalXHRSetRequestHeader;
|
|
396
|
+
originalXHRSetRequestHeader = undefined;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Get current configuration
|
|
403
|
+
* @internal
|
|
404
|
+
*/
|
|
405
|
+
export function getConfig(): AutotelWebConfig | undefined {
|
|
406
|
+
return config;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Get current privacy manager
|
|
411
|
+
* @internal
|
|
412
|
+
*/
|
|
413
|
+
export function getPrivacyManager(): PrivacyManager | undefined {
|
|
414
|
+
return privacyManager;
|
|
415
|
+
}
|