@logg/signals 0.1.5 → 0.3.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/README.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  Universal event tracking SDK for Logg Signals. Track events from web, React Native, and Node.js applications.
4
4
 
5
- **Version 0.1.4** - Test release
5
+ Also ships an embeddable on-device recommender as a separate entry point — see [On-Device Recommender](#on-device-recommender-loggsignalsreco).
6
+
7
+ **Version 0.3.0**
6
8
 
7
9
  ## Features
8
10
 
@@ -12,7 +14,7 @@ Universal event tracking SDK for Logg Signals. Track events from web, React Nati
12
14
  ✅ **Persistent storage** - Uses localStorage, AsyncStorage, or memory as fallback
13
15
  ✅ **Retry logic** - Exponential backoff for failed requests
14
16
  ✅ **Auto metadata** - Automatically collects browser/device information
15
- ✅ **Small bundle** - <5KB gzipped
17
+ ✅ **Small bundle** - <5KB gzipped (tracking entry; recommender is a separate opt-in entry)
16
18
 
17
19
  ## Installation
18
20
 
@@ -168,12 +170,53 @@ const queueSize = signals.getQueueSize();
168
170
 
169
171
  ### `signals.destroy()`
170
172
 
171
- Destroy the client, flush remaining events, and cleanup resources.
173
+ Destroy the client. **Drains the entire queue** (flushes batches until empty
174
+ or sends start stalling) before shutting down. Always `await` this on
175
+ process exit / app teardown — anything left in the in-memory queue after
176
+ the Node process exits is lost.
172
177
 
173
178
  ```typescript
174
179
  await signals.destroy();
175
180
  ```
176
181
 
182
+ ### `signals.flushAll()`
183
+
184
+ Drain the queue without destroying the client. Useful for backfill scripts
185
+ that want a checkpoint before moving on. Returns the number of events that
186
+ could not be sent (zero on full success).
187
+
188
+ ```typescript
189
+ const stranded = await signals.flushAll();
190
+ if (stranded > 0) {
191
+ console.warn(`${stranded} events failed to deliver`);
192
+ }
193
+ ```
194
+
195
+ ### `signals.on('error', listener)` / `signals.off('error', listener)`
196
+
197
+ Subscribe to delivery errors. `signals.event()` and `signals.flush()` never
198
+ throw on backend failures, so this is how you observe them. The listener
199
+ receives a `SignalsErrorEvent`:
200
+
201
+ ```typescript
202
+ const off = signals.on('error', (e) => {
203
+ // e.type: 'send_failed' | 'send_retry' | 'destroy_pending'
204
+ // e.message: human-readable description
205
+ // e.error: the underlying Error
206
+ // e.batchId: uuid of the failing/retrying batch (if applicable)
207
+ // e.eventCount: number of events in the affected batch
208
+ // e.pendingCount: number of events still queued after the failure
209
+ // e.attempt: 1-indexed retry attempt (for 'send_retry' only)
210
+ console.error('[signals]', e.type, e.message, e.pendingCount, 'queued');
211
+ });
212
+
213
+ // later
214
+ off();
215
+ ```
216
+
217
+ If no `error` listener is attached, the SDK falls back to `console.warn`
218
+ so failures are at least visible during development.
219
+
177
220
  ## Event Batching
178
221
 
179
222
  Events are automatically batched to reduce network requests:
@@ -414,6 +457,75 @@ function trackUserEvent(event: Omit<EventData, 'userId'>) {
414
457
  }
415
458
  ```
416
459
 
460
+ ## On-Device Recommender (`@logg/signals/reco`)
461
+
462
+ A pure-TypeScript, zero-dependency recommendation engine that runs entirely on
463
+ the client — ship it inside a React Native bundle, a web app, or a Node
464
+ process. The backend hands the app a flat catalog (a few thousand items × a
465
+ few feature columns); the device ranks it live as the user scrolls, dwells,
466
+ taps, and favourites.
467
+
468
+ It lives at its own entry point so tracking-only consumers don't pay for it:
469
+
470
+ ```typescript
471
+ import { Recommender, DualBucketRecommender, SIGNALS } from '@logg/signals/reco';
472
+ import type { BaseItem, Schema } from '@logg/signals/reco';
473
+ ```
474
+
475
+ Two engines ship behind the same surface — pick at construction time:
476
+
477
+ | Engine | Class | Best for |
478
+ |---|---|---|
479
+ | v1 | `Recommender` | Probabilistic exploration, smoother defaults |
480
+ | v2 | `DualBucketRecommender` | Explicit liked/disliked separation, tighter exploit |
481
+
482
+ The library is **domain-agnostic and generic over your item shape**. You bring
483
+ an item type extending `BaseItem` (only `id` is required) and a `Schema<T>`
484
+ that extracts categorical feature values from each item — the engine has no
485
+ built-in vocabulary of brands, prices, or categories.
486
+
487
+ ```typescript
488
+ import { Recommender, SIGNALS, logDecadeBucket, type BaseItem, type Schema } from '@logg/signals/reco';
489
+
490
+ interface Listing extends BaseItem {
491
+ brand: string | null;
492
+ category: string | null;
493
+ price_cents: number | null;
494
+ popularity: number;
495
+ }
496
+
497
+ const schema = {
498
+ brand: { extract: (i: Listing) => i.brand, capacity: 4, weight: 0.4 },
499
+ category: { extract: (i: Listing) => i.category, capacity: 2, weight: 0.2 },
500
+ price_band: {
501
+ extract: (i: Listing) => (i.price_cents != null ? logDecadeBucket(i.price_cents / 100) : null),
502
+ weight: 0.4,
503
+ },
504
+ } satisfies Schema<Listing>;
505
+
506
+ const reco = new Recommender(catalog, {
507
+ schema,
508
+ popularity: (i) => i.popularity, // optional cold-start prior
509
+ });
510
+
511
+ reco.prime(usersCollection); // pre-warm from a known collection
512
+ reco.setOwned(['id-1', 'id-2']); // excluded from results
513
+
514
+ reco.engage(item, SIGNALS.view); // saw and scrolled past
515
+ reco.engage(item, SIGNALS.collect); // added to collection
516
+ reco.engage(item, -3); // strong negative — caller picks any magnitude
517
+
518
+ const { items, scores, diagnostics } = reco.recommend(20);
519
+ ```
520
+
521
+ Methods shared by both engines: `prime()`, `engage()`, `recommend()`,
522
+ `setOwned()`, `clearSeen()`, `reset()`, `seenCount()`. The dual engine adds
523
+ `setSampleSize(k)` to tune explore vs. exploit.
524
+
525
+ Advanced primitives (slot tables, interest state, bucket admission, the
526
+ weighted sampler, and `seededRng` for deterministic tests) are exported from
527
+ the same entry for callers building custom pipelines or CLIs on top.
528
+
417
529
  ## License
418
530
 
419
531
  MIT
@@ -0,0 +1,10 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
8
+ export {
9
+ __require
10
+ };
package/dist/index.d.mts CHANGED
@@ -71,20 +71,37 @@ interface QueuedEvent {
71
71
  event: Event;
72
72
  timestamp: number;
73
73
  }
74
+ type SignalsErrorType = 'send_failed' | 'send_retry' | 'destroy_pending';
75
+ interface SignalsErrorEvent {
76
+ type: SignalsErrorType;
77
+ message: string;
78
+ error?: Error;
79
+ batchId?: string;
80
+ eventCount?: number;
81
+ pendingCount?: number;
82
+ attempt?: number;
83
+ }
84
+ type SignalsErrorListener = (event: SignalsErrorEvent) => void;
74
85
 
75
86
  declare class Signals {
76
87
  private config;
77
88
  private queue;
78
89
  private flushTimer;
79
90
  private isDestroyed;
80
- private isFlushing;
91
+ private flushingPromise;
92
+ private errorListeners;
81
93
  private userContext;
82
94
  private eventContext;
83
95
  constructor(config: SignalsConfig);
84
96
  private init;
85
97
  event(eventData: EventData): Promise<void>;
86
98
  flush(): Promise<void>;
99
+ flushAll(): Promise<number>;
100
+ private doFlushOnce;
87
101
  private sendBatch;
102
+ on(event: 'error', listener: SignalsErrorListener): () => void;
103
+ off(event: 'error', listener: SignalsErrorListener): void;
104
+ private emitError;
88
105
  private startFlushTimer;
89
106
  private stopFlushTimer;
90
107
  getSessionId(): string;
@@ -106,7 +123,7 @@ declare class EventQueue {
106
123
  private getUserContext;
107
124
  private getEventContext;
108
125
  constructor(storage: StorageAdapter, getUserContext: () => UserContext, getEventContext: () => EventContext);
109
- init(): Promise<void>;
126
+ init(): Promise<number>;
110
127
  add(eventData: EventData): Promise<Event>;
111
128
  getAll(): Event[];
112
129
  getBatch(size: number): Event[];
@@ -143,4 +160,4 @@ declare class MemoryStorageAdapter implements StorageAdapter {
143
160
 
144
161
  declare function getDefaultStorageAdapter(): StorageAdapter;
145
162
 
146
- export { AsyncStorageAdapter, type ClientMetadata, type Event, type EventBatch, type EventContext, type EventData, EventQueue, type EventTarget, LocalStorageAdapter, MemoryStorageAdapter, type QueuedEvent, Signals, type SignalsConfig, type StorageAdapter, type UserContext, getDefaultStorageAdapter };
163
+ export { AsyncStorageAdapter, type ClientMetadata, type Event, type EventBatch, type EventContext, type EventData, EventQueue, type EventTarget, LocalStorageAdapter, MemoryStorageAdapter, type QueuedEvent, Signals, type SignalsConfig, type SignalsErrorEvent, type SignalsErrorListener, type SignalsErrorType, type StorageAdapter, type UserContext, getDefaultStorageAdapter };
package/dist/index.d.ts CHANGED
@@ -71,20 +71,37 @@ interface QueuedEvent {
71
71
  event: Event;
72
72
  timestamp: number;
73
73
  }
74
+ type SignalsErrorType = 'send_failed' | 'send_retry' | 'destroy_pending';
75
+ interface SignalsErrorEvent {
76
+ type: SignalsErrorType;
77
+ message: string;
78
+ error?: Error;
79
+ batchId?: string;
80
+ eventCount?: number;
81
+ pendingCount?: number;
82
+ attempt?: number;
83
+ }
84
+ type SignalsErrorListener = (event: SignalsErrorEvent) => void;
74
85
 
75
86
  declare class Signals {
76
87
  private config;
77
88
  private queue;
78
89
  private flushTimer;
79
90
  private isDestroyed;
80
- private isFlushing;
91
+ private flushingPromise;
92
+ private errorListeners;
81
93
  private userContext;
82
94
  private eventContext;
83
95
  constructor(config: SignalsConfig);
84
96
  private init;
85
97
  event(eventData: EventData): Promise<void>;
86
98
  flush(): Promise<void>;
99
+ flushAll(): Promise<number>;
100
+ private doFlushOnce;
87
101
  private sendBatch;
102
+ on(event: 'error', listener: SignalsErrorListener): () => void;
103
+ off(event: 'error', listener: SignalsErrorListener): void;
104
+ private emitError;
88
105
  private startFlushTimer;
89
106
  private stopFlushTimer;
90
107
  getSessionId(): string;
@@ -106,7 +123,7 @@ declare class EventQueue {
106
123
  private getUserContext;
107
124
  private getEventContext;
108
125
  constructor(storage: StorageAdapter, getUserContext: () => UserContext, getEventContext: () => EventContext);
109
- init(): Promise<void>;
126
+ init(): Promise<number>;
110
127
  add(eventData: EventData): Promise<Event>;
111
128
  getAll(): Event[];
112
129
  getBatch(size: number): Event[];
@@ -143,4 +160,4 @@ declare class MemoryStorageAdapter implements StorageAdapter {
143
160
 
144
161
  declare function getDefaultStorageAdapter(): StorageAdapter;
145
162
 
146
- export { AsyncStorageAdapter, type ClientMetadata, type Event, type EventBatch, type EventContext, type EventData, EventQueue, type EventTarget, LocalStorageAdapter, MemoryStorageAdapter, type QueuedEvent, Signals, type SignalsConfig, type StorageAdapter, type UserContext, getDefaultStorageAdapter };
163
+ export { AsyncStorageAdapter, type ClientMetadata, type Event, type EventBatch, type EventContext, type EventData, EventQueue, type EventTarget, LocalStorageAdapter, MemoryStorageAdapter, type QueuedEvent, Signals, type SignalsConfig, type SignalsErrorEvent, type SignalsErrorListener, type SignalsErrorType, type StorageAdapter, type UserContext, getDefaultStorageAdapter };
package/dist/index.js CHANGED
@@ -18,8 +18,8 @@ var __copyProps = (to, from, except, desc) => {
18
18
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
19
 
20
20
  // src/index.ts
21
- var index_exports = {};
22
- __export(index_exports, {
21
+ var src_exports = {};
22
+ __export(src_exports, {
23
23
  AsyncStorageAdapter: () => AsyncStorageAdapter,
24
24
  EventQueue: () => EventQueue,
25
25
  LocalStorageAdapter: () => LocalStorageAdapter,
@@ -27,7 +27,7 @@ __export(index_exports, {
27
27
  Signals: () => Signals,
28
28
  getDefaultStorageAdapter: () => getDefaultStorageAdapter
29
29
  });
30
- module.exports = __toCommonJS(index_exports);
30
+ module.exports = __toCommonJS(src_exports);
31
31
 
32
32
  // src/utils/helpers.ts
33
33
  function uuid() {
@@ -74,19 +74,23 @@ var EventQueue = class {
74
74
  this.getEventContext = getEventContext;
75
75
  }
76
76
  /**
77
- * Initialize queue by loading persisted events
77
+ * Initialize queue by loading persisted events.
78
+ * Returns the number of events loaded from storage so callers can decide
79
+ * whether to force an immediate flush without racing against new events.
78
80
  */
79
81
  async init() {
80
82
  try {
81
83
  const stored = await this.storage.getItem(STORAGE_KEY);
82
84
  if (stored) {
83
85
  const parsed = JSON.parse(stored);
84
- this.queue = Array.isArray(parsed) ? parsed : [];
86
+ const loaded = Array.isArray(parsed) ? parsed : [];
87
+ this.queue = loaded.concat(this.queue);
88
+ return loaded.length;
85
89
  }
86
90
  } catch (error) {
87
91
  console.warn("Failed to load persisted events:", error);
88
- this.queue = [];
89
92
  }
93
+ return 0;
90
94
  }
91
95
  /**
92
96
  * Add event to queue
@@ -350,7 +354,8 @@ var Signals = class {
350
354
  constructor(config) {
351
355
  this.flushTimer = null;
352
356
  this.isDestroyed = false;
353
- this.isFlushing = false;
357
+ this.flushingPromise = null;
358
+ this.errorListeners = /* @__PURE__ */ new Set();
354
359
  if (!config.apiKey) {
355
360
  throw new Error("Signals: apiKey is required");
356
361
  }
@@ -384,13 +389,13 @@ var Signals = class {
384
389
  * Initialize client
385
390
  */
386
391
  async init() {
387
- await this.queue.init();
392
+ const persistedCount = await this.queue.init();
388
393
  this.log("Signals initialized", {
389
394
  sessionId: this.config.sessionId,
390
395
  queueSize: this.queue.size()
391
396
  });
392
397
  this.startFlushTimer();
393
- if (!this.queue.isEmpty()) {
398
+ if (persistedCount > 0) {
394
399
  await this.flush();
395
400
  }
396
401
  }
@@ -408,18 +413,55 @@ var Signals = class {
408
413
  }
409
414
  }
410
415
  /**
411
- * Manually flush events
416
+ * Send one batch of up to `batchSize` events. If a flush is already
417
+ * in-flight, callers join that one rather than starting a new request — this
418
+ * preserves at-most-one-in-flight delivery without silently no-op'ing.
419
+ *
420
+ * Failures do not throw. They emit a `send_failed` error event and leave
421
+ * events in the queue for the next flush.
412
422
  */
413
423
  async flush() {
414
- if (this.isDestroyed || this.queue.isEmpty() || this.isFlushing) {
424
+ if (this.queue.isEmpty()) return;
425
+ if (this.flushingPromise) {
426
+ await this.flushingPromise.catch(() => {
427
+ });
415
428
  return;
416
429
  }
417
- this.isFlushing = true;
418
- const events = this.queue.getBatch(this.config.batchSize);
419
- if (events.length === 0) {
420
- this.isFlushing = false;
421
- return;
430
+ this.flushingPromise = this.doFlushOnce();
431
+ try {
432
+ await this.flushingPromise;
433
+ } finally {
434
+ this.flushingPromise = null;
422
435
  }
436
+ }
437
+ /**
438
+ * Drain the entire queue. Loops `flush()` until the queue is empty or sends
439
+ * keep failing without making progress. Use at process shutdown or anywhere
440
+ * a backfill / migration script needs every queued event to land.
441
+ *
442
+ * Returns the number of events still queued (and thus unsent) after the
443
+ * drain attempt completes. Zero means full success.
444
+ */
445
+ async flushAll() {
446
+ let consecutiveStalls = 0;
447
+ while (!this.queue.isEmpty() && consecutiveStalls < 3) {
448
+ const before = this.queue.size();
449
+ await this.flush();
450
+ if (this.queue.size() === before) {
451
+ consecutiveStalls++;
452
+ } else {
453
+ consecutiveStalls = 0;
454
+ }
455
+ }
456
+ return this.queue.size();
457
+ }
458
+ /**
459
+ * Send a single batch. Internal — call via flush() so the in-flight gate
460
+ * is honored.
461
+ */
462
+ async doFlushOnce() {
463
+ const events = this.queue.getBatch(this.config.batchSize);
464
+ if (events.length === 0) return;
423
465
  this.log(`Flushing ${events.length} events`);
424
466
  const batch = {
425
467
  api_key: this.config.apiKey,
@@ -434,8 +476,14 @@ var Signals = class {
434
476
  this.log("Batch sent successfully");
435
477
  } catch (error) {
436
478
  this.log("Failed to send batch", error);
437
- } finally {
438
- this.isFlushing = false;
479
+ this.emitError({
480
+ type: "send_failed",
481
+ message: `Failed to send batch after ${this.config.maxRetries} retries`,
482
+ error: error instanceof Error ? error : new Error(String(error)),
483
+ batchId: batch.batch_id,
484
+ eventCount: events.length,
485
+ pendingCount: this.queue.size()
486
+ });
439
487
  }
440
488
  }
441
489
  /**
@@ -463,10 +511,56 @@ var Signals = class {
463
511
  initialDelay: this.config.retryDelay,
464
512
  onRetry: (attempt, error) => {
465
513
  this.log(`Retry attempt ${attempt}`, error);
514
+ this.emitError({
515
+ type: "send_retry",
516
+ message: `Retrying batch send (attempt ${attempt})`,
517
+ error,
518
+ batchId: batch.batch_id,
519
+ eventCount: batch.events.length,
520
+ attempt
521
+ });
466
522
  }
467
523
  }
468
524
  );
469
525
  }
526
+ /**
527
+ * Subscribe to error events from the SDK. Returns an unsubscribe function.
528
+ *
529
+ * @example
530
+ * const off = signals.on('error', (e) => {
531
+ * console.error('[signals]', e.type, e.message, e.error);
532
+ * });
533
+ * // ... later
534
+ * off();
535
+ */
536
+ on(event, listener) {
537
+ if (event !== "error") {
538
+ throw new Error(`Signals.on: unknown event "${event}"`);
539
+ }
540
+ this.errorListeners.add(listener);
541
+ return () => this.errorListeners.delete(listener);
542
+ }
543
+ /** Remove a previously registered error listener. */
544
+ off(event, listener) {
545
+ if (event !== "error") return;
546
+ this.errorListeners.delete(listener);
547
+ }
548
+ emitError(payload) {
549
+ if (this.errorListeners.size === 0) {
550
+ console.warn(
551
+ `[Signals] ${payload.type}: ${payload.message}`,
552
+ payload.error ?? "",
553
+ payload.pendingCount !== void 0 ? `(${payload.pendingCount} events still queued)` : ""
554
+ );
555
+ return;
556
+ }
557
+ for (const listener of this.errorListeners) {
558
+ try {
559
+ listener(payload);
560
+ } catch {
561
+ }
562
+ }
563
+ }
470
564
  /**
471
565
  * Start periodic flush timer
472
566
  */
@@ -558,21 +652,38 @@ var Signals = class {
558
652
  this.log("Context reset");
559
653
  }
560
654
  /**
561
- * Destroy client and cleanup
655
+ * Destroy the client and drain the queue.
656
+ *
657
+ * Awaits any in-flight flush, then keeps flushing batches until the queue
658
+ * is empty (or sends keep failing without progress). Only after that does
659
+ * the instance refuse new events. If anything remains undrainable, emits a
660
+ * `destroy_pending` error so callers can see the loss instead of having it
661
+ * silently swallowed at process exit.
562
662
  */
563
663
  async destroy() {
564
664
  if (this.isDestroyed) return;
565
665
  this.log("Destroying Signals instance");
566
- this.isDestroyed = true;
567
666
  this.stopFlushTimer();
568
- await this.flush();
667
+ if (this.flushingPromise) {
668
+ await this.flushingPromise.catch(() => {
669
+ });
670
+ }
671
+ const stranded = await this.flushAll();
672
+ this.isDestroyed = true;
673
+ if (stranded > 0) {
674
+ this.emitError({
675
+ type: "destroy_pending",
676
+ message: `Destroyed with ${stranded} unsent events \u2014 sends are failing`,
677
+ pendingCount: stranded
678
+ });
679
+ }
569
680
  }
570
681
  /**
571
682
  * Debug logging
572
683
  */
573
684
  log(message, data) {
574
685
  if (this.config.debug) {
575
- console.log(`[LoggClient] ${message}`, data ?? "");
686
+ console.log(`[Signals] ${message}`, data ?? "");
576
687
  }
577
688
  }
578
689
  };