@chip-hosting/analytics 1.0.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/dist/index.js ADDED
@@ -0,0 +1,1131 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ const VISITOR_ID_KEY = '_dwb_vid';
6
+ const SESSION_ID_KEY = '_dwb_sid';
7
+ const USER_ID_KEY = '_dwb_uid';
8
+ // 2 years in seconds
9
+ const VISITOR_MAX_AGE = 2 * 365 * 24 * 60 * 60;
10
+ // 30 minutes in seconds
11
+ const SESSION_MAX_AGE = 30 * 60;
12
+ /**
13
+ * Generates a unique ID using crypto.randomUUID if available,
14
+ * otherwise falls back to a simple random string
15
+ */
16
+ function generateId(prefix) {
17
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
18
+ return `${prefix}_${crypto.randomUUID().replace(/-/g, '')}`;
19
+ }
20
+ // Fallback for older browsers
21
+ const random = Math.random().toString(36).substring(2, 15) +
22
+ Math.random().toString(36).substring(2, 15);
23
+ return `${prefix}_${random}`;
24
+ }
25
+ /**
26
+ * Cookie-based storage adapter
27
+ */
28
+ class CookieStorage {
29
+ constructor(domain) {
30
+ this.domain = domain;
31
+ }
32
+ get(key) {
33
+ if (typeof document === 'undefined')
34
+ return null;
35
+ const cookies = document.cookie.split(';');
36
+ for (const cookie of cookies) {
37
+ const [name, value] = cookie.trim().split('=');
38
+ if (name === key) {
39
+ return decodeURIComponent(value);
40
+ }
41
+ }
42
+ return null;
43
+ }
44
+ set(key, value, options = {}) {
45
+ if (typeof document === 'undefined')
46
+ return;
47
+ const parts = [
48
+ `${key}=${encodeURIComponent(value)}`,
49
+ ];
50
+ if (options.maxAge !== undefined) {
51
+ parts.push(`max-age=${options.maxAge}`);
52
+ }
53
+ const domain = options.domain || this.domain;
54
+ if (domain) {
55
+ parts.push(`domain=${domain}`);
56
+ }
57
+ parts.push('path=/');
58
+ if (options.secure !== false) {
59
+ parts.push('secure');
60
+ }
61
+ const sameSite = options.sameSite || 'None';
62
+ parts.push(`samesite=${sameSite}`);
63
+ document.cookie = parts.join('; ');
64
+ }
65
+ remove(key) {
66
+ this.set(key, '', { maxAge: -1 });
67
+ }
68
+ }
69
+ /**
70
+ * Manages visitor and session identities via cookies
71
+ */
72
+ class CookieManager {
73
+ constructor(domain, storage) {
74
+ this.visitorId = null;
75
+ this.sessionId = null;
76
+ this.userId = null;
77
+ this.storage = storage || new CookieStorage(domain);
78
+ this.initialize();
79
+ }
80
+ /**
81
+ * Initialize visitor and session IDs
82
+ */
83
+ initialize() {
84
+ // Get or create visitor ID
85
+ this.visitorId = this.storage.get(VISITOR_ID_KEY);
86
+ if (!this.visitorId) {
87
+ this.visitorId = generateId('v');
88
+ this.storage.set(VISITOR_ID_KEY, this.visitorId, {
89
+ maxAge: VISITOR_MAX_AGE,
90
+ sameSite: 'None',
91
+ secure: true,
92
+ });
93
+ }
94
+ // Get or create session ID
95
+ this.sessionId = this.storage.get(SESSION_ID_KEY);
96
+ if (!this.sessionId) {
97
+ this.sessionId = generateId('sess');
98
+ }
99
+ // Always refresh session cookie to extend expiration
100
+ this.storage.set(SESSION_ID_KEY, this.sessionId, {
101
+ maxAge: SESSION_MAX_AGE,
102
+ sameSite: 'None',
103
+ secure: true,
104
+ });
105
+ // Get existing user ID if any
106
+ this.userId = this.storage.get(USER_ID_KEY);
107
+ }
108
+ /**
109
+ * Get current visitor ID
110
+ */
111
+ getVisitorId() {
112
+ return this.visitorId;
113
+ }
114
+ /**
115
+ * Get current session ID
116
+ */
117
+ getSessionId() {
118
+ return this.sessionId;
119
+ }
120
+ /**
121
+ * Get current user ID (if identified)
122
+ */
123
+ getUserId() {
124
+ return this.userId;
125
+ }
126
+ /**
127
+ * Set user ID (after login/identification)
128
+ */
129
+ setUserId(userId) {
130
+ this.userId = userId;
131
+ this.storage.set(USER_ID_KEY, userId, {
132
+ maxAge: VISITOR_MAX_AGE,
133
+ sameSite: 'None',
134
+ secure: true,
135
+ });
136
+ }
137
+ /**
138
+ * Clear user ID (after logout)
139
+ */
140
+ clearUserId() {
141
+ this.userId = null;
142
+ this.storage.remove(USER_ID_KEY);
143
+ }
144
+ /**
145
+ * Start a new session
146
+ */
147
+ newSession() {
148
+ this.sessionId = generateId('sess');
149
+ this.storage.set(SESSION_ID_KEY, this.sessionId, {
150
+ maxAge: SESSION_MAX_AGE,
151
+ sameSite: 'None',
152
+ secure: true,
153
+ });
154
+ }
155
+ /**
156
+ * Refresh session (extend expiration)
157
+ */
158
+ refreshSession() {
159
+ if (this.sessionId) {
160
+ this.storage.set(SESSION_ID_KEY, this.sessionId, {
161
+ maxAge: SESSION_MAX_AGE,
162
+ sameSite: 'None',
163
+ secure: true,
164
+ });
165
+ }
166
+ }
167
+ /**
168
+ * Reset all identities (for testing/debugging)
169
+ */
170
+ reset() {
171
+ this.storage.remove(VISITOR_ID_KEY);
172
+ this.storage.remove(SESSION_ID_KEY);
173
+ this.storage.remove(USER_ID_KEY);
174
+ this.visitorId = null;
175
+ this.sessionId = null;
176
+ this.userId = null;
177
+ this.initialize();
178
+ }
179
+ }
180
+
181
+ const DEFAULT_TIMEOUT = 10000; // 10 seconds
182
+ const DEFAULT_MAX_RETRIES = 3;
183
+ const RETRY_BASE_DELAY = 1000; // 1 second
184
+ /**
185
+ * HTTP transport layer for sending analytics data to Sensor
186
+ */
187
+ class HttpTransport {
188
+ constructor(config) {
189
+ this.endpoint = config.endpoint.replace(/\/$/, ''); // Remove trailing slash
190
+ this.clientId = config.clientId;
191
+ this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
192
+ this.timeout = config.timeout ?? DEFAULT_TIMEOUT;
193
+ this.debug = config.debug ?? false;
194
+ this.useProxy = config.useProxy ?? false;
195
+ this.proxyEndpoint = config.proxyEndpoint?.replace(/\/$/, '');
196
+ }
197
+ /**
198
+ * Send a single event
199
+ */
200
+ async sendEvent(event, visitorId, sessionId, userId, identities) {
201
+ const url = this.getCollectUrl();
202
+ const headers = this.buildHeaders(visitorId, sessionId, userId, identities);
203
+ return this.sendWithRetry(url, event, headers);
204
+ }
205
+ /**
206
+ * Send a batch of events
207
+ */
208
+ async sendBatch(batch) {
209
+ const url = this.getBatchUrl();
210
+ const headers = this.buildHeaders(batch.visitorId, batch.sessionId, null, batch.identities);
211
+ return this.sendWithRetry(url, batch, headers);
212
+ }
213
+ /**
214
+ * Get the appropriate collect URL based on proxy setting
215
+ */
216
+ getCollectUrl() {
217
+ if (this.useProxy && this.proxyEndpoint) {
218
+ return `${this.proxyEndpoint}/collect`;
219
+ }
220
+ return `${this.endpoint}/collect`;
221
+ }
222
+ /**
223
+ * Get the appropriate batch URL based on proxy setting
224
+ */
225
+ getBatchUrl() {
226
+ if (this.useProxy && this.proxyEndpoint) {
227
+ return `${this.proxyEndpoint}/collect/batch`;
228
+ }
229
+ return `${this.endpoint}/collect/batch`;
230
+ }
231
+ /**
232
+ * Build headers for the request
233
+ */
234
+ buildHeaders(visitorId, sessionId, userId, identities) {
235
+ const headers = {
236
+ 'Content-Type': 'application/json',
237
+ 'X-Client-ID': this.clientId,
238
+ 'X-Visitor-ID': visitorId,
239
+ 'X-Session-ID': sessionId,
240
+ };
241
+ if (userId) {
242
+ headers['X-User-Id'] = userId;
243
+ }
244
+ if (identities.emails && identities.emails.length > 0) {
245
+ headers['X-Email'] = identities.emails[0];
246
+ }
247
+ if (identities.customerIds && identities.customerIds.length > 0) {
248
+ headers['X-Customer-Id'] = identities.customerIds[0];
249
+ }
250
+ if (identities.deviceIds && identities.deviceIds.length > 0) {
251
+ headers['X-Device-Id'] = identities.deviceIds[0];
252
+ }
253
+ return headers;
254
+ }
255
+ /**
256
+ * Send request with retry logic and exponential backoff
257
+ */
258
+ async sendWithRetry(url, payload, headers, attempt = 0) {
259
+ try {
260
+ const response = await this.fetchWithTimeout(url, {
261
+ method: 'POST',
262
+ headers,
263
+ body: JSON.stringify(payload),
264
+ credentials: 'include', // Include cookies for cross-origin
265
+ });
266
+ if (response.ok) {
267
+ this.log('Event sent successfully', { url, attempt });
268
+ return { success: true, statusCode: response.status };
269
+ }
270
+ // Don't retry client errors (4xx) except 429 (rate limit)
271
+ if (response.status >= 400 && response.status < 500 && response.status !== 429) {
272
+ const errorText = await response.text().catch(() => 'Unknown error');
273
+ this.log('Client error, not retrying', { status: response.status, error: errorText });
274
+ return {
275
+ success: false,
276
+ statusCode: response.status,
277
+ error: errorText,
278
+ };
279
+ }
280
+ // Retry server errors (5xx) and rate limits (429)
281
+ if (attempt < this.maxRetries) {
282
+ const delay = this.calculateBackoff(attempt, response.status === 429);
283
+ this.log(`Retrying after ${delay}ms`, { attempt, status: response.status });
284
+ await this.sleep(delay);
285
+ return this.sendWithRetry(url, payload, headers, attempt + 1);
286
+ }
287
+ return {
288
+ success: false,
289
+ statusCode: response.status,
290
+ error: `Max retries exceeded (${this.maxRetries})`,
291
+ };
292
+ }
293
+ catch (error) {
294
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
295
+ // Retry network errors
296
+ if (attempt < this.maxRetries) {
297
+ const delay = this.calculateBackoff(attempt, false);
298
+ this.log(`Network error, retrying after ${delay}ms`, { attempt, error: errorMessage });
299
+ await this.sleep(delay);
300
+ return this.sendWithRetry(url, payload, headers, attempt + 1);
301
+ }
302
+ this.log('Failed after max retries', { error: errorMessage });
303
+ return {
304
+ success: false,
305
+ error: errorMessage,
306
+ };
307
+ }
308
+ }
309
+ /**
310
+ * Fetch with timeout using AbortController
311
+ */
312
+ async fetchWithTimeout(url, options) {
313
+ const controller = new AbortController();
314
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
315
+ try {
316
+ const response = await fetch(url, {
317
+ ...options,
318
+ signal: controller.signal,
319
+ });
320
+ return response;
321
+ }
322
+ finally {
323
+ clearTimeout(timeoutId);
324
+ }
325
+ }
326
+ /**
327
+ * Calculate exponential backoff delay
328
+ */
329
+ calculateBackoff(attempt, isRateLimit) {
330
+ // For rate limits, use longer delays
331
+ const baseDelay = isRateLimit ? RETRY_BASE_DELAY * 2 : RETRY_BASE_DELAY;
332
+ // Exponential backoff with jitter
333
+ const exponentialDelay = baseDelay * Math.pow(2, attempt);
334
+ const jitter = Math.random() * 1000;
335
+ return Math.min(exponentialDelay + jitter, 30000); // Cap at 30 seconds
336
+ }
337
+ /**
338
+ * Sleep for specified milliseconds
339
+ */
340
+ sleep(ms) {
341
+ return new Promise((resolve) => setTimeout(resolve, ms));
342
+ }
343
+ /**
344
+ * Log messages in debug mode
345
+ */
346
+ log(message, data) {
347
+ if (this.debug) {
348
+ console.log(`[DataWorkbench] ${message}`, data ?? '');
349
+ }
350
+ }
351
+ /**
352
+ * Send beacon on page unload (best-effort, no retry)
353
+ */
354
+ sendBeacon(payload) {
355
+ if (typeof navigator === 'undefined' || !navigator.sendBeacon) {
356
+ return false;
357
+ }
358
+ const url = this.getBatchUrl();
359
+ const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
360
+ try {
361
+ return navigator.sendBeacon(url, blob);
362
+ }
363
+ catch {
364
+ return false;
365
+ }
366
+ }
367
+ }
368
+
369
+ const DEFAULT_BATCH_SIZE = 5;
370
+ const DEFAULT_FLUSH_INTERVAL = 5000; // 5 seconds
371
+ /**
372
+ * Event queue for batching analytics events
373
+ */
374
+ class EventQueue {
375
+ constructor(config) {
376
+ this.queue = [];
377
+ this.flushTimer = null;
378
+ this.isFlushing = false;
379
+ this.visitorId = '';
380
+ this.sessionId = '';
381
+ this.clientId = '';
382
+ this.identities = {};
383
+ this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE;
384
+ this.flushInterval = config.flushInterval ?? DEFAULT_FLUSH_INTERVAL;
385
+ this.onFlush = config.onFlush;
386
+ this.onBeaconFlush = config.onBeaconFlush;
387
+ this.debug = config.debug ?? false;
388
+ // Set up page unload handler
389
+ if (typeof window !== 'undefined') {
390
+ window.addEventListener('beforeunload', () => this.flushSync());
391
+ window.addEventListener('pagehide', () => this.flushSync());
392
+ // Handle visibility change (e.g., switching tabs on mobile)
393
+ document.addEventListener('visibilitychange', () => {
394
+ if (document.visibilityState === 'hidden') {
395
+ this.flushSync();
396
+ }
397
+ });
398
+ }
399
+ }
400
+ /**
401
+ * Set context for all events
402
+ */
403
+ setContext(visitorId, sessionId, clientId, identities) {
404
+ this.visitorId = visitorId;
405
+ this.sessionId = sessionId;
406
+ this.clientId = clientId;
407
+ this.identities = identities;
408
+ }
409
+ /**
410
+ * Update identities (e.g., after identify() call)
411
+ */
412
+ updateIdentities(identities) {
413
+ this.identities = { ...this.identities, ...identities };
414
+ }
415
+ /**
416
+ * Add an event to the queue
417
+ */
418
+ enqueue(event) {
419
+ this.queue.push(event);
420
+ this.log('Event enqueued', { eventType: event.eventType, queueSize: this.queue.length });
421
+ // Start or restart flush timer
422
+ this.scheduleFlush();
423
+ // Flush immediately if batch size reached
424
+ if (this.queue.length >= this.batchSize) {
425
+ this.log('Batch size reached, flushing');
426
+ this.flush();
427
+ }
428
+ }
429
+ /**
430
+ * Schedule a flush after the interval
431
+ */
432
+ scheduleFlush() {
433
+ if (this.flushTimer) {
434
+ return; // Timer already running
435
+ }
436
+ this.flushTimer = setTimeout(() => {
437
+ this.flushTimer = null;
438
+ if (this.queue.length > 0) {
439
+ this.log('Interval reached, flushing');
440
+ this.flush();
441
+ }
442
+ }, this.flushInterval);
443
+ }
444
+ /**
445
+ * Flush queued events asynchronously
446
+ */
447
+ async flush() {
448
+ if (this.isFlushing || this.queue.length === 0) {
449
+ return;
450
+ }
451
+ this.isFlushing = true;
452
+ // Take current batch
453
+ const batch = this.queue.splice(0, this.batchSize);
454
+ this.log('Flushing batch', { count: batch.length });
455
+ try {
456
+ await this.onFlush(batch);
457
+ this.log('Flush successful');
458
+ }
459
+ catch (error) {
460
+ // Re-queue failed events (at the front)
461
+ this.log('Flush failed, re-queueing', { error });
462
+ this.queue.unshift(...batch.map(e => ({ ...e, retryCount: e.retryCount + 1 })));
463
+ }
464
+ finally {
465
+ this.isFlushing = false;
466
+ }
467
+ // Continue flushing if more events in queue
468
+ if (this.queue.length > 0) {
469
+ this.scheduleFlush();
470
+ }
471
+ }
472
+ /**
473
+ * Flush synchronously using sendBeacon (for page unload)
474
+ */
475
+ flushSync() {
476
+ if (this.queue.length === 0 || !this.onBeaconFlush) {
477
+ return;
478
+ }
479
+ const events = this.queue.splice(0);
480
+ const payload = {
481
+ events: events.map(e => this.eventToPayload(e)),
482
+ clientId: this.clientId,
483
+ visitorId: this.visitorId,
484
+ sessionId: this.sessionId,
485
+ identities: this.identities,
486
+ };
487
+ const success = this.onBeaconFlush(payload);
488
+ this.log('Beacon flush', { success, count: events.length });
489
+ if (!success) {
490
+ // Restore events if beacon failed
491
+ this.queue.push(...events);
492
+ }
493
+ }
494
+ /**
495
+ * Convert internal event to API payload
496
+ */
497
+ eventToPayload(event) {
498
+ return {
499
+ eventType: event.eventType,
500
+ timestamp: event.timestamp,
501
+ properties: event.properties,
502
+ sessionId: event.sessionId,
503
+ };
504
+ }
505
+ /**
506
+ * Get current queue size
507
+ */
508
+ size() {
509
+ return this.queue.length;
510
+ }
511
+ /**
512
+ * Clear the queue (for testing)
513
+ */
514
+ clear() {
515
+ this.queue = [];
516
+ if (this.flushTimer) {
517
+ clearTimeout(this.flushTimer);
518
+ this.flushTimer = null;
519
+ }
520
+ }
521
+ /**
522
+ * Destroy the queue and clean up
523
+ */
524
+ destroy() {
525
+ this.clear();
526
+ if (typeof window !== 'undefined') {
527
+ window.removeEventListener('beforeunload', () => this.flushSync());
528
+ window.removeEventListener('pagehide', () => this.flushSync());
529
+ }
530
+ }
531
+ /**
532
+ * Log messages in debug mode
533
+ */
534
+ log(message, data) {
535
+ if (this.debug) {
536
+ console.log(`[DataWorkbench Queue] ${message}`, data ?? '');
537
+ }
538
+ }
539
+ }
540
+
541
+ const BROWSER_RULES = [
542
+ { name: 'Edge', pattern: /Edg(?:e|A|iOS)?\/(\d+[\d.]*)/ },
543
+ { name: 'Opera', pattern: /OPR\/(\d+[\d.]*)/ },
544
+ { name: 'Samsung Internet', pattern: /SamsungBrowser\/(\d+[\d.]*)/ },
545
+ { name: 'UC Browser', pattern: /UCBrowser\/(\d+[\d.]*)/ },
546
+ { name: 'Firefox', pattern: /Firefox\/(\d+[\d.]*)/ },
547
+ { name: 'Safari', pattern: /Version\/(\d+[\d.]*).*Safari/ },
548
+ { name: 'Chrome', pattern: /Chrome\/(\d+[\d.]*)/ },
549
+ ];
550
+ const OS_RULES = [
551
+ { name: 'iOS', pattern: /iPhone|iPad|iPod/, versionPattern: /OS (\d+[_.\d]*)/ },
552
+ { name: 'Android', pattern: /Android/, versionPattern: /Android (\d+[\d.]*)/ },
553
+ { name: 'ChromeOS', pattern: /CrOS/ },
554
+ { name: 'Windows', pattern: /Windows/, versionPattern: /Windows NT (\d+[\d.]*)/ },
555
+ { name: 'macOS', pattern: /Macintosh|Mac OS X/, versionPattern: /Mac OS X (\d+[_.\d]*)/ },
556
+ { name: 'Linux', pattern: /Linux/ },
557
+ ];
558
+ const WINDOWS_VERSION_MAP = {
559
+ '10.0': '10',
560
+ '6.3': '8.1',
561
+ '6.2': '8',
562
+ '6.1': '7',
563
+ };
564
+ function majorMinor(version) {
565
+ const parts = version.replace(/_/g, '.').split('.');
566
+ return parts.slice(0, 2).join('.');
567
+ }
568
+ /**
569
+ * Lightweight user agent parser. Zero dependencies.
570
+ * Covers ~95% of real-world browser traffic.
571
+ * Returns "Unknown" for unrecognized user agents.
572
+ */
573
+ function parseUserAgent(ua) {
574
+ const result = {
575
+ browser: 'Unknown',
576
+ browser_version: '',
577
+ os: 'Unknown',
578
+ os_version: '',
579
+ device_type: 'desktop',
580
+ };
581
+ if (!ua)
582
+ return result;
583
+ // Browser detection (order matters -- Edge/Opera before Chrome)
584
+ for (const rule of BROWSER_RULES) {
585
+ const match = ua.match(rule.pattern);
586
+ if (match) {
587
+ result.browser = rule.name;
588
+ result.browser_version = majorMinor(match[1]);
589
+ break;
590
+ }
591
+ }
592
+ // OS detection
593
+ for (const rule of OS_RULES) {
594
+ if (rule.pattern.test(ua)) {
595
+ result.os = rule.name;
596
+ if (rule.versionPattern) {
597
+ const match = ua.match(rule.versionPattern);
598
+ if (match) {
599
+ let version = majorMinor(match[1]);
600
+ if (rule.name === 'Windows' && WINDOWS_VERSION_MAP[version]) {
601
+ version = WINDOWS_VERSION_MAP[version];
602
+ }
603
+ result.os_version = version;
604
+ }
605
+ }
606
+ break;
607
+ }
608
+ }
609
+ // Device type detection
610
+ if (/iPad|tablet/i.test(ua) || (/Android/i.test(ua) && !/Mobile/i.test(ua))) {
611
+ result.device_type = 'tablet';
612
+ }
613
+ else if (/Mobile|iPhone|iPod|Android.*Mobile|webOS|BlackBerry|IEMobile|Opera Mini/i.test(ua)) {
614
+ result.device_type = 'mobile';
615
+ }
616
+ return result;
617
+ }
618
+
619
+ const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
620
+ /**
621
+ * Extracts UTM parameters from a URL string.
622
+ * Returns only recognized UTM keys (source, medium, campaign, term, content).
623
+ */
624
+ function parseUtmParams(url) {
625
+ if (!url)
626
+ return {};
627
+ try {
628
+ const searchParams = new URL(url).searchParams;
629
+ const result = {};
630
+ for (const key of UTM_KEYS) {
631
+ const value = searchParams.get(key);
632
+ if (value) {
633
+ result[key] = value;
634
+ }
635
+ }
636
+ return result;
637
+ }
638
+ catch {
639
+ return {};
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Collects browser context and merges it into event properties.
645
+ *
646
+ * - Stable context (browser, OS, screen, language) is cached on first call.
647
+ * - Dynamic context (viewport, UTM, performance) is read fresh each call.
648
+ */
649
+ class ContextCollector {
650
+ constructor(config) {
651
+ this.stableContext = null;
652
+ if (typeof config === 'boolean') {
653
+ this.enabled = config;
654
+ this.categories = {
655
+ screen: true,
656
+ browser: true,
657
+ locale: true,
658
+ utm: true,
659
+ device: true,
660
+ performance: true,
661
+ network: true,
662
+ };
663
+ }
664
+ else {
665
+ this.enabled = true;
666
+ this.categories = {
667
+ screen: config.screen !== false,
668
+ browser: config.browser !== false,
669
+ locale: config.locale !== false,
670
+ utm: config.utm !== false,
671
+ device: config.device !== false,
672
+ performance: config.performance !== false,
673
+ network: config.network !== false,
674
+ };
675
+ }
676
+ }
677
+ /** Returns auto-collected context properties. */
678
+ getContext() {
679
+ if (!this.enabled)
680
+ return {};
681
+ if (!this.stableContext) {
682
+ this.stableContext = this.collectStableContext();
683
+ }
684
+ return { ...this.stableContext, ...this.collectDynamicContext() };
685
+ }
686
+ /** Merges auto-collected context under user-supplied properties. */
687
+ mergeContext(userProperties) {
688
+ const autoContext = this.getContext();
689
+ return { ...autoContext, ...userProperties };
690
+ }
691
+ collectStableContext() {
692
+ const ctx = {};
693
+ if (typeof window === 'undefined')
694
+ return ctx;
695
+ const win = window;
696
+ if (this.categories.screen) {
697
+ ctx.screen_size = `${win.screen.width}x${win.screen.height}`;
698
+ ctx.pixel_ratio = win.devicePixelRatio;
699
+ ctx.color_depth = win.screen.colorDepth;
700
+ }
701
+ if (this.categories.browser) {
702
+ const ua = parseUserAgent(win.navigator.userAgent);
703
+ ctx.browser = ua.browser;
704
+ ctx.browser_version = ua.browser_version;
705
+ ctx.os = ua.os;
706
+ ctx.os_version = ua.os_version;
707
+ ctx.device_type = ua.device_type;
708
+ }
709
+ if (this.categories.locale) {
710
+ ctx.language = win.navigator.language;
711
+ try {
712
+ ctx.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
713
+ }
714
+ catch {
715
+ // Intl not available
716
+ }
717
+ }
718
+ if (this.categories.device) {
719
+ ctx.touch_support = 'ontouchstart' in win;
720
+ ctx.cookie_enabled = win.navigator.cookieEnabled;
721
+ ctx.do_not_track = win.navigator.doNotTrack === '1' || win.navigator.doNotTrack === 'yes';
722
+ ctx.hardware_concurrency = win.navigator.hardwareConcurrency;
723
+ ctx.platform = win.navigator.platform;
724
+ }
725
+ return ctx;
726
+ }
727
+ collectDynamicContext() {
728
+ const ctx = {};
729
+ if (typeof window === 'undefined')
730
+ return ctx;
731
+ const win = window;
732
+ if (this.categories.screen) {
733
+ ctx.viewport_size = `${win.innerWidth}x${win.innerHeight}`;
734
+ }
735
+ if (this.categories.utm) {
736
+ const utms = parseUtmParams(win.location.href);
737
+ Object.assign(ctx, utms);
738
+ }
739
+ if (this.categories.performance) {
740
+ try {
741
+ const entries = win.performance.getEntriesByType('navigation');
742
+ if (entries.length > 0) {
743
+ const nav = entries[0];
744
+ if (nav.loadEventEnd > 0) {
745
+ ctx.page_load_time = Math.round(nav.loadEventEnd - nav.startTime);
746
+ ctx.dom_ready_time = Math.round(nav.domContentLoadedEventEnd - nav.startTime);
747
+ ctx.navigation_type = nav.type;
748
+ }
749
+ }
750
+ }
751
+ catch {
752
+ // Performance API not available
753
+ }
754
+ }
755
+ if (this.categories.network) {
756
+ const conn = win.navigator.connection;
757
+ if (conn?.effectiveType) {
758
+ ctx.connection_type = conn.effectiveType;
759
+ }
760
+ }
761
+ return ctx;
762
+ }
763
+ }
764
+
765
+ /**
766
+ * Generate a unique event ID
767
+ */
768
+ function generateEventId() {
769
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
770
+ return crypto.randomUUID();
771
+ }
772
+ return Math.random().toString(36).substring(2, 15) +
773
+ Math.random().toString(36).substring(2, 15);
774
+ }
775
+ /**
776
+ * Main Analytics class - the core of the SDK
777
+ */
778
+ class Analytics {
779
+ constructor() {
780
+ this.config = null;
781
+ this.cookieManager = null;
782
+ this.transport = null;
783
+ this.queue = null;
784
+ this.identities = {};
785
+ this.initialized = false;
786
+ this.debug = false;
787
+ this.contextCollector = null;
788
+ }
789
+ /**
790
+ * Initialize the SDK with configuration
791
+ */
792
+ init(config) {
793
+ if (this.initialized) {
794
+ this.log('SDK already initialized, skipping');
795
+ return;
796
+ }
797
+ this.config = config;
798
+ this.debug = config.debug ?? false;
799
+ // Initialize cookie manager
800
+ this.cookieManager = new CookieManager(config.cookieDomain);
801
+ // Initialize identities
802
+ this.identities = {
803
+ anonymous: [this.cookieManager.getVisitorId()],
804
+ };
805
+ // Initialize transport
806
+ this.transport = new HttpTransport({
807
+ endpoint: config.endpoint,
808
+ clientId: config.clientId,
809
+ maxRetries: config.maxRetries,
810
+ debug: config.debug,
811
+ useProxy: config.useProxy,
812
+ proxyEndpoint: config.proxyEndpoint,
813
+ });
814
+ // Initialize queue
815
+ this.queue = new EventQueue({
816
+ batchSize: config.batchSize,
817
+ flushInterval: config.flushInterval,
818
+ debug: config.debug,
819
+ onFlush: async (events) => this.sendEvents(events),
820
+ onBeaconFlush: (payload) => this.transport.sendBeacon(payload),
821
+ });
822
+ // Set queue context
823
+ this.queue.setContext(this.cookieManager.getVisitorId(), this.cookieManager.getSessionId(), config.clientId, this.identities);
824
+ // Initialize context collector
825
+ this.contextCollector = new ContextCollector(config.autoCollect ?? true);
826
+ this.initialized = true;
827
+ this.log('SDK initialized', { endpoint: config.endpoint, clientId: config.clientId });
828
+ }
829
+ /**
830
+ * Track a custom event
831
+ */
832
+ track(eventType, properties = {}) {
833
+ this.ensureInitialized();
834
+ const mergedProperties = this.contextCollector?.mergeContext(properties) ?? properties;
835
+ const event = {
836
+ id: generateEventId(),
837
+ timestamp: Date.now(),
838
+ eventType,
839
+ properties: mergedProperties,
840
+ visitorId: this.cookieManager.getVisitorId(),
841
+ sessionId: this.cookieManager.getSessionId(),
842
+ userId: this.cookieManager.getUserId() ?? undefined,
843
+ identities: { ...this.identities },
844
+ retryCount: 0,
845
+ };
846
+ this.queue.enqueue(event);
847
+ this.cookieManager.refreshSession();
848
+ }
849
+ /**
850
+ * Track a page view
851
+ */
852
+ page(name, properties = {}) {
853
+ const pageProperties = {
854
+ ...properties,
855
+ url: typeof window !== 'undefined' ? window.location.href : undefined,
856
+ path: typeof window !== 'undefined' ? window.location.pathname : undefined,
857
+ referrer: typeof document !== 'undefined' ? document.referrer : undefined,
858
+ title: typeof document !== 'undefined' ? document.title : undefined,
859
+ };
860
+ if (name) {
861
+ pageProperties.name = name;
862
+ }
863
+ this.track('page_view', pageProperties);
864
+ }
865
+ /**
866
+ * Identify a user
867
+ */
868
+ identify(userId, traits = {}) {
869
+ this.ensureInitialized();
870
+ // Update cookie manager
871
+ this.cookieManager.setUserId(userId);
872
+ // Update identities
873
+ this.identities.authenticated = [userId];
874
+ if (traits.email) {
875
+ this.identities.emails = [traits.email.toLowerCase()];
876
+ }
877
+ if (traits.customerId) {
878
+ this.identities.customerIds = [traits.customerId];
879
+ }
880
+ if (traits.deviceId) {
881
+ this.identities.deviceIds = [traits.deviceId];
882
+ }
883
+ if (traits.phone) {
884
+ this.identities.phones = [traits.phone];
885
+ }
886
+ // Update queue context
887
+ this.queue.updateIdentities(this.identities);
888
+ // Track identify event
889
+ this.track('identify', { userId, ...traits });
890
+ this.log('User identified', { userId, traits });
891
+ }
892
+ /**
893
+ * Clear user identity (logout)
894
+ */
895
+ reset() {
896
+ this.ensureInitialized();
897
+ this.cookieManager.clearUserId();
898
+ this.cookieManager.newSession();
899
+ // Reset identities to anonymous only
900
+ this.identities = {
901
+ anonymous: [this.cookieManager.getVisitorId()],
902
+ };
903
+ // Update queue context
904
+ this.queue.setContext(this.cookieManager.getVisitorId(), this.cookieManager.getSessionId(), this.config.clientId, this.identities);
905
+ this.log('User reset');
906
+ }
907
+ /**
908
+ * Manually flush the event queue
909
+ */
910
+ async flush() {
911
+ this.ensureInitialized();
912
+ await this.queue.flush();
913
+ }
914
+ /**
915
+ * Get current visitor ID
916
+ */
917
+ getVisitorId() {
918
+ this.ensureInitialized();
919
+ return this.cookieManager.getVisitorId();
920
+ }
921
+ /**
922
+ * Get current session ID
923
+ */
924
+ getSessionId() {
925
+ this.ensureInitialized();
926
+ return this.cookieManager.getSessionId();
927
+ }
928
+ /**
929
+ * Get current user ID (if identified)
930
+ */
931
+ getUserId() {
932
+ this.ensureInitialized();
933
+ return this.cookieManager.getUserId();
934
+ }
935
+ /**
936
+ * Check if SDK is initialized
937
+ */
938
+ isInitialized() {
939
+ return this.initialized;
940
+ }
941
+ /**
942
+ * Destroy the SDK instance
943
+ */
944
+ destroy() {
945
+ if (this.queue) {
946
+ this.queue.destroy();
947
+ }
948
+ this.initialized = false;
949
+ this.config = null;
950
+ this.cookieManager = null;
951
+ this.transport = null;
952
+ this.queue = null;
953
+ this.contextCollector = null;
954
+ this.identities = {};
955
+ }
956
+ /**
957
+ * Send events to the Sensor
958
+ */
959
+ async sendEvents(events) {
960
+ if (!this.transport || !this.cookieManager) {
961
+ throw new Error('Transport not initialized');
962
+ }
963
+ // Send as batch
964
+ const payload = {
965
+ events: events.map((e) => this.eventToPayload(e)),
966
+ clientId: this.config.clientId,
967
+ visitorId: this.cookieManager.getVisitorId(),
968
+ sessionId: this.cookieManager.getSessionId(),
969
+ identities: this.identities,
970
+ };
971
+ const response = await this.transport.sendBatch(payload);
972
+ if (!response.success) {
973
+ throw new Error(response.error || 'Failed to send events');
974
+ }
975
+ }
976
+ /**
977
+ * Convert internal event to API payload
978
+ */
979
+ eventToPayload(event) {
980
+ return {
981
+ eventType: event.eventType,
982
+ timestamp: event.timestamp,
983
+ properties: event.properties,
984
+ sessionId: event.sessionId,
985
+ };
986
+ }
987
+ /**
988
+ * Ensure SDK is initialized before use
989
+ */
990
+ ensureInitialized() {
991
+ if (!this.initialized) {
992
+ throw new Error('DataWorkbench SDK not initialized. Call DataWorkbench.init() first.');
993
+ }
994
+ }
995
+ /**
996
+ * Log messages in debug mode
997
+ */
998
+ log(message, data) {
999
+ if (this.debug) {
1000
+ console.log(`[DataWorkbench] ${message}`, data ?? '');
1001
+ }
1002
+ }
1003
+ }
1004
+
1005
+ const SELECTOR = 'a, button, [role="button"], [data-track]';
1006
+ /**
1007
+ * Automatic click tracking plugin for the Data Workbench SDK.
1008
+ *
1009
+ * Listens for clicks on interactive elements (links, buttons, role="button",
1010
+ * data-track) and fires a `click` event via the provided track function.
1011
+ */
1012
+ class ClickTracker {
1013
+ constructor(trackFn) {
1014
+ this.trackFn = trackFn;
1015
+ this.handler = this.handleClick.bind(this);
1016
+ document.addEventListener('click', this.handler, { capture: true });
1017
+ }
1018
+ destroy() {
1019
+ if (this.handler) {
1020
+ document.removeEventListener('click', this.handler, { capture: true });
1021
+ this.handler = null;
1022
+ }
1023
+ }
1024
+ handleClick(event) {
1025
+ const target = event.target;
1026
+ const clickable = target.closest?.(SELECTOR);
1027
+ if (!clickable)
1028
+ return;
1029
+ const props = {
1030
+ tagName: clickable.tagName.toLowerCase(),
1031
+ text: (clickable.textContent || '').slice(0, 100).trim(),
1032
+ classList: Array.from(clickable.classList).join(' '),
1033
+ };
1034
+ if (clickable.hasAttribute('data-track')) {
1035
+ props.trackId = clickable.getAttribute('data-track');
1036
+ }
1037
+ if (clickable instanceof HTMLAnchorElement) {
1038
+ props.href = clickable.href;
1039
+ props.isExternal = clickable.hostname !== window.location.hostname;
1040
+ }
1041
+ if (clickable instanceof HTMLButtonElement) {
1042
+ props.buttonType = clickable.type;
1043
+ if (clickable.form) {
1044
+ props.formId = clickable.form.id || undefined;
1045
+ props.formAction = clickable.form.action || undefined;
1046
+ }
1047
+ }
1048
+ this.trackFn('click', props);
1049
+ }
1050
+ }
1051
+
1052
+ // Create singleton instance for simple usage
1053
+ const analytics = new Analytics();
1054
+ /**
1055
+ * DataWorkbench Analytics SDK
1056
+ *
1057
+ * Usage:
1058
+ * ```typescript
1059
+ * import DataWorkbench from '@data-workbench/analytics';
1060
+ *
1061
+ * DataWorkbench.init({
1062
+ * endpoint: 'https://sensor.yourcompany.com',
1063
+ * clientId: 'your-website'
1064
+ * });
1065
+ *
1066
+ * DataWorkbench.track('button_click', { buttonId: 'signup' });
1067
+ * DataWorkbench.identify('user123', { email: 'user@example.com' });
1068
+ * DataWorkbench.page('/products');
1069
+ * ```
1070
+ */
1071
+ const DataWorkbench = {
1072
+ /**
1073
+ * Initialize the SDK
1074
+ */
1075
+ init: analytics.init.bind(analytics),
1076
+ /**
1077
+ * Track a custom event
1078
+ */
1079
+ track: analytics.track.bind(analytics),
1080
+ /**
1081
+ * Track a page view
1082
+ */
1083
+ page: analytics.page.bind(analytics),
1084
+ /**
1085
+ * Identify a user
1086
+ */
1087
+ identify: analytics.identify.bind(analytics),
1088
+ /**
1089
+ * Reset user identity (logout)
1090
+ */
1091
+ reset: analytics.reset.bind(analytics),
1092
+ /**
1093
+ * Manually flush the event queue
1094
+ */
1095
+ flush: analytics.flush.bind(analytics),
1096
+ /**
1097
+ * Get current visitor ID
1098
+ */
1099
+ getVisitorId: analytics.getVisitorId.bind(analytics),
1100
+ /**
1101
+ * Get current session ID
1102
+ */
1103
+ getSessionId: analytics.getSessionId.bind(analytics),
1104
+ /**
1105
+ * Get current user ID
1106
+ */
1107
+ getUserId: analytics.getUserId.bind(analytics),
1108
+ /**
1109
+ * Check if SDK is initialized
1110
+ */
1111
+ isInitialized: analytics.isInitialized.bind(analytics),
1112
+ /**
1113
+ * Destroy the SDK instance
1114
+ */
1115
+ destroy: analytics.destroy.bind(analytics),
1116
+ };
1117
+ // For CDN/script tag usage - attach to window
1118
+ if (typeof window !== 'undefined') {
1119
+ window.DataWorkbench = DataWorkbench;
1120
+ }
1121
+
1122
+ exports.Analytics = Analytics;
1123
+ exports.ClickTracker = ClickTracker;
1124
+ exports.ContextCollector = ContextCollector;
1125
+ exports.CookieManager = CookieManager;
1126
+ exports.CookieStorage = CookieStorage;
1127
+ exports.DataWorkbench = DataWorkbench;
1128
+ exports.EventQueue = EventQueue;
1129
+ exports.HttpTransport = HttpTransport;
1130
+ exports.default = DataWorkbench;
1131
+ //# sourceMappingURL=index.js.map