@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/data-workbench-analytics.js +1137 -0
- package/dist/data-workbench-analytics.js.map +1 -0
- package/dist/data-workbench-analytics.min.js +2 -0
- package/dist/data-workbench-analytics.min.js.map +1 -0
- package/dist/index.d.ts +487 -0
- package/dist/index.esm.js +1119 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +1131 -0
- package/dist/index.js.map +1 -0
- package/package.json +44 -0
|
@@ -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
|