@logg/signals 0.1.5 → 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 +43 -2
- package/dist/index.d.mts +20 -3
- package/dist/index.d.ts +20 -3
- package/dist/index.js +130 -19
- package/dist/index.mjs +130 -19
- package/package.json +1 -1
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.
|
|
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
|
|
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
|
|
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<
|
|
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
|
|
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<
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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
|
-
*
|
|
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.
|
|
424
|
+
if (this.queue.isEmpty()) return;
|
|
425
|
+
if (this.flushingPromise) {
|
|
426
|
+
await this.flushingPromise.catch(() => {
|
|
427
|
+
});
|
|
415
428
|
return;
|
|
416
429
|
}
|
|
417
|
-
this.
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
438
|
-
|
|
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
|
|
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
|
-
|
|
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(`[
|
|
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
|
-
|
|
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.
|
|
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
|
}
|
|
@@ -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 (
|
|
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
|
-
*
|
|
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.
|
|
400
|
+
if (this.queue.isEmpty()) return;
|
|
401
|
+
if (this.flushingPromise) {
|
|
402
|
+
await this.flushingPromise.catch(() => {
|
|
403
|
+
});
|
|
391
404
|
return;
|
|
392
405
|
}
|
|
393
|
-
this.
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
414
|
-
|
|
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
|
|
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
|
-
|
|
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(`[
|
|
662
|
+
console.log(`[Signals] ${message}`, data ?? "");
|
|
552
663
|
}
|
|
553
664
|
}
|
|
554
665
|
};
|