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