@logg/signals 0.1.4 → 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/README.md CHANGED
@@ -2,7 +2,7 @@
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
+ **Version 0.2.0**
6
6
 
7
7
  ## Features
8
8
 
@@ -168,12 +168,53 @@ const queueSize = signals.getQueueSize();
168
168
 
169
169
  ### `signals.destroy()`
170
170
 
171
- Destroy the client, flush remaining events, and cleanup resources.
171
+ Destroy the client. **Drains the entire queue** (flushes batches until empty
172
+ or sends start stalling) before shutting down. Always `await` this on
173
+ process exit / app teardown — anything left in the in-memory queue after
174
+ the Node process exits is lost.
172
175
 
173
176
  ```typescript
174
177
  await signals.destroy();
175
178
  ```
176
179
 
180
+ ### `signals.flushAll()`
181
+
182
+ Drain the queue without destroying the client. Useful for backfill scripts
183
+ that want a checkpoint before moving on. Returns the number of events that
184
+ could not be sent (zero on full success).
185
+
186
+ ```typescript
187
+ const stranded = await signals.flushAll();
188
+ if (stranded > 0) {
189
+ console.warn(`${stranded} events failed to deliver`);
190
+ }
191
+ ```
192
+
193
+ ### `signals.on('error', listener)` / `signals.off('error', listener)`
194
+
195
+ Subscribe to delivery errors. `signals.event()` and `signals.flush()` never
196
+ throw on backend failures, so this is how you observe them. The listener
197
+ receives a `SignalsErrorEvent`:
198
+
199
+ ```typescript
200
+ const off = signals.on('error', (e) => {
201
+ // e.type: 'send_failed' | 'send_retry' | 'destroy_pending'
202
+ // e.message: human-readable description
203
+ // e.error: the underlying Error
204
+ // e.batchId: uuid of the failing/retrying batch (if applicable)
205
+ // e.eventCount: number of events in the affected batch
206
+ // e.pendingCount: number of events still queued after the failure
207
+ // e.attempt: 1-indexed retry attempt (for 'send_retry' only)
208
+ console.error('[signals]', e.type, e.message, e.pendingCount, 'queued');
209
+ });
210
+
211
+ // later
212
+ off();
213
+ ```
214
+
215
+ If no `error` listener is attached, the SDK falls back to `console.warn`
216
+ so failures are at least visible during development.
217
+
177
218
  ## Event Batching
178
219
 
179
220
  Events are automatically batched to reduce network requests:
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
@@ -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
  }
@@ -358,7 +363,7 @@ var Signals = class {
358
363
  const anonymousId = config.anonymousId ?? uuid();
359
364
  this.config = {
360
365
  apiKey: config.apiKey,
361
- endpoint: config.endpoint ?? DEFAULT_ENDPOINT,
366
+ endpoint: config.endpoint || DEFAULT_ENDPOINT,
362
367
  batchSize: config.batchSize ?? DEFAULT_BATCH_SIZE,
363
368
  batchInterval: config.batchInterval ?? DEFAULT_BATCH_INTERVAL,
364
369
  maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,
@@ -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
  };
package/dist/index.mjs CHANGED
@@ -50,19 +50,23 @@ var EventQueue = class {
50
50
  this.getEventContext = getEventContext;
51
51
  }
52
52
  /**
53
- * Initialize queue by loading persisted events
53
+ * Initialize queue by loading persisted events.
54
+ * Returns the number of events loaded from storage so callers can decide
55
+ * whether to force an immediate flush without racing against new events.
54
56
  */
55
57
  async init() {
56
58
  try {
57
59
  const stored = await this.storage.getItem(STORAGE_KEY);
58
60
  if (stored) {
59
61
  const parsed = JSON.parse(stored);
60
- this.queue = Array.isArray(parsed) ? parsed : [];
62
+ const loaded = Array.isArray(parsed) ? parsed : [];
63
+ this.queue = loaded.concat(this.queue);
64
+ return loaded.length;
61
65
  }
62
66
  } catch (error) {
63
67
  console.warn("Failed to load persisted events:", error);
64
- this.queue = [];
65
68
  }
69
+ return 0;
66
70
  }
67
71
  /**
68
72
  * Add event to queue
@@ -326,7 +330,8 @@ var Signals = class {
326
330
  constructor(config) {
327
331
  this.flushTimer = null;
328
332
  this.isDestroyed = false;
329
- this.isFlushing = false;
333
+ this.flushingPromise = null;
334
+ this.errorListeners = /* @__PURE__ */ new Set();
330
335
  if (!config.apiKey) {
331
336
  throw new Error("Signals: apiKey is required");
332
337
  }
@@ -334,7 +339,7 @@ var Signals = class {
334
339
  const anonymousId = config.anonymousId ?? uuid();
335
340
  this.config = {
336
341
  apiKey: config.apiKey,
337
- endpoint: config.endpoint ?? DEFAULT_ENDPOINT,
342
+ endpoint: config.endpoint || DEFAULT_ENDPOINT,
338
343
  batchSize: config.batchSize ?? DEFAULT_BATCH_SIZE,
339
344
  batchInterval: config.batchInterval ?? DEFAULT_BATCH_INTERVAL,
340
345
  maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,
@@ -360,13 +365,13 @@ var Signals = class {
360
365
  * Initialize client
361
366
  */
362
367
  async init() {
363
- await this.queue.init();
368
+ const persistedCount = await this.queue.init();
364
369
  this.log("Signals initialized", {
365
370
  sessionId: this.config.sessionId,
366
371
  queueSize: this.queue.size()
367
372
  });
368
373
  this.startFlushTimer();
369
- if (!this.queue.isEmpty()) {
374
+ if (persistedCount > 0) {
370
375
  await this.flush();
371
376
  }
372
377
  }
@@ -384,18 +389,55 @@ var Signals = class {
384
389
  }
385
390
  }
386
391
  /**
387
- * Manually flush events
392
+ * Send one batch of up to `batchSize` events. If a flush is already
393
+ * in-flight, callers join that one rather than starting a new request — this
394
+ * preserves at-most-one-in-flight delivery without silently no-op'ing.
395
+ *
396
+ * Failures do not throw. They emit a `send_failed` error event and leave
397
+ * events in the queue for the next flush.
388
398
  */
389
399
  async flush() {
390
- if (this.isDestroyed || this.queue.isEmpty() || this.isFlushing) {
400
+ if (this.queue.isEmpty()) return;
401
+ if (this.flushingPromise) {
402
+ await this.flushingPromise.catch(() => {
403
+ });
391
404
  return;
392
405
  }
393
- this.isFlushing = true;
394
- const events = this.queue.getBatch(this.config.batchSize);
395
- if (events.length === 0) {
396
- this.isFlushing = false;
397
- return;
406
+ this.flushingPromise = this.doFlushOnce();
407
+ try {
408
+ await this.flushingPromise;
409
+ } finally {
410
+ this.flushingPromise = null;
398
411
  }
412
+ }
413
+ /**
414
+ * Drain the entire queue. Loops `flush()` until the queue is empty or sends
415
+ * keep failing without making progress. Use at process shutdown or anywhere
416
+ * a backfill / migration script needs every queued event to land.
417
+ *
418
+ * Returns the number of events still queued (and thus unsent) after the
419
+ * drain attempt completes. Zero means full success.
420
+ */
421
+ async flushAll() {
422
+ let consecutiveStalls = 0;
423
+ while (!this.queue.isEmpty() && consecutiveStalls < 3) {
424
+ const before = this.queue.size();
425
+ await this.flush();
426
+ if (this.queue.size() === before) {
427
+ consecutiveStalls++;
428
+ } else {
429
+ consecutiveStalls = 0;
430
+ }
431
+ }
432
+ return this.queue.size();
433
+ }
434
+ /**
435
+ * Send a single batch. Internal — call via flush() so the in-flight gate
436
+ * is honored.
437
+ */
438
+ async doFlushOnce() {
439
+ const events = this.queue.getBatch(this.config.batchSize);
440
+ if (events.length === 0) return;
399
441
  this.log(`Flushing ${events.length} events`);
400
442
  const batch = {
401
443
  api_key: this.config.apiKey,
@@ -410,8 +452,14 @@ var Signals = class {
410
452
  this.log("Batch sent successfully");
411
453
  } catch (error) {
412
454
  this.log("Failed to send batch", error);
413
- } finally {
414
- this.isFlushing = false;
455
+ this.emitError({
456
+ type: "send_failed",
457
+ message: `Failed to send batch after ${this.config.maxRetries} retries`,
458
+ error: error instanceof Error ? error : new Error(String(error)),
459
+ batchId: batch.batch_id,
460
+ eventCount: events.length,
461
+ pendingCount: this.queue.size()
462
+ });
415
463
  }
416
464
  }
417
465
  /**
@@ -439,10 +487,56 @@ var Signals = class {
439
487
  initialDelay: this.config.retryDelay,
440
488
  onRetry: (attempt, error) => {
441
489
  this.log(`Retry attempt ${attempt}`, error);
490
+ this.emitError({
491
+ type: "send_retry",
492
+ message: `Retrying batch send (attempt ${attempt})`,
493
+ error,
494
+ batchId: batch.batch_id,
495
+ eventCount: batch.events.length,
496
+ attempt
497
+ });
442
498
  }
443
499
  }
444
500
  );
445
501
  }
502
+ /**
503
+ * Subscribe to error events from the SDK. Returns an unsubscribe function.
504
+ *
505
+ * @example
506
+ * const off = signals.on('error', (e) => {
507
+ * console.error('[signals]', e.type, e.message, e.error);
508
+ * });
509
+ * // ... later
510
+ * off();
511
+ */
512
+ on(event, listener) {
513
+ if (event !== "error") {
514
+ throw new Error(`Signals.on: unknown event "${event}"`);
515
+ }
516
+ this.errorListeners.add(listener);
517
+ return () => this.errorListeners.delete(listener);
518
+ }
519
+ /** Remove a previously registered error listener. */
520
+ off(event, listener) {
521
+ if (event !== "error") return;
522
+ this.errorListeners.delete(listener);
523
+ }
524
+ emitError(payload) {
525
+ if (this.errorListeners.size === 0) {
526
+ console.warn(
527
+ `[Signals] ${payload.type}: ${payload.message}`,
528
+ payload.error ?? "",
529
+ payload.pendingCount !== void 0 ? `(${payload.pendingCount} events still queued)` : ""
530
+ );
531
+ return;
532
+ }
533
+ for (const listener of this.errorListeners) {
534
+ try {
535
+ listener(payload);
536
+ } catch {
537
+ }
538
+ }
539
+ }
446
540
  /**
447
541
  * Start periodic flush timer
448
542
  */
@@ -534,21 +628,38 @@ var Signals = class {
534
628
  this.log("Context reset");
535
629
  }
536
630
  /**
537
- * Destroy client and cleanup
631
+ * Destroy the client and drain the queue.
632
+ *
633
+ * Awaits any in-flight flush, then keeps flushing batches until the queue
634
+ * is empty (or sends keep failing without progress). Only after that does
635
+ * the instance refuse new events. If anything remains undrainable, emits a
636
+ * `destroy_pending` error so callers can see the loss instead of having it
637
+ * silently swallowed at process exit.
538
638
  */
539
639
  async destroy() {
540
640
  if (this.isDestroyed) return;
541
641
  this.log("Destroying Signals instance");
542
- this.isDestroyed = true;
543
642
  this.stopFlushTimer();
544
- await this.flush();
643
+ if (this.flushingPromise) {
644
+ await this.flushingPromise.catch(() => {
645
+ });
646
+ }
647
+ const stranded = await this.flushAll();
648
+ this.isDestroyed = true;
649
+ if (stranded > 0) {
650
+ this.emitError({
651
+ type: "destroy_pending",
652
+ message: `Destroyed with ${stranded} unsent events \u2014 sends are failing`,
653
+ pendingCount: stranded
654
+ });
655
+ }
545
656
  }
546
657
  /**
547
658
  * Debug logging
548
659
  */
549
660
  log(message, data) {
550
661
  if (this.config.debug) {
551
- console.log(`[LoggClient] ${message}`, data ?? "");
662
+ console.log(`[Signals] ${message}`, data ?? "");
552
663
  }
553
664
  }
554
665
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logg/signals",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Universal event tracking SDK for Logg Signals",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",