@yraylabs/boring-analytics 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,603 @@
1
+ 'use strict';
2
+
3
+ // src/transport.ts
4
+ var Transport = class {
5
+ constructor(config) {
6
+ this.config = config;
7
+ }
8
+ async sendEvents(events) {
9
+ await this.post("/ingest/events", { events });
10
+ }
11
+ async sendErrors(errors) {
12
+ await this.post("/ingest/errors", { errors });
13
+ }
14
+ async post(path, body) {
15
+ const url = this.config.endpoint.replace(/\/+$/, "") + path;
16
+ let lastError;
17
+ for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
18
+ try {
19
+ const response = await fetch(url, {
20
+ method: "POST",
21
+ headers: {
22
+ "Content-Type": "application/json",
23
+ "x-api-key": this.config.apiKey
24
+ },
25
+ body: JSON.stringify(body),
26
+ keepalive: true
27
+ });
28
+ if (response.ok) return;
29
+ if (response.status >= 400 && response.status < 500 && response.status !== 429) {
30
+ const text = await response.text().catch(() => "");
31
+ throw new Error(`HTTP ${response.status}: ${text}`);
32
+ }
33
+ lastError = new Error(`HTTP ${response.status}`);
34
+ } catch (err) {
35
+ lastError = err instanceof Error ? err : new Error(String(err));
36
+ if (err instanceof Error && err.message.startsWith("HTTP 4")) {
37
+ break;
38
+ }
39
+ }
40
+ if (attempt < this.config.maxRetries) {
41
+ await this.backoff(attempt);
42
+ }
43
+ }
44
+ if (lastError) {
45
+ this.config.onError?.(lastError);
46
+ }
47
+ }
48
+ backoff(attempt) {
49
+ const delay = Math.min(1e3 * 2 ** attempt, 3e4);
50
+ const jitter = delay * (0.5 + Math.random() * 0.5);
51
+ return new Promise((resolve) => setTimeout(resolve, jitter));
52
+ }
53
+ };
54
+
55
+ // src/queue.ts
56
+ var EventQueue = class {
57
+ constructor(transport, config) {
58
+ this.items = [];
59
+ this.timer = null;
60
+ this.flushing = false;
61
+ this.transport = transport;
62
+ this.config = config;
63
+ this.startTimer();
64
+ }
65
+ add(item) {
66
+ this.items.push(item);
67
+ this.config.debug.log(`Queued ${item.kind}:`, item.kind === "event" ? item.payload.name : item.payload.message);
68
+ if (this.items.length >= this.config.flushAt) {
69
+ void this.flush();
70
+ }
71
+ }
72
+ async flush() {
73
+ if (this.flushing || this.items.length === 0) return;
74
+ this.flushing = true;
75
+ const batch = this.items.splice(0);
76
+ try {
77
+ const events = [];
78
+ const errors = [];
79
+ for (const item of batch) {
80
+ if (item.kind === "event") events.push(item.payload);
81
+ else errors.push(item.payload);
82
+ }
83
+ const promises = [];
84
+ if (events.length > 0) {
85
+ this.config.debug.log(`Flushing ${events.length} events`);
86
+ promises.push(this.transport.sendEvents(events));
87
+ }
88
+ if (errors.length > 0) {
89
+ this.config.debug.log(`Flushing ${errors.length} errors`);
90
+ promises.push(this.transport.sendErrors(errors));
91
+ }
92
+ await Promise.all(promises);
93
+ } catch {
94
+ this.items.unshift(...batch);
95
+ } finally {
96
+ this.flushing = false;
97
+ }
98
+ }
99
+ startTimer() {
100
+ if (this.config.flushInterval > 0) {
101
+ this.timer = setInterval(() => {
102
+ void this.flush();
103
+ }, this.config.flushInterval);
104
+ if (this.timer && typeof this.timer.unref === "function") {
105
+ this.timer.unref();
106
+ }
107
+ }
108
+ }
109
+ async shutdown() {
110
+ if (this.timer) {
111
+ clearInterval(this.timer);
112
+ this.timer = null;
113
+ }
114
+ await this.flush();
115
+ }
116
+ get size() {
117
+ return this.items.length;
118
+ }
119
+ };
120
+
121
+ // src/utils.ts
122
+ function generateId() {
123
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
124
+ return crypto.randomUUID();
125
+ }
126
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
127
+ const r = Math.random() * 16 | 0;
128
+ const v = c === "x" ? r : r & 3 | 8;
129
+ return v.toString(16);
130
+ });
131
+ }
132
+ function isBrowser() {
133
+ return typeof window !== "undefined" && typeof document !== "undefined";
134
+ }
135
+ function createDebugLogger(enabled) {
136
+ return {
137
+ log: (...args) => {
138
+ if (enabled) console.log("[BoringAnalytics]", ...args);
139
+ },
140
+ warn: (...args) => {
141
+ if (enabled) console.warn("[BoringAnalytics]", ...args);
142
+ },
143
+ error: (...args) => {
144
+ if (enabled) console.error("[BoringAnalytics]", ...args);
145
+ }
146
+ };
147
+ }
148
+
149
+ // src/session.ts
150
+ var ANON_ID_KEY = "anonymous_id";
151
+ var SESSION_ID_KEY = "session_id";
152
+ var SESSION_EXPIRY_KEY = "session_expiry";
153
+ var SESSION_TIMEOUT_MS = 30 * 60 * 1e3;
154
+ var SessionManager = class {
155
+ constructor(storage) {
156
+ this.userId = null;
157
+ this.storage = storage;
158
+ this.anonymousId = this.storage.get(ANON_ID_KEY) ?? this.createAnonymousId();
159
+ this.sessionId = this.resolveSession();
160
+ }
161
+ createAnonymousId() {
162
+ const id = generateId();
163
+ this.storage.set(ANON_ID_KEY, id);
164
+ return id;
165
+ }
166
+ resolveSession() {
167
+ const existingSession = this.storage.get(SESSION_ID_KEY);
168
+ const expiryStr = this.storage.get(SESSION_EXPIRY_KEY);
169
+ const expiry = expiryStr ? parseInt(expiryStr, 10) : 0;
170
+ if (existingSession && Date.now() < expiry) {
171
+ this.touchSession();
172
+ return existingSession;
173
+ }
174
+ return this.startNewSession();
175
+ }
176
+ startNewSession() {
177
+ const id = generateId();
178
+ this.sessionId = id;
179
+ this.storage.set(SESSION_ID_KEY, id);
180
+ this.touchSession();
181
+ return id;
182
+ }
183
+ touchSession() {
184
+ this.storage.set(
185
+ SESSION_EXPIRY_KEY,
186
+ String(Date.now() + SESSION_TIMEOUT_MS)
187
+ );
188
+ }
189
+ setUserId(id) {
190
+ this.userId = id;
191
+ }
192
+ getUserId() {
193
+ return this.userId;
194
+ }
195
+ getAnonymousId() {
196
+ return this.anonymousId;
197
+ }
198
+ getSessionId() {
199
+ this.sessionId = this.resolveSession();
200
+ return this.sessionId;
201
+ }
202
+ reset() {
203
+ this.userId = null;
204
+ this.anonymousId = this.createAnonymousId();
205
+ this.sessionId = this.startNewSession();
206
+ }
207
+ };
208
+
209
+ // src/error-handler.ts
210
+ var GlobalErrorHandler = class {
211
+ constructor(callback) {
212
+ this.installed = false;
213
+ this.originalOnError = null;
214
+ this.originalOnUnhandledRejection = null;
215
+ this.handleNodeError = (error) => {
216
+ this.callback(error, "uncaughtException");
217
+ };
218
+ this.handleNodeRejection = (reason) => {
219
+ const error = reason instanceof Error ? reason : new Error(String(reason));
220
+ this.callback(error, "unhandledRejection");
221
+ };
222
+ this.callback = callback;
223
+ }
224
+ install() {
225
+ if (this.installed) return;
226
+ this.installed = true;
227
+ if (isBrowser()) {
228
+ this.installBrowser();
229
+ } else {
230
+ this.installNode();
231
+ }
232
+ }
233
+ uninstall() {
234
+ if (!this.installed) return;
235
+ this.installed = false;
236
+ if (isBrowser()) {
237
+ this.uninstallBrowser();
238
+ } else {
239
+ this.uninstallNode();
240
+ }
241
+ }
242
+ installBrowser() {
243
+ this.originalOnError = window.onerror;
244
+ window.onerror = (message, source, lineno, colno, error) => {
245
+ if (error) {
246
+ this.callback(error, source ?? void 0);
247
+ } else if (typeof message === "string") {
248
+ this.callback(new Error(message), source ?? void 0);
249
+ }
250
+ this.originalOnError?.call(window, message, source, lineno, colno, error);
251
+ };
252
+ this.originalOnUnhandledRejection = window.onunhandledrejection;
253
+ window.onunhandledrejection = (event) => {
254
+ const error = event.reason instanceof Error ? event.reason : new Error(String(event.reason));
255
+ this.callback(error, "unhandledrejection");
256
+ this.originalOnUnhandledRejection?.call(window, event);
257
+ };
258
+ }
259
+ uninstallBrowser() {
260
+ window.onerror = this.originalOnError;
261
+ window.onunhandledrejection = this.originalOnUnhandledRejection;
262
+ }
263
+ installNode() {
264
+ if (typeof process === "undefined") return;
265
+ process.on("uncaughtException", this.handleNodeError);
266
+ process.on("unhandledRejection", this.handleNodeRejection);
267
+ }
268
+ uninstallNode() {
269
+ if (typeof process === "undefined") return;
270
+ process.removeListener("uncaughtException", this.handleNodeError);
271
+ process.removeListener("unhandledRejection", this.handleNodeRejection);
272
+ }
273
+ };
274
+
275
+ // src/context.ts
276
+ function parseUserAgent(ua) {
277
+ const browser = { name: "Unknown", version: void 0 };
278
+ const os = { name: "Unknown", version: void 0 };
279
+ const device = { type: "desktop" };
280
+ if (/Mobi|Android|iPhone|iPad|iPod/i.test(ua)) {
281
+ device.type = /iPad|Tablet/i.test(ua) ? "tablet" : "mobile";
282
+ }
283
+ if (/Edg\//i.test(ua)) {
284
+ browser.name = "Edge";
285
+ browser.version = ua.match(/Edg\/([\d.]+)/)?.[1];
286
+ } else if (/OPR\//i.test(ua) || /Opera/i.test(ua)) {
287
+ browser.name = "Opera";
288
+ browser.version = ua.match(/(?:OPR|Opera)\/([\d.]+)/)?.[1];
289
+ } else if (/Chrome\//i.test(ua) && !/Chromium/i.test(ua)) {
290
+ browser.name = "Chrome";
291
+ browser.version = ua.match(/Chrome\/([\d.]+)/)?.[1];
292
+ } else if (/Safari\//i.test(ua) && !/Chrome/i.test(ua)) {
293
+ browser.name = "Safari";
294
+ browser.version = ua.match(/Version\/([\d.]+)/)?.[1];
295
+ } else if (/Firefox\//i.test(ua)) {
296
+ browser.name = "Firefox";
297
+ browser.version = ua.match(/Firefox\/([\d.]+)/)?.[1];
298
+ }
299
+ if (/Windows NT/i.test(ua)) {
300
+ os.name = "Windows";
301
+ const ntVersion = ua.match(/Windows NT ([\d.]+)/)?.[1];
302
+ const versionMap = {
303
+ "10.0": "10+",
304
+ "6.3": "8.1",
305
+ "6.2": "8",
306
+ "6.1": "7"
307
+ };
308
+ os.version = ntVersion ? versionMap[ntVersion] ?? ntVersion : void 0;
309
+ } else if (/Mac OS X/i.test(ua)) {
310
+ os.name = "macOS";
311
+ os.version = ua.match(/Mac OS X ([\d_.]+)/)?.[1]?.replace(/_/g, ".");
312
+ } else if (/Android/i.test(ua)) {
313
+ os.name = "Android";
314
+ os.version = ua.match(/Android ([\d.]+)/)?.[1];
315
+ } else if (/iPhone OS|iPad/i.test(ua)) {
316
+ os.name = "iOS";
317
+ os.version = ua.match(/OS ([\d_]+)/)?.[1]?.replace(/_/g, ".");
318
+ } else if (/Linux/i.test(ua)) {
319
+ os.name = "Linux";
320
+ }
321
+ return { browser, os, device };
322
+ }
323
+ function gatherContext() {
324
+ if (!isBrowser()) {
325
+ return {};
326
+ }
327
+ const ua = navigator.userAgent;
328
+ const { browser, os, device } = parseUserAgent(ua);
329
+ return {
330
+ userAgent: ua,
331
+ locale: navigator.language,
332
+ page: {
333
+ url: window.location.href,
334
+ referrer: document.referrer || void 0,
335
+ title: document.title || void 0,
336
+ path: window.location.pathname
337
+ },
338
+ browser,
339
+ os,
340
+ device
341
+ };
342
+ }
343
+ function mergeContext(auto, user) {
344
+ if (!user) return auto;
345
+ return {
346
+ ...auto,
347
+ ...user,
348
+ page: { ...auto.page, ...user.page },
349
+ browser: { ...auto.browser, ...user.browser },
350
+ os: { ...auto.os, ...user.os },
351
+ device: { ...auto.device, ...user.device }
352
+ };
353
+ }
354
+
355
+ // src/storage.ts
356
+ var PREFIX = "ba_";
357
+ var LocalStorage = class {
358
+ get(key) {
359
+ try {
360
+ return localStorage.getItem(PREFIX + key);
361
+ } catch {
362
+ return null;
363
+ }
364
+ }
365
+ set(key, value) {
366
+ try {
367
+ localStorage.setItem(PREFIX + key, value);
368
+ } catch {
369
+ }
370
+ }
371
+ remove(key) {
372
+ try {
373
+ localStorage.removeItem(PREFIX + key);
374
+ } catch {
375
+ }
376
+ }
377
+ };
378
+ var MemoryStorage = class {
379
+ constructor() {
380
+ this.store = /* @__PURE__ */ new Map();
381
+ }
382
+ get(key) {
383
+ return this.store.get(PREFIX + key) ?? null;
384
+ }
385
+ set(key, value) {
386
+ this.store.set(PREFIX + key, value);
387
+ }
388
+ remove(key) {
389
+ this.store.delete(PREFIX + key);
390
+ }
391
+ };
392
+ function createStorage() {
393
+ try {
394
+ if (typeof localStorage !== "undefined") {
395
+ localStorage.setItem("__ba_test__", "1");
396
+ localStorage.removeItem("__ba_test__");
397
+ return new LocalStorage();
398
+ }
399
+ } catch {
400
+ }
401
+ return new MemoryStorage();
402
+ }
403
+
404
+ // src/client.ts
405
+ var DEFAULT_ENDPOINT = "https://api.boringanalytics.io";
406
+ var DEFAULT_FLUSH_AT = 20;
407
+ var DEFAULT_FLUSH_INTERVAL = 5e3;
408
+ var DEFAULT_MAX_RETRIES = 3;
409
+ var BoringAnalytics = class {
410
+ constructor(config) {
411
+ this.errorHandler = null;
412
+ this.pageViewUnsubscribe = null;
413
+ if (!config.apiKey) {
414
+ throw new Error("BoringAnalytics: apiKey is required");
415
+ }
416
+ this.config = {
417
+ ...config,
418
+ defaultProperties: config.defaultProperties ?? {}
419
+ };
420
+ this.debug = createDebugLogger(config.debug ?? false);
421
+ this.debug.log("Initializing with endpoint:", config.endpoint ?? DEFAULT_ENDPOINT);
422
+ const storage = createStorage();
423
+ this.session = new SessionManager(storage);
424
+ const transport = new Transport({
425
+ endpoint: config.endpoint ?? DEFAULT_ENDPOINT,
426
+ apiKey: config.apiKey,
427
+ maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,
428
+ onError: config.onError
429
+ });
430
+ this.queue = new EventQueue(transport, {
431
+ flushAt: config.flushAt ?? DEFAULT_FLUSH_AT,
432
+ flushInterval: config.flushInterval ?? DEFAULT_FLUSH_INTERVAL,
433
+ debug: this.debug
434
+ });
435
+ const autoCapture = config.autoCapture ?? {};
436
+ if (autoCapture.errors !== false) {
437
+ this.errorHandler = new GlobalErrorHandler((error, source) => {
438
+ this.captureError(error, {
439
+ handled: false,
440
+ metadata: { source }
441
+ });
442
+ });
443
+ this.errorHandler.install();
444
+ }
445
+ if (autoCapture.pageViews && isBrowser()) {
446
+ this.installPageViewTracking();
447
+ }
448
+ }
449
+ // --- Public API ---
450
+ /**
451
+ * Track a named event.
452
+ *
453
+ * @example
454
+ * analytics.track('button_clicked', { properties: { buttonId: 'signup' } });
455
+ */
456
+ track(name, options = {}) {
457
+ this.enqueueEvent("TRACK", name, options.properties, options.timestamp, options.context);
458
+ }
459
+ /**
460
+ * Identify a user with traits.
461
+ * Sets the userId for all subsequent calls.
462
+ *
463
+ * @example
464
+ * analytics.identify('user-123', { traits: { email: 'user@example.com' } });
465
+ */
466
+ identify(userId, options = {}) {
467
+ this.session.setUserId(userId);
468
+ this.enqueueEvent("IDENTIFY", "identify", options.traits, options.timestamp, options.context);
469
+ }
470
+ /**
471
+ * Track a page view (typically browser).
472
+ *
473
+ * @example
474
+ * analytics.page('Home');
475
+ * analytics.page('Product', { properties: { productId: '123' } });
476
+ */
477
+ page(name, options = {}) {
478
+ const pageName = name ?? (isBrowser() ? document.title : "Page View");
479
+ this.enqueueEvent("PAGE", pageName, options.properties, options.timestamp, options.context);
480
+ }
481
+ /**
482
+ * Track a screen view (typically mobile).
483
+ */
484
+ screen(name, options = {}) {
485
+ this.enqueueEvent("SCREEN", name, options.properties, options.timestamp, options.context);
486
+ }
487
+ /**
488
+ * Associate a user with a group.
489
+ */
490
+ group(groupId, options = {}) {
491
+ this.enqueueEvent("GROUP", groupId, options.traits, options.timestamp, options.context);
492
+ }
493
+ /**
494
+ * Create an alias between two user identities.
495
+ */
496
+ alias(userId, previousId) {
497
+ this.enqueueEvent("ALIAS", "alias", { userId, previousId });
498
+ }
499
+ /**
500
+ * Capture an error for error monitoring.
501
+ *
502
+ * @example
503
+ * try { riskyOp(); }
504
+ * catch (err) { analytics.captureError(err, { tags: { env: 'prod' } }); }
505
+ */
506
+ captureError(error, options = {}) {
507
+ const err = typeof error === "string" ? new Error(error) : error;
508
+ const autoContext = gatherContext();
509
+ const payload = {
510
+ type: err.name || "Error",
511
+ message: err.message,
512
+ stackTrace: err.stack,
513
+ level: options.level ?? "ERROR",
514
+ fingerprint: options.fingerprint,
515
+ handled: options.handled ?? true,
516
+ metadata: options.metadata,
517
+ tags: options.tags,
518
+ timestamp: (options.timestamp ?? /* @__PURE__ */ new Date()).toISOString(),
519
+ userId: this.session.getUserId() ?? void 0,
520
+ sessionId: this.session.getSessionId(),
521
+ context: {
522
+ userAgent: autoContext.userAgent,
523
+ browser: autoContext.browser?.name,
524
+ os: autoContext.os?.name,
525
+ device: autoContext.device?.type,
526
+ url: autoContext.page?.url,
527
+ release: options.release
528
+ }
529
+ };
530
+ this.queue.add({ kind: "error", payload });
531
+ }
532
+ /**
533
+ * Flush all queued events and errors immediately.
534
+ */
535
+ async flush() {
536
+ await this.queue.flush();
537
+ }
538
+ /**
539
+ * Reset the current user. Clears userId, generates new anonymousId and sessionId.
540
+ * Call this on logout.
541
+ */
542
+ reset() {
543
+ this.session.reset();
544
+ this.debug.log("User reset");
545
+ }
546
+ /**
547
+ * Gracefully shut down: flush remaining events, remove global handlers.
548
+ */
549
+ async shutdown() {
550
+ this.debug.log("Shutting down");
551
+ this.errorHandler?.uninstall();
552
+ this.pageViewUnsubscribe?.();
553
+ await this.queue.shutdown();
554
+ }
555
+ // --- Internal ---
556
+ enqueueEvent(type, name, properties, timestamp, userContext) {
557
+ const autoContext = gatherContext();
558
+ const context = mergeContext(autoContext, userContext);
559
+ const payload = {
560
+ type,
561
+ name,
562
+ properties: { ...this.config.defaultProperties, ...properties },
563
+ timestamp: (timestamp ?? /* @__PURE__ */ new Date()).toISOString(),
564
+ userId: this.session.getUserId() ?? void 0,
565
+ sessionId: this.session.getSessionId(),
566
+ anonymousId: this.session.getAnonymousId(),
567
+ context
568
+ };
569
+ this.queue.add({ kind: "event", payload });
570
+ }
571
+ installPageViewTracking() {
572
+ if (!isBrowser()) return;
573
+ let lastUrl = window.location.href;
574
+ const checkUrl = () => {
575
+ const currentUrl = window.location.href;
576
+ if (currentUrl !== lastUrl) {
577
+ lastUrl = currentUrl;
578
+ this.page();
579
+ }
580
+ };
581
+ const origPushState = history.pushState.bind(history);
582
+ const origReplaceState = history.replaceState.bind(history);
583
+ history.pushState = (...args) => {
584
+ origPushState(...args);
585
+ checkUrl();
586
+ };
587
+ history.replaceState = (...args) => {
588
+ origReplaceState(...args);
589
+ checkUrl();
590
+ };
591
+ window.addEventListener("popstate", checkUrl);
592
+ this.pageViewUnsubscribe = () => {
593
+ history.pushState = origPushState;
594
+ history.replaceState = origReplaceState;
595
+ window.removeEventListener("popstate", checkUrl);
596
+ };
597
+ this.page();
598
+ }
599
+ };
600
+
601
+ exports.BoringAnalytics = BoringAnalytics;
602
+ //# sourceMappingURL=index.js.map
603
+ //# sourceMappingURL=index.js.map