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