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/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
+ }