@drakkar.software/sunglasses-core 0.2.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,1612 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ ConsentManager: () => ConsentManager,
24
+ EventCounter: () => EventCounter,
25
+ EventQueue: () => EventQueue,
26
+ FrequencyMiddleware: () => FrequencyMiddleware,
27
+ IdentityManager: () => IdentityManager,
28
+ LocalEventArchive: () => LocalEventArchive,
29
+ MiddlewarePipeline: () => MiddlewarePipeline,
30
+ PiiSanitizer: () => PiiSanitizer,
31
+ SamplingMiddleware: () => SamplingMiddleware,
32
+ SessionManager: () => SessionManager,
33
+ SunglassesCore: () => SunglassesCore,
34
+ TraitManager: () => TraitManager,
35
+ asTyped: () => asTyped,
36
+ createLogger: () => createLogger,
37
+ generateUUID: () => generateUUID,
38
+ nowISO: () => nowISO,
39
+ sha256Hex: () => sha256Hex
40
+ });
41
+ module.exports = __toCommonJS(index_exports);
42
+
43
+ // src/utils/timestamp.ts
44
+ function nowISO() {
45
+ return (/* @__PURE__ */ new Date()).toISOString();
46
+ }
47
+
48
+ // src/ConsentManager.ts
49
+ var STORAGE_KEY = "sunglasses:consent";
50
+ var MAX_HISTORY_LENGTH = 10;
51
+ var ConsentManager = class {
52
+ constructor(storage, logger) {
53
+ this.storage = storage;
54
+ this.logger = logger;
55
+ this.state = { status: "unknown", updatedAt: null };
56
+ }
57
+ /**
58
+ * Load persisted consent state. Must be called once during SDK initialization.
59
+ * @param defaultOptIn — when no persisted state exists, opt-in (true) or opt-out (false)
60
+ * @param policyVersion — current policy version; if it differs from stored, consent resets
61
+ * @param expiryMs — if set, consent older than this resets to 'unknown'
62
+ */
63
+ async initialize(defaultOptIn, policyVersion, expiryMs) {
64
+ try {
65
+ const raw = await this.storage.read(STORAGE_KEY);
66
+ if (raw !== null) {
67
+ const parsed = JSON.parse(raw);
68
+ if (expiryMs !== void 0 && parsed.status !== "unknown" && parsed.updatedAt !== null) {
69
+ const ageMs = Date.now() - new Date(parsed.updatedAt).getTime();
70
+ if (ageMs > expiryMs) {
71
+ this.logger.debug("ConsentManager: consent expired, resetting to unknown");
72
+ await this.resetToUnknown(policyVersion);
73
+ return;
74
+ }
75
+ }
76
+ if (policyVersion !== void 0 && parsed.policyVersion !== void 0 && parsed.policyVersion !== policyVersion) {
77
+ this.logger.debug(
78
+ "ConsentManager: policy version changed",
79
+ parsed.policyVersion,
80
+ "\u2192",
81
+ policyVersion,
82
+ "\u2014 resetting consent"
83
+ );
84
+ const historyEntry = {
85
+ status: "unknown",
86
+ policyVersion,
87
+ timestamp: nowISO()
88
+ };
89
+ this.state = {
90
+ status: "unknown",
91
+ updatedAt: nowISO(),
92
+ policyVersion,
93
+ history: this.appendHistory(parsed.history ?? [], historyEntry)
94
+ };
95
+ await this.persist();
96
+ return;
97
+ }
98
+ this.state = parsed;
99
+ if (policyVersion !== void 0 && this.state.policyVersion !== policyVersion) {
100
+ this.state = { ...this.state, policyVersion };
101
+ await this.persist();
102
+ }
103
+ this.logger.debug("ConsentManager: loaded persisted state", parsed.status);
104
+ return;
105
+ }
106
+ } catch (err) {
107
+ this.logger.warn("ConsentManager: failed to read persisted consent", err);
108
+ }
109
+ const initialStatus = defaultOptIn ? "opted-in" : "opted-out";
110
+ this.state = {
111
+ status: initialStatus,
112
+ updatedAt: null,
113
+ policyVersion,
114
+ history: []
115
+ };
116
+ this.logger.debug(
117
+ "ConsentManager: first run, defaultOptIn=",
118
+ defaultOptIn,
119
+ "\u2192",
120
+ this.state.status
121
+ );
122
+ await this.persist();
123
+ }
124
+ get status() {
125
+ return this.state.status;
126
+ }
127
+ isOptedIn() {
128
+ return this.state.status === "opted-in";
129
+ }
130
+ isOptedOut() {
131
+ return this.state.status === "opted-out";
132
+ }
133
+ async optIn(policyVersion) {
134
+ const timestamp = nowISO();
135
+ const entry = {
136
+ status: "opted-in",
137
+ policyVersion: policyVersion ?? this.state.policyVersion,
138
+ timestamp
139
+ };
140
+ this.state = {
141
+ ...this.state,
142
+ status: "opted-in",
143
+ updatedAt: timestamp,
144
+ policyVersion: policyVersion ?? this.state.policyVersion,
145
+ history: this.appendHistory(this.state.history ?? [], entry)
146
+ };
147
+ this.logger.debug("ConsentManager: opted in");
148
+ await this.persist();
149
+ }
150
+ async optOut(policyVersion) {
151
+ const timestamp = nowISO();
152
+ const entry = {
153
+ status: "opted-out",
154
+ policyVersion: policyVersion ?? this.state.policyVersion,
155
+ timestamp
156
+ };
157
+ this.state = {
158
+ ...this.state,
159
+ status: "opted-out",
160
+ updatedAt: timestamp,
161
+ policyVersion: policyVersion ?? this.state.policyVersion,
162
+ history: this.appendHistory(this.state.history ?? [], entry)
163
+ };
164
+ this.logger.debug("ConsentManager: opted out");
165
+ await this.persist();
166
+ }
167
+ /** Returns a copy of the consent audit trail (oldest first). */
168
+ getHistory() {
169
+ return [...this.state.history ?? []];
170
+ }
171
+ /**
172
+ * Reset consent status to 'unknown'.
173
+ * Used internally by consent expiry and deleteUserData().
174
+ * Appends a history entry so the reset is auditable.
175
+ */
176
+ async resetToUnknown(policyVersion) {
177
+ const timestamp = nowISO();
178
+ const entry = {
179
+ status: "unknown",
180
+ policyVersion: policyVersion ?? this.state.policyVersion,
181
+ timestamp
182
+ };
183
+ this.state = {
184
+ status: "unknown",
185
+ updatedAt: timestamp,
186
+ policyVersion: policyVersion ?? this.state.policyVersion,
187
+ history: this.appendHistory(this.state.history ?? [], entry)
188
+ };
189
+ this.logger.debug("ConsentManager: reset to unknown");
190
+ await this.persist();
191
+ }
192
+ // ── Private ─────────────────────────────────────────────────────────────────
193
+ appendHistory(existing, entry) {
194
+ const updated = [...existing, entry];
195
+ return updated.length > MAX_HISTORY_LENGTH ? updated.slice(updated.length - MAX_HISTORY_LENGTH) : updated;
196
+ }
197
+ async persist() {
198
+ try {
199
+ await this.storage.write(STORAGE_KEY, JSON.stringify(this.state));
200
+ } catch (err) {
201
+ this.logger.warn("ConsentManager: failed to persist consent state", err);
202
+ }
203
+ }
204
+ };
205
+
206
+ // src/EventCounter.ts
207
+ var KEY_PREFIX = "sunglasses:count";
208
+ var EventCounter = class {
209
+ constructor(storage, logger) {
210
+ this.storage = storage;
211
+ this.logger = logger;
212
+ /**
213
+ * In-memory cache of counts keyed by storage key.
214
+ * Updated synchronously on increment so getCount() can return immediately
215
+ * without waiting for storage writes to complete.
216
+ */
217
+ this.cache = /* @__PURE__ */ new Map();
218
+ /**
219
+ * Tracks all storage keys written during this session.
220
+ * Used to reliably clear an event's timed buckets on reset(),
221
+ * even when they fall outside the 90-day sweep window.
222
+ */
223
+ this.writtenKeys = /* @__PURE__ */ new Set();
224
+ }
225
+ /**
226
+ * Increment the count for `eventName` across all four periods for `date`.
227
+ */
228
+ async increment(eventName, date = /* @__PURE__ */ new Date()) {
229
+ const periods = ["daily", "weekly", "monthly", "all-time"];
230
+ await Promise.all(periods.map((period) => this.incrementPeriod(eventName, period, date)));
231
+ }
232
+ async getCount(eventName, period, date = /* @__PURE__ */ new Date()) {
233
+ const key = this.storageKey(eventName, period, date);
234
+ const cached = this.cache.get(key);
235
+ if (cached !== void 0) return cached;
236
+ try {
237
+ const raw = await this.storage.read(key);
238
+ if (raw === null) return 0;
239
+ const n = parseInt(raw, 10);
240
+ const count = Number.isNaN(n) ? 0 : n;
241
+ this.cache.set(key, count);
242
+ return count;
243
+ } catch (err) {
244
+ this.logger.warn("EventCounter: failed to read count", err);
245
+ return 0;
246
+ }
247
+ }
248
+ /**
249
+ * Reset counts for a specific event (all periods), or all events if omitted.
250
+ *
251
+ * Note: resetting all events requires listing storage keys. This is a best-effort
252
+ * operation — adapters that don't support key enumeration will only clear
253
+ * the provided event name's keys.
254
+ */
255
+ async reset(eventName) {
256
+ if (eventName) {
257
+ await this.clearEvent(eventName);
258
+ } else {
259
+ const allKeys = [...this.writtenKeys];
260
+ for (const key of allKeys) {
261
+ try {
262
+ await this.storage.delete(key);
263
+ } catch {
264
+ }
265
+ this.writtenKeys.delete(key);
266
+ this.cache.delete(key);
267
+ }
268
+ const now = /* @__PURE__ */ new Date();
269
+ const seenKeys = new Set(allKeys);
270
+ const periods = ["daily", "weekly", "monthly", "all-time"];
271
+ const eventNames = new Set(
272
+ allKeys.map((k) => k.split(":").pop()).filter(Boolean)
273
+ );
274
+ for (let i = 0; i < 90; i++) {
275
+ const d = new Date(now);
276
+ d.setDate(d.getDate() - i);
277
+ for (const name of eventNames) {
278
+ for (const period of periods) {
279
+ const key = this.storageKey(name, period, d);
280
+ if (!seenKeys.has(key)) {
281
+ seenKeys.add(key);
282
+ try {
283
+ await this.storage.delete(key);
284
+ } catch {
285
+ }
286
+ }
287
+ }
288
+ }
289
+ }
290
+ this.logger.debug("EventCounter.reset(): cleared all tracked event counters");
291
+ }
292
+ }
293
+ // ── Private ──────────────────────────────────────────────────────────────
294
+ async incrementPeriod(eventName, period, date) {
295
+ const key = this.storageKey(eventName, period, date);
296
+ this.writtenKeys.add(key);
297
+ const currentCached = this.cache.get(key) ?? 0;
298
+ const next = currentCached + 1;
299
+ this.cache.set(key, next);
300
+ try {
301
+ await this.storage.write(key, String(next));
302
+ } catch (err) {
303
+ this.logger.warn("EventCounter: failed to persist count", err);
304
+ }
305
+ }
306
+ async clearEvent(eventName) {
307
+ const safeEvent = eventName.replace(/[^a-zA-Z0-9_\-]/g, "_");
308
+ const sessionKeys = [...this.writtenKeys].filter(
309
+ (k) => k.endsWith(`:${safeEvent}`)
310
+ );
311
+ for (const key of sessionKeys) {
312
+ try {
313
+ await this.storage.delete(key);
314
+ } catch {
315
+ }
316
+ this.writtenKeys.delete(key);
317
+ this.cache.delete(key);
318
+ }
319
+ const now = /* @__PURE__ */ new Date();
320
+ const seenBuckets = new Set(sessionKeys);
321
+ for (let i = 0; i < 90; i++) {
322
+ const d = new Date(now);
323
+ d.setDate(d.getDate() - i);
324
+ const periods = ["daily", "weekly", "monthly"];
325
+ for (const period of periods) {
326
+ const key = this.storageKey(eventName, period, d);
327
+ if (!seenBuckets.has(key)) {
328
+ seenBuckets.add(key);
329
+ try {
330
+ await this.storage.delete(key);
331
+ } catch {
332
+ }
333
+ }
334
+ }
335
+ }
336
+ try {
337
+ await this.storage.delete(this.storageKey(eventName, "all-time", /* @__PURE__ */ new Date()));
338
+ } catch {
339
+ }
340
+ }
341
+ /** Build the storage key for a given event, period, and date. */
342
+ storageKey(eventName, period, date) {
343
+ const bucket = this.bucketFor(period, date);
344
+ const safeEvent = eventName.replace(/[^a-zA-Z0-9_\-]/g, "_");
345
+ return `${KEY_PREFIX}:${period}:${bucket}:${safeEvent}`;
346
+ }
347
+ /**
348
+ * Return the time-bucket string for a given period and date.
349
+ * - daily: "2024-01-15"
350
+ * - weekly: "2024-W03"
351
+ * - monthly: "2024-01"
352
+ * - all-time: "all"
353
+ */
354
+ bucketFor(period, date) {
355
+ switch (period) {
356
+ case "daily":
357
+ return date.toISOString().slice(0, 10);
358
+ // "YYYY-MM-DD"
359
+ case "weekly": {
360
+ const iso = toISOWeek(date);
361
+ return iso;
362
+ }
363
+ case "monthly":
364
+ return date.toISOString().slice(0, 7);
365
+ // "YYYY-MM"
366
+ case "all-time":
367
+ return "all";
368
+ }
369
+ }
370
+ };
371
+ function toISOWeek(date) {
372
+ const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
373
+ d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
374
+ const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
375
+ const weekNumber = Math.ceil(
376
+ ((d.getTime() - yearStart.getTime()) / 864e5 + 1) / 7
377
+ );
378
+ return `${d.getUTCFullYear()}-W${String(weekNumber).padStart(2, "0")}`;
379
+ }
380
+
381
+ // src/EventQueue.ts
382
+ var STORAGE_KEY2 = "sunglasses:queue";
383
+ var EventQueue = class {
384
+ constructor(storage, logger, maxSize) {
385
+ this.storage = storage;
386
+ this.logger = logger;
387
+ this.maxSize = maxSize;
388
+ this.queue = [];
389
+ this.persistTimer = null;
390
+ this.persistPending = false;
391
+ }
392
+ /** Load persisted queue. Call once during SDK initialization. */
393
+ async initialize() {
394
+ try {
395
+ const raw = await this.storage.read(STORAGE_KEY2);
396
+ if (raw) {
397
+ const parsed = JSON.parse(raw);
398
+ this.queue = Array.isArray(parsed) ? parsed : [];
399
+ this.logger.debug(`EventQueue: loaded ${this.queue.length} events from storage`);
400
+ }
401
+ } catch (err) {
402
+ this.logger.warn("EventQueue: failed to load persisted queue \u2014 starting empty", err);
403
+ this.queue = [];
404
+ }
405
+ }
406
+ /** Add an event to the queue. Triggers async persistence (debounced). */
407
+ enqueue(event) {
408
+ try {
409
+ JSON.stringify(event);
410
+ } catch {
411
+ this.logger.warn("EventQueue: event contains non-serialisable data \u2014 dropped", event.event);
412
+ return;
413
+ }
414
+ if (this.queue.length >= this.maxSize) {
415
+ this.queue.shift();
416
+ this.logger.warn("EventQueue: maxSize reached \u2014 oldest event dropped");
417
+ }
418
+ this.queue.push(event);
419
+ this.schedulePersist();
420
+ }
421
+ /** Return up to `batchSize` events without removing them. */
422
+ peek(batchSize) {
423
+ return this.queue.slice(0, batchSize);
424
+ }
425
+ /** Remove the first `count` events (call after successful adapter.send). */
426
+ remove(count) {
427
+ this.queue.splice(0, count);
428
+ this.schedulePersist();
429
+ }
430
+ get size() {
431
+ return this.queue.length;
432
+ }
433
+ /** Force-persist the queue immediately. */
434
+ async persist() {
435
+ if (this.persistTimer !== null) {
436
+ clearTimeout(this.persistTimer);
437
+ this.persistTimer = null;
438
+ }
439
+ this.persistPending = false;
440
+ try {
441
+ await this.storage.write(STORAGE_KEY2, JSON.stringify(this.queue));
442
+ } catch (err) {
443
+ this.logger.warn("EventQueue: failed to persist queue", err);
444
+ }
445
+ }
446
+ /** Clear queue from memory and storage. */
447
+ async clear() {
448
+ this.queue = [];
449
+ try {
450
+ await this.storage.delete(STORAGE_KEY2);
451
+ } catch (err) {
452
+ this.logger.warn("EventQueue: failed to clear persisted queue", err);
453
+ }
454
+ }
455
+ schedulePersist() {
456
+ if (this.persistPending) return;
457
+ this.persistPending = true;
458
+ this.persistTimer = setTimeout(() => {
459
+ this.persistPending = false;
460
+ this.persistTimer = null;
461
+ this.persist().catch(() => {
462
+ });
463
+ }, 0);
464
+ }
465
+ };
466
+
467
+ // src/utils/uuid.ts
468
+ function generateUUID() {
469
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
470
+ return crypto.randomUUID();
471
+ }
472
+ if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
473
+ const bytes = new Uint8Array(16);
474
+ crypto.getRandomValues(bytes);
475
+ bytes[6] = bytes[6] & 15 | 64;
476
+ bytes[8] = bytes[8] & 63 | 128;
477
+ const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
478
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
479
+ }
480
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
481
+ const r = Math.random() * 16 | 0;
482
+ const v = c === "x" ? r : r & 3 | 8;
483
+ return v.toString(16);
484
+ });
485
+ }
486
+ async function sha256Hex(input) {
487
+ const encoder = new TextEncoder();
488
+ const data = encoder.encode(input);
489
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
490
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
491
+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
492
+ }
493
+
494
+ // src/IdentityManager.ts
495
+ var ANON_ID_KEY = "sunglasses:anon_id";
496
+ var DISTINCT_ID_KEY = "sunglasses:distinct_id";
497
+ var IdentityManager = class {
498
+ constructor(storage, logger, anonymizeUserId) {
499
+ this.storage = storage;
500
+ this.logger = logger;
501
+ this.anonymizeUserId = anonymizeUserId;
502
+ this.anonymousId = "";
503
+ this.distinctId = null;
504
+ }
505
+ /** Load persisted identity. Must be called once during SDK initialization. */
506
+ async initialize() {
507
+ try {
508
+ const storedAnon = await this.storage.read(ANON_ID_KEY);
509
+ if (storedAnon) {
510
+ this.anonymousId = storedAnon;
511
+ } else {
512
+ this.anonymousId = generateUUID();
513
+ await this.storage.write(ANON_ID_KEY, this.anonymousId);
514
+ }
515
+ } catch (err) {
516
+ this.logger.warn("IdentityManager: failed to load anonymousId, generating new one", err);
517
+ this.anonymousId = generateUUID();
518
+ }
519
+ try {
520
+ const storedDistinct = await this.storage.read(DISTINCT_ID_KEY);
521
+ this.distinctId = storedDistinct ?? null;
522
+ } catch (err) {
523
+ this.logger.warn("IdentityManager: failed to load distinctId", err);
524
+ }
525
+ this.logger.debug("IdentityManager: initialized", {
526
+ anonymousId: this.anonymousId,
527
+ isIdentified: this.distinctId !== null
528
+ });
529
+ }
530
+ getState() {
531
+ return {
532
+ anonymousId: this.anonymousId,
533
+ distinctId: this.distinctId,
534
+ isIdentified: this.distinctId !== null
535
+ };
536
+ }
537
+ getAnonymousId() {
538
+ return this.anonymousId;
539
+ }
540
+ /**
541
+ * Resolved identity for use in events.
542
+ * Returns distinctId if set, otherwise anonymousId.
543
+ */
544
+ getEffectiveDistinctId() {
545
+ return this.distinctId ?? this.anonymousId;
546
+ }
547
+ /**
548
+ * Link current session to a known user.
549
+ * @param userId — the raw user identifier (hashed if anonymizeUserId=true)
550
+ * @throws if userId is empty or whitespace-only
551
+ */
552
+ async identify(userId) {
553
+ if (!userId || userId.trim().length === 0) {
554
+ throw new Error("IdentityManager: userId cannot be empty");
555
+ }
556
+ const resolvedId = this.anonymizeUserId ? await sha256Hex(userId) : userId;
557
+ this.distinctId = resolvedId;
558
+ try {
559
+ await this.storage.write(DISTINCT_ID_KEY, resolvedId);
560
+ } catch (err) {
561
+ this.logger.warn("IdentityManager: failed to persist distinctId", err);
562
+ }
563
+ this.logger.debug("IdentityManager: identified", { isAnonymized: this.anonymizeUserId });
564
+ return resolvedId;
565
+ }
566
+ /**
567
+ * Clear identity and generate a fresh anonymous ID.
568
+ * Adapters should also call their own reset() if applicable.
569
+ */
570
+ async reset() {
571
+ this.distinctId = null;
572
+ this.anonymousId = generateUUID();
573
+ try {
574
+ await this.storage.delete(DISTINCT_ID_KEY);
575
+ await this.storage.write(ANON_ID_KEY, this.anonymousId);
576
+ } catch (err) {
577
+ this.logger.warn("IdentityManager: failed to persist reset identity", err);
578
+ }
579
+ this.logger.debug("IdentityManager: reset \u2014 new anonymousId generated");
580
+ }
581
+ };
582
+
583
+ // src/LocalEventArchive.ts
584
+ var STORAGE_KEY3 = "sunglasses:archive";
585
+ var LocalEventArchive = class {
586
+ constructor(storage, logger) {
587
+ this.storage = storage;
588
+ this.logger = logger;
589
+ this.events = [];
590
+ }
591
+ /**
592
+ * Load all previously archived events from storage.
593
+ * Call once during SDK initialization.
594
+ */
595
+ async initialize() {
596
+ try {
597
+ const raw = await this.storage.read(STORAGE_KEY3);
598
+ if (raw !== null) {
599
+ const store = JSON.parse(raw);
600
+ this.events = store.events ?? [];
601
+ this.logger.debug(
602
+ "LocalEventArchive: loaded",
603
+ this.events.length,
604
+ "archived events"
605
+ );
606
+ }
607
+ } catch (err) {
608
+ this.logger.warn("LocalEventArchive: failed to load archive", err);
609
+ this.events = [];
610
+ }
611
+ }
612
+ /**
613
+ * Append events to the archive.
614
+ * Deduplicates by `messageId` — safe to call multiple times with the same batch.
615
+ */
616
+ async append(events) {
617
+ if (events.length === 0) return;
618
+ const existingIds = new Set(this.events.map((e) => e.messageId));
619
+ const newEvents = events.filter((e) => !existingIds.has(e.messageId));
620
+ if (newEvents.length === 0) return;
621
+ this.events = [...this.events, ...newEvents];
622
+ await this.persist();
623
+ }
624
+ /** All archived events (oldest first). */
625
+ getAll() {
626
+ return [...this.events];
627
+ }
628
+ /** Number of archived events. */
629
+ get size() {
630
+ return this.events.length;
631
+ }
632
+ /**
633
+ * Prune archived events by age and/or count.
634
+ * `maxEventsPerIdentity` is applied **per distinctId** — each identity keeps its
635
+ * most recent N events independently. If neither option is set, nothing is removed.
636
+ */
637
+ async cleanup(config = {}) {
638
+ let filtered = [...this.events];
639
+ if (config.maxAgeMs && config.maxAgeMs > 0) {
640
+ const cutoff = Date.now() - config.maxAgeMs;
641
+ filtered = filtered.filter((e) => {
642
+ const ts = new Date(e.timestamp).getTime();
643
+ return !Number.isNaN(ts) && ts >= cutoff;
644
+ });
645
+ }
646
+ const maxN = config.maxEventsPerIdentity ?? 0;
647
+ if (maxN > 0) {
648
+ const byIdentity = /* @__PURE__ */ new Map();
649
+ for (const event of filtered) {
650
+ const id = event.distinctId;
651
+ if (!byIdentity.has(id)) byIdentity.set(id, []);
652
+ byIdentity.get(id).push(event);
653
+ }
654
+ const kept = /* @__PURE__ */ new Set();
655
+ for (const events of byIdentity.values()) {
656
+ const slice = events.slice(Math.max(0, events.length - maxN));
657
+ for (const e of slice) kept.add(e.messageId);
658
+ }
659
+ filtered = filtered.filter((e) => kept.has(e.messageId));
660
+ }
661
+ this.events = filtered;
662
+ await this.persist();
663
+ this.logger.debug("LocalEventArchive: after cleanup, size =", this.events.length);
664
+ }
665
+ /**
666
+ * Remove all archived events and clear storage.
667
+ * This is irreversible.
668
+ */
669
+ async clear() {
670
+ this.events = [];
671
+ try {
672
+ await this.storage.delete(STORAGE_KEY3);
673
+ } catch (err) {
674
+ this.logger.warn("LocalEventArchive: failed to clear storage", err);
675
+ }
676
+ }
677
+ // ── Private ─────────────────────────────────────────────────────────────────
678
+ async persist() {
679
+ try {
680
+ const store = { version: "1", events: this.events };
681
+ await this.storage.write(STORAGE_KEY3, JSON.stringify(store));
682
+ } catch (err) {
683
+ this.logger.warn("LocalEventArchive: failed to persist archive", err);
684
+ }
685
+ }
686
+ };
687
+
688
+ // src/MiddlewarePipeline.ts
689
+ var MiddlewarePipeline = class {
690
+ constructor(middleware, logger) {
691
+ this.middleware = middleware;
692
+ this.logger = logger;
693
+ }
694
+ async run(event) {
695
+ let index = 0;
696
+ const next = async (current) => {
697
+ if (index >= this.middleware.length) {
698
+ return current;
699
+ }
700
+ const mw = this.middleware[index++];
701
+ try {
702
+ const result = await mw.process(current, next);
703
+ if (result === null) {
704
+ this.logger.debug(`MiddlewarePipeline: event dropped by "${mw.name}"`);
705
+ }
706
+ return result;
707
+ } catch (err) {
708
+ this.logger.error(
709
+ `MiddlewarePipeline: middleware "${mw.name}" threw an error (event dropped)`,
710
+ err
711
+ );
712
+ return null;
713
+ }
714
+ };
715
+ return next(event);
716
+ }
717
+ };
718
+
719
+ // src/PiiSanitizer.ts
720
+ var BUILTIN_DENIED_KEYS = /* @__PURE__ */ new Set([
721
+ "email",
722
+ "phone",
723
+ "password",
724
+ "passwd",
725
+ "secret",
726
+ "ssn",
727
+ "social_security",
728
+ "credit_card",
729
+ "card_number",
730
+ "cvv",
731
+ "ip",
732
+ "ip_address"
733
+ ]);
734
+ var PII_PATTERNS = [
735
+ // Email (requires letter-only TLD)
736
+ /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/,
737
+ // Phone (US/international, various formats)
738
+ /(\+?1?\s?)?\(?\d{3}\)?[\s.\-]\d{3}[\s.\-]\d{4}/,
739
+ // IPv4
740
+ /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/,
741
+ // Credit card (major formats: 16 digits with optional separators)
742
+ /\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b/
743
+ ];
744
+ var PiiSanitizer = class {
745
+ constructor(allowedProperties, deniedProperties) {
746
+ this.allowedProperties = allowedProperties;
747
+ this.deniedProperties = deniedProperties;
748
+ this.name = "PiiSanitizer";
749
+ }
750
+ async process(event, next) {
751
+ const sanitized = this.sanitizeProperties(event.properties);
752
+ return next({ ...event, properties: sanitized });
753
+ }
754
+ sanitizeProperties(props) {
755
+ let result = { ...props };
756
+ if (this.allowedProperties && this.allowedProperties.length > 0) {
757
+ const allowed = new Set(this.allowedProperties);
758
+ result = Object.fromEntries(
759
+ Object.entries(result).filter(([key]) => allowed.has(key))
760
+ );
761
+ return this.deepSanitizeValues(result);
762
+ }
763
+ if (this.deniedProperties && this.deniedProperties.length > 0) {
764
+ const denied = new Set(this.deniedProperties);
765
+ result = Object.fromEntries(
766
+ Object.entries(result).filter(([key]) => !denied.has(key))
767
+ );
768
+ }
769
+ return this.deepSanitizeValues(result);
770
+ }
771
+ /**
772
+ * Recursively traverse an object or array:
773
+ * - Remove keys in BUILTIN_DENIED_KEYS (case-insensitive)
774
+ * - Redact string values that contain PII
775
+ * - Recurse into nested objects and arrays
776
+ */
777
+ deepSanitizeValues(value) {
778
+ if (Array.isArray(value)) {
779
+ return value.map((item) => this.deepSanitizeValues(item));
780
+ }
781
+ if (value !== null && typeof value === "object") {
782
+ const obj = value;
783
+ const result = {};
784
+ for (const [key, val] of Object.entries(obj)) {
785
+ if (BUILTIN_DENIED_KEYS.has(key.toLowerCase())) continue;
786
+ result[key] = this.deepSanitizeValues(val);
787
+ }
788
+ return result;
789
+ }
790
+ if (typeof value === "string" && this.containsPii(value)) {
791
+ return "[redacted]";
792
+ }
793
+ return value;
794
+ }
795
+ containsPii(value) {
796
+ for (const pattern of PII_PATTERNS) {
797
+ if (pattern.test(value)) return true;
798
+ }
799
+ return false;
800
+ }
801
+ };
802
+
803
+ // src/SessionManager.ts
804
+ var STORAGE_KEY4 = "sunglasses:session";
805
+ var DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1e3;
806
+ var SessionManager = class {
807
+ constructor(storage, logger, idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS) {
808
+ this.storage = storage;
809
+ this.logger = logger;
810
+ this.idleTimeoutMs = idleTimeoutMs;
811
+ this.session = null;
812
+ this.idleTimer = null;
813
+ }
814
+ /**
815
+ * Load the previous session from storage. If it has already expired, it is
816
+ * discarded so that `getOrCreate()` will start a fresh one.
817
+ */
818
+ async initialize() {
819
+ try {
820
+ const raw = await this.storage.read(STORAGE_KEY4);
821
+ if (raw !== null) {
822
+ const state = JSON.parse(raw);
823
+ const idleSinceMs = Date.now() - new Date(state.lastActiveAt).getTime();
824
+ if (idleSinceMs < this.idleTimeoutMs) {
825
+ this.session = state;
826
+ this.logger.debug("SessionManager: resumed session", state.sessionId);
827
+ this.resetIdleTimer();
828
+ return;
829
+ }
830
+ this.logger.debug("SessionManager: previous session expired");
831
+ }
832
+ } catch (err) {
833
+ this.logger.warn("SessionManager: failed to load persisted session", err);
834
+ }
835
+ this.session = null;
836
+ }
837
+ /**
838
+ * Return the current session, creating a new one if none exists.
839
+ * A `$session_start` event should be emitted by the caller whenever this
840
+ * method creates a new session (detectable by checking whether the returned
841
+ * `eventCount` is 0).
842
+ */
843
+ getOrCreate() {
844
+ if (this.session === null) {
845
+ this.session = {
846
+ sessionId: generateUUID(),
847
+ startedAt: nowISO(),
848
+ lastActiveAt: nowISO(),
849
+ eventCount: 0
850
+ };
851
+ this.logger.debug("SessionManager: new session", this.session.sessionId);
852
+ this.persist().catch(() => {
853
+ });
854
+ this.resetIdleTimer();
855
+ }
856
+ return this.session;
857
+ }
858
+ /**
859
+ * Record activity — resets the idle expiry timer and updates `lastActiveAt`.
860
+ * Call this after every event is successfully enqueued.
861
+ */
862
+ touch(now = /* @__PURE__ */ new Date()) {
863
+ if (this.session === null) return;
864
+ this.session = {
865
+ ...this.session,
866
+ lastActiveAt: now.toISOString(),
867
+ eventCount: this.session.eventCount + 1
868
+ };
869
+ this.persist().catch(() => {
870
+ });
871
+ this.resetIdleTimer();
872
+ }
873
+ /**
874
+ * Explicitly end the current session (e.g. on sign-out or reset).
875
+ * Clears both in-memory state and storage.
876
+ */
877
+ async end() {
878
+ this.session = null;
879
+ this.clearIdleTimer();
880
+ try {
881
+ await this.storage.delete(STORAGE_KEY4);
882
+ } catch (err) {
883
+ this.logger.warn("SessionManager: failed to clear persisted session", err);
884
+ }
885
+ }
886
+ /** The current session ID, or null if no session is active. */
887
+ get sessionId() {
888
+ return this.session?.sessionId ?? null;
889
+ }
890
+ // ── Private ─────────────────────────────────────────────────────────────────
891
+ resetIdleTimer() {
892
+ this.clearIdleTimer();
893
+ this.idleTimer = setTimeout(() => {
894
+ this.logger.debug("SessionManager: session expired due to inactivity");
895
+ this.session = null;
896
+ this.storage.delete(STORAGE_KEY4).catch(() => {
897
+ });
898
+ }, this.idleTimeoutMs);
899
+ }
900
+ clearIdleTimer() {
901
+ if (this.idleTimer !== null) {
902
+ clearTimeout(this.idleTimer);
903
+ this.idleTimer = null;
904
+ }
905
+ }
906
+ async persist() {
907
+ if (this.session === null) return;
908
+ try {
909
+ await this.storage.write(STORAGE_KEY4, JSON.stringify(this.session));
910
+ } catch (err) {
911
+ this.logger.warn("SessionManager: failed to persist session", err);
912
+ }
913
+ }
914
+ };
915
+
916
+ // src/TraitManager.ts
917
+ var STORAGE_KEY5 = "sunglasses:traits";
918
+ var SENSITIVE_KEYS = /* @__PURE__ */ new Set([
919
+ "email",
920
+ "phone",
921
+ "password",
922
+ "passwd",
923
+ "secret",
924
+ "ssn",
925
+ "social_security",
926
+ "credit_card",
927
+ "card_number",
928
+ "cvv",
929
+ "ip",
930
+ "ip_address"
931
+ ]);
932
+ var TraitManager = class {
933
+ constructor(storage, logger) {
934
+ this.storage = storage;
935
+ this.logger = logger;
936
+ this.traits = {};
937
+ }
938
+ /**
939
+ * Load persisted traits from storage. Call once during SDK initialization.
940
+ */
941
+ async initialize() {
942
+ try {
943
+ const raw = await this.storage.read(STORAGE_KEY5);
944
+ if (raw !== null) {
945
+ this.traits = JSON.parse(raw);
946
+ this.logger.debug("TraitManager: loaded traits", Object.keys(this.traits));
947
+ }
948
+ } catch (err) {
949
+ this.logger.warn("TraitManager: failed to load persisted traits", err);
950
+ this.traits = {};
951
+ }
952
+ }
953
+ /**
954
+ * Merge new traits into the persisted set.
955
+ * Sensitive keys (email, phone, password, etc.) are stripped silently.
956
+ * Passing `null` as a value removes that key.
957
+ */
958
+ async setTraits(traits) {
959
+ const sanitized = this.sanitize(traits);
960
+ for (const [key, value] of Object.entries(sanitized)) {
961
+ if (value === null) {
962
+ delete this.traits[key];
963
+ } else {
964
+ this.traits[key] = value;
965
+ }
966
+ }
967
+ await this.persist();
968
+ }
969
+ /** Return a shallow copy of the current traits object. */
970
+ getTraits() {
971
+ return { ...this.traits };
972
+ }
973
+ /** Remove all stored traits (called on reset()). */
974
+ async clearTraits() {
975
+ this.traits = {};
976
+ try {
977
+ await this.storage.delete(STORAGE_KEY5);
978
+ } catch (err) {
979
+ this.logger.warn("TraitManager: failed to clear persisted traits", err);
980
+ }
981
+ }
982
+ // ── Private ─────────────────────────────────────────────────────────────────
983
+ sanitize(traits) {
984
+ const result = {};
985
+ for (const [key, value] of Object.entries(traits)) {
986
+ if (SENSITIVE_KEYS.has(key.toLowerCase())) {
987
+ this.logger.debug("TraitManager: stripped sensitive key", key);
988
+ continue;
989
+ }
990
+ result[key] = deepClone(value);
991
+ }
992
+ return result;
993
+ }
994
+ async persist() {
995
+ try {
996
+ await this.storage.write(STORAGE_KEY5, JSON.stringify(this.traits));
997
+ } catch (err) {
998
+ this.logger.warn("TraitManager: failed to persist traits", err);
999
+ }
1000
+ }
1001
+ };
1002
+ function deepClone(value) {
1003
+ if (value === null || typeof value !== "object") return value;
1004
+ try {
1005
+ return JSON.parse(JSON.stringify(value));
1006
+ } catch {
1007
+ return value;
1008
+ }
1009
+ }
1010
+
1011
+ // src/utils/logger.ts
1012
+ var PREFIX = "[SunGlasses]";
1013
+ function createLogger(debug) {
1014
+ return {
1015
+ debug(...args) {
1016
+ if (debug) {
1017
+ console.log(PREFIX, "[debug]", ...args);
1018
+ }
1019
+ },
1020
+ info(...args) {
1021
+ if (debug) {
1022
+ console.log(PREFIX, "[info]", ...args);
1023
+ }
1024
+ },
1025
+ warn(...args) {
1026
+ console.warn(PREFIX, "[warn]", ...args);
1027
+ },
1028
+ error(...args) {
1029
+ console.error(PREFIX, "[error]", ...args);
1030
+ }
1031
+ };
1032
+ }
1033
+
1034
+ // src/SunglassesCore.ts
1035
+ var LIBRARY_NAME = "@drakkar.software/sunglasses-core";
1036
+ var LIBRARY_VERSION = "0.2.0";
1037
+ var DEFAULT_FLUSH_INTERVAL = 3e4;
1038
+ var DEFAULT_MAX_QUEUE_SIZE = 500;
1039
+ var DEFAULT_MAX_BATCH_SIZE = 50;
1040
+ var DEFAULT_SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1e3;
1041
+ var SunglassesCore = class _SunglassesCore {
1042
+ constructor(config, consent, identity, queue, pipeline, eventCounter, sessionManager, traitManager, localArchive) {
1043
+ this.flushTimer = null;
1044
+ this.isShutdown = false;
1045
+ /** Guard against concurrent flushes — prevents double-send. */
1046
+ this.flushInFlight = false;
1047
+ /** In-memory super properties merged into every event's properties. */
1048
+ this.superProperties = /* @__PURE__ */ new Map();
1049
+ /** In-memory group ID attached to every event's context after group() is called. */
1050
+ this.groupId = null;
1051
+ this.config = {
1052
+ defaultOptIn: config.defaultOptIn ?? false,
1053
+ flushInterval: config.flushInterval ?? DEFAULT_FLUSH_INTERVAL,
1054
+ maxBatchSize: config.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE,
1055
+ maxQueueSize: config.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE,
1056
+ platform: config.platform ?? "web",
1057
+ appName: config.appName ?? "",
1058
+ appVersion: config.appVersion ?? "",
1059
+ appBuild: config.appBuild ?? "",
1060
+ debug: config.debug ?? false,
1061
+ disabled: config.disabled ?? false,
1062
+ anonymizeUserId: config.anonymizeUserId ?? false,
1063
+ consentPolicyVersion: config.consentPolicyVersion
1064
+ };
1065
+ this.consent = consent;
1066
+ this.identity = identity;
1067
+ this.queue = queue;
1068
+ this.pipeline = pipeline;
1069
+ this.adapters = config.adapters;
1070
+ this._eventCounter = eventCounter;
1071
+ this.cleanupConfig = config.cleanupAfterFlush ?? null;
1072
+ this.sessionManager = sessionManager;
1073
+ this.traitManager = traitManager;
1074
+ this.localArchive = localArchive;
1075
+ }
1076
+ /**
1077
+ * Create and initialize a SunglassesCore instance.
1078
+ * This is the only way to construct the SDK.
1079
+ */
1080
+ static async create(config) {
1081
+ if (!config.adapters || config.adapters.length === 0) {
1082
+ throw new Error("SunglassesCore: at least one adapter is required");
1083
+ }
1084
+ const logger = createLogger(config.debug ?? false);
1085
+ const consent = new ConsentManager(config.storage, logger);
1086
+ const identity = new IdentityManager(
1087
+ config.storage,
1088
+ logger,
1089
+ config.anonymizeUserId ?? false
1090
+ );
1091
+ const queue = new EventQueue(
1092
+ config.storage,
1093
+ logger,
1094
+ config.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE
1095
+ );
1096
+ const piiSanitizer = new PiiSanitizer(config.allowedProperties, config.deniedProperties);
1097
+ const middlewares = [piiSanitizer, ...config.middleware ?? []];
1098
+ const pipeline = new MiddlewarePipeline(middlewares, logger);
1099
+ const eventCounter = config.enableEventCounting ? new EventCounter(config.storage, logger) : null;
1100
+ const sessionManager = config.enableSessionTracking ? new SessionManager(
1101
+ config.storage,
1102
+ logger,
1103
+ config.sessionIdleTimeoutMs ?? DEFAULT_SESSION_IDLE_TIMEOUT_MS
1104
+ ) : null;
1105
+ const traitManager = new TraitManager(config.storage, logger);
1106
+ const localArchive = config.enableLocalArchive ? new LocalEventArchive(config.storage, logger) : null;
1107
+ const instance = new _SunglassesCore(
1108
+ config,
1109
+ consent,
1110
+ identity,
1111
+ queue,
1112
+ pipeline,
1113
+ eventCounter,
1114
+ sessionManager,
1115
+ traitManager,
1116
+ localArchive
1117
+ );
1118
+ await consent.initialize(config.defaultOptIn ?? false, config.consentPolicyVersion, config.consentExpiryMs);
1119
+ await identity.initialize();
1120
+ await queue.initialize();
1121
+ await traitManager.initialize();
1122
+ if (sessionManager) {
1123
+ await sessionManager.initialize();
1124
+ }
1125
+ if (localArchive) {
1126
+ await localArchive.initialize();
1127
+ }
1128
+ if (config.respectDoNotTrack !== false && (config.platform ?? "web") === "web" && consent.status === "unknown" && "navigator" in globalThis) {
1129
+ const nav = globalThis["navigator"];
1130
+ const hasGpc = nav.globalPrivacyControl === true;
1131
+ const hasDnt = nav.doNotTrack === "1";
1132
+ if (hasGpc || hasDnt) {
1133
+ await consent.optOut();
1134
+ logger.debug("SunglassesCore: auto opted-out via privacy signal", { gpc: hasGpc, dnt: hasDnt });
1135
+ }
1136
+ }
1137
+ if (!(config.disabled ?? false)) {
1138
+ instance.startFlushTimer();
1139
+ }
1140
+ logger.debug("SunglassesCore: initialized", {
1141
+ platform: config.platform ?? "web",
1142
+ defaultOptIn: config.defaultOptIn ?? false,
1143
+ consentStatus: consent.status,
1144
+ eventCounting: config.enableEventCounting ?? false,
1145
+ sessionTracking: config.enableSessionTracking ?? false,
1146
+ localArchive: config.enableLocalArchive ?? false
1147
+ });
1148
+ return instance;
1149
+ }
1150
+ // ── Public API ─────────────────────────────────────────────────────────────
1151
+ capture(eventName, properties, options) {
1152
+ this.enqueueEvent("capture", eventName, properties, options);
1153
+ }
1154
+ screen(screenName, properties) {
1155
+ this.enqueueEvent("screen", "$screen", {
1156
+ ...properties,
1157
+ $screen_name: screenName
1158
+ });
1159
+ }
1160
+ identify(userId, traits) {
1161
+ if (!this.canCapture()) return;
1162
+ if (traits && Object.keys(traits).length > 0) {
1163
+ this.traitManager.setTraits(traits).catch(() => {
1164
+ });
1165
+ }
1166
+ this.identity.identify(userId).then((resolvedId) => {
1167
+ this.enqueueEvent("identify", "$identify", {
1168
+ ...traits,
1169
+ $user_id: resolvedId
1170
+ });
1171
+ }).catch(() => {
1172
+ this.enqueueEvent("identify", "$identify", { ...traits });
1173
+ });
1174
+ }
1175
+ alias(newId, existingId) {
1176
+ this.enqueueEvent("alias", "$alias", {
1177
+ alias: newId,
1178
+ previous_id: existingId
1179
+ });
1180
+ }
1181
+ group(groupId, groupTraits) {
1182
+ if (!this.canCapture()) return;
1183
+ this.groupId = groupId;
1184
+ this.enqueueEvent("group", "$group", {
1185
+ ...groupTraits,
1186
+ $group_id: groupId
1187
+ });
1188
+ }
1189
+ // ── Super properties ──────────────────────────────────────────────────────
1190
+ register(properties) {
1191
+ for (const [key, value] of Object.entries(properties)) {
1192
+ this.superProperties.set(key, value);
1193
+ }
1194
+ }
1195
+ unregister(...keys) {
1196
+ if (keys.length === 0) {
1197
+ this.superProperties.clear();
1198
+ return;
1199
+ }
1200
+ for (const key of keys) {
1201
+ this.superProperties.delete(key);
1202
+ }
1203
+ }
1204
+ getRegisteredProperties() {
1205
+ return Object.fromEntries(this.superProperties);
1206
+ }
1207
+ async reset() {
1208
+ await this.identity.reset();
1209
+ await this.queue.clear();
1210
+ await this.traitManager.clearTraits();
1211
+ this.groupId = null;
1212
+ if (this.sessionManager) {
1213
+ await this.sessionManager.end();
1214
+ }
1215
+ for (const adapter of this.adapters) {
1216
+ await adapter.reset?.();
1217
+ }
1218
+ }
1219
+ async optIn() {
1220
+ await this.consent.optIn();
1221
+ this.startFlushTimer();
1222
+ }
1223
+ async optOut() {
1224
+ await this.consent.optOut();
1225
+ this.stopFlushTimer();
1226
+ await this.queue.clear();
1227
+ }
1228
+ hasOptedIn() {
1229
+ return this.consent.isOptedIn();
1230
+ }
1231
+ hasOptedOut() {
1232
+ return this.consent.isOptedOut();
1233
+ }
1234
+ getConsentStatus() {
1235
+ return this.consent.status;
1236
+ }
1237
+ getConsentHistory() {
1238
+ return this.consent.getHistory();
1239
+ }
1240
+ async flush() {
1241
+ if (!this.canCapture()) return;
1242
+ await this.flushOnce();
1243
+ }
1244
+ async shutdown() {
1245
+ if (this.isShutdown) return;
1246
+ this.isShutdown = true;
1247
+ this.stopFlushTimer();
1248
+ if (this.canCapture()) {
1249
+ await this.flushOnce();
1250
+ }
1251
+ for (const adapter of this.adapters) {
1252
+ await adapter.shutdown?.();
1253
+ }
1254
+ }
1255
+ // ── Event counting ─────────────────────────────────────────────────────────
1256
+ get eventCounter() {
1257
+ return this._eventCounter;
1258
+ }
1259
+ async getEventCount(eventName, period, date) {
1260
+ if (!this._eventCounter) return 0;
1261
+ return this._eventCounter.getCount(eventName, period, date);
1262
+ }
1263
+ async resetEventCount(eventName) {
1264
+ await this._eventCounter?.reset(eventName);
1265
+ }
1266
+ getQueuedEventCount() {
1267
+ return this.queue.size;
1268
+ }
1269
+ // ── Local event archive ────────────────────────────────────────────────────
1270
+ /**
1271
+ * Prune the local event archive by age/count, or clear it entirely.
1272
+ * Pass `{}` to clear all archived events.
1273
+ * No-op when `enableLocalArchive` was not set in config.
1274
+ */
1275
+ async clearLocalArchive(config = {}) {
1276
+ if (!this.localArchive) return;
1277
+ if (config.maxAgeMs === void 0 && config.maxEventsPerIdentity === void 0) {
1278
+ await this.localArchive.clear();
1279
+ } else {
1280
+ await this.localArchive.cleanup(config);
1281
+ }
1282
+ }
1283
+ // ── Data portability ───────────────────────────────────────────────────────
1284
+ /**
1285
+ * Export all locally held user data as a machine-readable object.
1286
+ * GDPR Article 20 — right to data portability.
1287
+ */
1288
+ async exportUserData() {
1289
+ const identityState = this.identity.getState();
1290
+ const queuedEvents = this.queue.peek(this.config.maxQueueSize);
1291
+ const eventCountSummary = {};
1292
+ if (this._eventCounter) {
1293
+ const eventNames = [...new Set(queuedEvents.map((e) => e.event))];
1294
+ for (const name of eventNames) {
1295
+ const [daily, weekly, monthly, allTime] = await Promise.all([
1296
+ this._eventCounter.getCount(name, "daily"),
1297
+ this._eventCounter.getCount(name, "weekly"),
1298
+ this._eventCounter.getCount(name, "monthly"),
1299
+ this._eventCounter.getCount(name, "all-time")
1300
+ ]);
1301
+ eventCountSummary[name] = { daily, weekly, monthly, "all-time": allTime };
1302
+ }
1303
+ }
1304
+ return {
1305
+ exportedAt: nowISO(),
1306
+ anonymousId: identityState.anonymousId,
1307
+ distinctId: identityState.distinctId,
1308
+ consentStatus: this.consent.status,
1309
+ consentHistory: this.consent.getHistory(),
1310
+ traits: this.traitManager.getTraits(),
1311
+ queuedEvents,
1312
+ archivedEvents: this.localArchive ? this.localArchive.getAll() : [],
1313
+ eventCountSummary
1314
+ };
1315
+ }
1316
+ // ── Data erasure ───────────────────────────────────────────────────────────
1317
+ /**
1318
+ * Erase all locally held user data. GDPR Article 17 — right to erasure.
1319
+ */
1320
+ async deleteUserData(options = {}) {
1321
+ await this.queue.clear();
1322
+ await this.traitManager.clearTraits();
1323
+ await this._eventCounter?.reset();
1324
+ if (this.sessionManager) await this.sessionManager.end();
1325
+ if (this.localArchive) await this.localArchive.clear();
1326
+ await this.identity.reset();
1327
+ this.groupId = null;
1328
+ this.superProperties.clear();
1329
+ if (options.resetConsent) {
1330
+ await this.consent.resetToUnknown(this.config.consentPolicyVersion);
1331
+ this.stopFlushTimer();
1332
+ }
1333
+ for (const adapter of this.adapters) {
1334
+ await adapter.reset?.();
1335
+ }
1336
+ }
1337
+ // ── Private helpers ────────────────────────────────────────────────────────
1338
+ canCapture() {
1339
+ if (this.config.disabled) return false;
1340
+ if (this.isShutdown) return false;
1341
+ if (!this.consent.isOptedIn()) return false;
1342
+ return true;
1343
+ }
1344
+ enqueueEvent(type, eventName, properties, options) {
1345
+ if (!this.canCapture()) return;
1346
+ const identityState = this.identity.getState();
1347
+ let sessionId;
1348
+ let isNewSession = false;
1349
+ if (this.sessionManager) {
1350
+ const before = this.sessionManager.sessionId;
1351
+ const session = this.sessionManager.getOrCreate();
1352
+ sessionId = session.sessionId;
1353
+ isNewSession = before !== session.sessionId;
1354
+ }
1355
+ const mergedProperties = {
1356
+ ...Object.fromEntries(this.superProperties),
1357
+ ...properties ?? {}
1358
+ };
1359
+ const event = {
1360
+ type,
1361
+ event: eventName,
1362
+ distinctId: this.identity.getEffectiveDistinctId(),
1363
+ anonymousId: identityState.anonymousId,
1364
+ timestamp: options?.timestamp ?? nowISO(),
1365
+ messageId: options?.messageId ?? generateUUID(),
1366
+ properties: mergedProperties,
1367
+ context: this.buildContext(sessionId)
1368
+ };
1369
+ if (this._eventCounter && type === "capture") {
1370
+ this._eventCounter.increment(eventName).catch(() => {
1371
+ });
1372
+ }
1373
+ if (isNewSession) {
1374
+ this.emitSessionStart(sessionId);
1375
+ }
1376
+ this.pipeline.run(event).then((processed) => {
1377
+ if (processed !== null) {
1378
+ this.queue.enqueue(processed);
1379
+ this.localArchive?.append([processed]).catch(() => {
1380
+ });
1381
+ this.sessionManager?.touch();
1382
+ if (this.queue.size >= this.config.maxBatchSize) {
1383
+ this.flushOnce().catch(() => {
1384
+ });
1385
+ }
1386
+ }
1387
+ }).catch(() => {
1388
+ });
1389
+ }
1390
+ emitSessionStart(sessionId) {
1391
+ const identityState = this.identity.getState();
1392
+ const event = {
1393
+ type: "capture",
1394
+ event: "$session_start",
1395
+ distinctId: this.identity.getEffectiveDistinctId(),
1396
+ anonymousId: identityState.anonymousId,
1397
+ timestamp: nowISO(),
1398
+ messageId: generateUUID(),
1399
+ properties: { $session_id: sessionId },
1400
+ context: this.buildContext(sessionId)
1401
+ };
1402
+ this.pipeline.run(event).then((processed) => {
1403
+ if (processed !== null) {
1404
+ this.queue.enqueue(processed);
1405
+ }
1406
+ }).catch(() => {
1407
+ });
1408
+ }
1409
+ buildContext(sessionId) {
1410
+ const ctx = {
1411
+ library: { name: LIBRARY_NAME, version: LIBRARY_VERSION },
1412
+ platform: this.config.platform
1413
+ };
1414
+ if (this.config.appName || this.config.appVersion) {
1415
+ ctx.app = {
1416
+ name: this.config.appName || void 0,
1417
+ version: this.config.appVersion || void 0,
1418
+ build: this.config.appBuild || void 0
1419
+ };
1420
+ }
1421
+ if (sessionId !== void 0) {
1422
+ ctx.sessionId = sessionId;
1423
+ }
1424
+ const traits = this.traitManager.getTraits();
1425
+ if (Object.keys(traits).length > 0) {
1426
+ ctx.traits = traits;
1427
+ }
1428
+ if (this.groupId !== null) {
1429
+ ctx.group = { id: this.groupId };
1430
+ }
1431
+ if (this.config.platform === "web") {
1432
+ if ("navigator" in globalThis) {
1433
+ const nav = globalThis["navigator"];
1434
+ const ua = nav.userAgent ?? "";
1435
+ let os = "Unknown";
1436
+ if (/iPhone|iPad|iPod/.test(ua)) os = "iOS";
1437
+ else if (/Android/.test(ua)) os = "Android";
1438
+ else if (/Windows/.test(ua)) os = "Windows";
1439
+ else if (/Mac OS X/.test(ua)) os = "macOS";
1440
+ else if (/Linux/.test(ua)) os = "Linux";
1441
+ let deviceType = "desktop";
1442
+ if (/iPhone|iPod/.test(ua) || /Android/.test(ua) && !/Tablet|tablet/.test(ua) && !/iPad/.test(ua)) {
1443
+ deviceType = "mobile";
1444
+ } else if (/iPad/.test(ua) || /Tablet|tablet/.test(ua)) {
1445
+ deviceType = "tablet";
1446
+ }
1447
+ ctx.device = { type: deviceType, os };
1448
+ if (nav.language) ctx.locale = nav.language;
1449
+ }
1450
+ if ("screen" in globalThis) {
1451
+ const scr = globalThis["screen"];
1452
+ if (scr.width && scr.height) {
1453
+ ctx.screen = { width: scr.width, height: scr.height };
1454
+ }
1455
+ }
1456
+ }
1457
+ return ctx;
1458
+ }
1459
+ /**
1460
+ * Flush up to maxBatchSize events to every adapter.
1461
+ *
1462
+ * Guards:
1463
+ * - If a flush is already in flight, returns immediately (no double-send).
1464
+ * - If ALL adapters succeed, events are removed from the queue.
1465
+ * - If ANY adapter fails, events stay in the queue for the next flush attempt.
1466
+ * The failed adapter's own retry logic handles re-delivery.
1467
+ */
1468
+ async flushOnce() {
1469
+ if (this.flushInFlight) return;
1470
+ if (this.queue.size === 0) return;
1471
+ const batch = this.queue.peek(this.config.maxBatchSize);
1472
+ if (batch.length === 0) return;
1473
+ const safeBatch = Object.freeze([...batch]);
1474
+ this.flushInFlight = true;
1475
+ try {
1476
+ let allSucceeded = true;
1477
+ for (const adapter of this.adapters) {
1478
+ try {
1479
+ await adapter.send(safeBatch);
1480
+ } catch {
1481
+ allSucceeded = false;
1482
+ }
1483
+ }
1484
+ if (allSucceeded) {
1485
+ this.queue.remove(batch.length);
1486
+ await this.queue.persist();
1487
+ if (this.cleanupConfig) {
1488
+ for (const adapter of this.adapters) {
1489
+ adapter.cleanupAfterFlush?.(safeBatch, this.cleanupConfig)?.catch(() => {
1490
+ });
1491
+ }
1492
+ }
1493
+ }
1494
+ } finally {
1495
+ this.flushInFlight = false;
1496
+ }
1497
+ }
1498
+ startFlushTimer() {
1499
+ if (this.flushTimer !== null) return;
1500
+ if (this.config.disabled) return;
1501
+ this.flushTimer = setInterval(() => {
1502
+ this.flushOnce().catch(() => {
1503
+ });
1504
+ }, this.config.flushInterval);
1505
+ }
1506
+ stopFlushTimer() {
1507
+ if (this.flushTimer !== null) {
1508
+ clearInterval(this.flushTimer);
1509
+ this.flushTimer = null;
1510
+ }
1511
+ }
1512
+ };
1513
+
1514
+ // src/middleware/FrequencyMiddleware.ts
1515
+ var FrequencyMiddleware = class {
1516
+ constructor(options) {
1517
+ this.name = "FrequencyMiddleware";
1518
+ this.counter = options.counter;
1519
+ this.periods = options.periods ?? ["daily", "monthly"];
1520
+ this.onlyFor = options.onlyFor ? new Set(options.onlyFor) : null;
1521
+ }
1522
+ async process(event, next) {
1523
+ if (event.type !== "capture") return next(event);
1524
+ if (this.onlyFor !== null && !this.onlyFor.has(event.event)) return next(event);
1525
+ const now = new Date(event.timestamp);
1526
+ const counts = await Promise.all(
1527
+ this.periods.map(async (period) => {
1528
+ const count = await this.counter.getCount(event.event, period, now);
1529
+ return [periodPropKey(period), count];
1530
+ })
1531
+ );
1532
+ const countProps = Object.fromEntries(counts);
1533
+ return next({
1534
+ ...event,
1535
+ properties: { ...event.properties, ...countProps }
1536
+ });
1537
+ }
1538
+ };
1539
+ function periodPropKey(period) {
1540
+ const keys = {
1541
+ "daily": "$count_daily",
1542
+ "weekly": "$count_weekly",
1543
+ "monthly": "$count_monthly",
1544
+ "all-time": "$count_all_time"
1545
+ };
1546
+ return keys[period];
1547
+ }
1548
+
1549
+ // src/middleware/SamplingMiddleware.ts
1550
+ var SamplingMiddleware = class {
1551
+ constructor(options) {
1552
+ this.name = "SamplingMiddleware";
1553
+ if (options.sampleRate < 0 || options.sampleRate > 1) {
1554
+ throw new Error("SamplingMiddleware: sampleRate must be between 0 and 1");
1555
+ }
1556
+ this.sampleRate = options.sampleRate;
1557
+ this.onlyFor = options.onlyFor ? new Set(options.onlyFor) : null;
1558
+ this.consistentSampling = options.consistentSampling ?? false;
1559
+ }
1560
+ async process(event, next) {
1561
+ if (event.type !== "capture") return next(event);
1562
+ if (this.onlyFor !== null && !this.onlyFor.has(event.event)) return next(event);
1563
+ const shouldKeep = this.consistentSampling ? this.isIncludedByIdentity(event.anonymousId) : Math.random() < this.sampleRate;
1564
+ if (!shouldKeep) return null;
1565
+ return next({
1566
+ ...event,
1567
+ properties: {
1568
+ ...event.properties,
1569
+ $sample_rate: this.sampleRate
1570
+ }
1571
+ });
1572
+ }
1573
+ /**
1574
+ * Determine inclusion based on anonymousId hash.
1575
+ * Produces a stable 0–1 value for the identity so the decision is consistent
1576
+ * across all events in the same session.
1577
+ */
1578
+ isIncludedByIdentity(anonymousId) {
1579
+ let hash = 2166136261;
1580
+ for (let i = 0; i < anonymousId.length; i++) {
1581
+ hash ^= anonymousId.charCodeAt(i);
1582
+ hash = hash * 16777619 >>> 0;
1583
+ }
1584
+ const fraction = hash / 4294967295;
1585
+ return fraction < this.sampleRate;
1586
+ }
1587
+ };
1588
+
1589
+ // src/asTyped.ts
1590
+ function asTyped(client) {
1591
+ return client;
1592
+ }
1593
+ // Annotate the CommonJS export names for ESM import in node:
1594
+ 0 && (module.exports = {
1595
+ ConsentManager,
1596
+ EventCounter,
1597
+ EventQueue,
1598
+ FrequencyMiddleware,
1599
+ IdentityManager,
1600
+ LocalEventArchive,
1601
+ MiddlewarePipeline,
1602
+ PiiSanitizer,
1603
+ SamplingMiddleware,
1604
+ SessionManager,
1605
+ SunglassesCore,
1606
+ TraitManager,
1607
+ asTyped,
1608
+ createLogger,
1609
+ generateUUID,
1610
+ nowISO,
1611
+ sha256Hex
1612
+ });