@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 +115 -3
- package/dist/chunk-Y6FXYEAI.mjs +10 -0
- package/dist/index.d.mts +20 -3
- package/dist/index.d.ts +20 -3
- package/dist/index.js +133 -22
- package/dist/index.mjs +133 -25
- package/dist/reco.d.mts +216 -0
- package/dist/reco.d.ts +216 -0
- package/dist/reco.js +659 -0
- package/dist/reco.mjs +602 -0
- package/package.json +11 -4
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
|
-
|
|
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
|
|
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
|
|
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
|
@@ -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
|
|
22
|
-
__export(
|
|
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(
|
|
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
|
-
|
|
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
|
};
|