@aforoai/metering 1.1.2 → 2.0.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 +46 -28
- package/dist/cjs/client.d.ts +32 -50
- package/dist/cjs/client.d.ts.map +1 -1
- package/dist/cjs/client.js +104 -215
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/middleware/common.d.ts +16 -0
- package/dist/cjs/middleware/common.d.ts.map +1 -1
- package/dist/cjs/middleware/common.js +54 -0
- package/dist/cjs/middleware/common.js.map +1 -1
- package/dist/cjs/middleware/express.d.ts +10 -5
- package/dist/cjs/middleware/express.d.ts.map +1 -1
- package/dist/cjs/middleware/express.js +22 -59
- package/dist/cjs/middleware/express.js.map +1 -1
- package/dist/cjs/middleware/fastify.d.ts +8 -4
- package/dist/cjs/middleware/fastify.d.ts.map +1 -1
- package/dist/cjs/middleware/fastify.js +25 -45
- package/dist/cjs/middleware/fastify.js.map +1 -1
- package/dist/cjs/middleware/koa.d.ts +10 -5
- package/dist/cjs/middleware/koa.d.ts.map +1 -1
- package/dist/cjs/middleware/koa.js +25 -43
- package/dist/cjs/middleware/koa.js.map +1 -1
- package/dist/cjs/transport-core/core.d.ts +355 -0
- package/dist/cjs/transport-core/core.d.ts.map +1 -0
- package/dist/cjs/transport-core/core.js +1047 -0
- package/dist/cjs/transport-core/core.js.map +1 -0
- package/dist/cjs/types.d.ts +82 -46
- package/dist/cjs/types.d.ts.map +1 -1
- package/dist/esm/client.js +103 -214
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/middleware/common.js +51 -0
- package/dist/esm/middleware/common.js.map +1 -1
- package/dist/esm/middleware/express.js +23 -60
- package/dist/esm/middleware/express.js.map +1 -1
- package/dist/esm/middleware/fastify.js +26 -46
- package/dist/esm/middleware/fastify.js.map +1 -1
- package/dist/esm/middleware/koa.js +26 -44
- package/dist/esm/middleware/koa.js.map +1 -1
- package/dist/esm/transport-core/core.js +1038 -0
- package/dist/esm/transport-core/core.js.map +1 -0
- package/dist/types/client.d.ts +32 -50
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/middleware/common.d.ts +16 -0
- package/dist/types/middleware/common.d.ts.map +1 -1
- package/dist/types/middleware/express.d.ts +10 -5
- package/dist/types/middleware/express.d.ts.map +1 -1
- package/dist/types/middleware/fastify.d.ts +8 -4
- package/dist/types/middleware/fastify.d.ts.map +1 -1
- package/dist/types/middleware/koa.d.ts +10 -5
- package/dist/types/middleware/koa.d.ts.map +1 -1
- package/dist/types/transport-core/core.d.ts +355 -0
- package/dist/types/transport-core/core.d.ts.map +1 -0
- package/dist/types/types.d.ts +82 -46
- package/dist/types/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/dist/cjs/buffer.d.ts +0 -33
- package/dist/cjs/buffer.d.ts.map +0 -1
- package/dist/cjs/buffer.js +0 -83
- package/dist/cjs/buffer.js.map +0 -1
- package/dist/cjs/transport.d.ts +0 -41
- package/dist/cjs/transport.d.ts.map +0 -1
- package/dist/cjs/transport.js +0 -186
- package/dist/cjs/transport.js.map +0 -1
- package/dist/esm/buffer.js +0 -79
- package/dist/esm/buffer.js.map +0 -1
- package/dist/esm/transport.js +0 -182
- package/dist/esm/transport.js.map +0 -1
- package/dist/types/buffer.d.ts +0 -33
- package/dist/types/buffer.d.ts.map +0 -1
- package/dist/types/transport.d.ts +0 -41
- package/dist/types/transport.d.ts.map +0 -1
|
@@ -0,0 +1,1047 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Aforo Node transport core (wave 5, contract v2).
|
|
4
|
+
*
|
|
5
|
+
* THE ONE PLACE TO EDIT. This file lives in `node/src/transport-core/` and is
|
|
6
|
+
* copied, byte for byte, into every Node variant (`node-ws`, `node-mqtt`,
|
|
7
|
+
* `node-grpc`, `node-graphql`, `node-mcp`, `node-agent`, `mcp-proxy`) by
|
|
8
|
+
* `scripts/sync-transport.sh node`. Edit it here, then run the sync; CI fails
|
|
9
|
+
* when a copy differs. Normative behaviour: `contract/TRANSPORT-SPEC.md`.
|
|
10
|
+
*
|
|
11
|
+
* Self-contained on purpose: no imports (not even relative ones, so every
|
|
12
|
+
* module format and test runner that receives the copy can load it), only
|
|
13
|
+
* Node built-ins reached through globals (`fetch`, `AbortSignal`, `Buffer`,
|
|
14
|
+
* timers). Target ES2020.
|
|
15
|
+
*
|
|
16
|
+
* What it does, per client:
|
|
17
|
+
* - one bounded FIFO queue (evict-oldest, reported as 'overflow');
|
|
18
|
+
* - one long-lived flusher: `enqueue()` never sends, it only wakes the
|
|
19
|
+
* flusher at `flushCount`; `flush()` hands work to the flusher and waits;
|
|
20
|
+
* at most one ingest request is ever in flight;
|
|
21
|
+
* - full-jitter retries, Retry-After in both forms (capped), a total
|
|
22
|
+
* deadline per request that also covers reading the response body;
|
|
23
|
+
* - redirects are never followed (the API key must not reach another
|
|
24
|
+
* host): a 3xx keeps the batch queued and opens the breaker;
|
|
25
|
+
* - each event is serialized once, at enqueue: an event JSON cannot
|
|
26
|
+
* represent (BigInt, a cycle, NaN) is dropped there as 'invalid';
|
|
27
|
+
* - 413 splits the batch, 2xx per-event `retryable` failures are requeued,
|
|
28
|
+
* other 4xx are dropped as 'rejected';
|
|
29
|
+
* - after the last retry (or on 401/403) the batch goes back to the front
|
|
30
|
+
* of the queue with its keys and a breaker pauses the flusher;
|
|
31
|
+
* - `shutdown()` drains until a deadline and reports leftovers as
|
|
32
|
+
* 'shutdown'; `enqueue()` after close reports 'closed';
|
|
33
|
+
* - no signal handlers: a process `beforeExit` hook flushes within the
|
|
34
|
+
* shutdown deadline. Apps call `shutdown()` from their own SIGTERM handler;
|
|
35
|
+
* - `onAccepted(body, events)` sees the parsed 2xx body of every accepted
|
|
36
|
+
* batch (for fields such as `killedSessionIds`); `onError` receives a
|
|
37
|
+
* `CoreTransportError` whose `kind` says what happened.
|
|
38
|
+
*/
|
|
39
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
+
exports.TransportCore = exports.defaultSleep = exports.CORE_DEFAULTS = exports.CoreTransportError = void 0;
|
|
41
|
+
exports.checkBaseUrl = checkBaseUrl;
|
|
42
|
+
exports.fullJitter = fullJitter;
|
|
43
|
+
exports.parseRetryAfter = parseRetryAfter;
|
|
44
|
+
/**
|
|
45
|
+
* The error every onError report receives. `message` is for people; branch on
|
|
46
|
+
* `kind`. `events` are the events concerned: the queued ones for outage/auth,
|
|
47
|
+
* the dropped ones otherwise (empty when the ingestor did not name them).
|
|
48
|
+
*/
|
|
49
|
+
class CoreTransportError extends Error {
|
|
50
|
+
constructor(kind, message, events, options = {}) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = 'CoreTransportError';
|
|
53
|
+
this.kind = kind;
|
|
54
|
+
this.events = events.slice();
|
|
55
|
+
if (options.status !== undefined)
|
|
56
|
+
this.status = options.status;
|
|
57
|
+
this.willRetry = kind === 'outage' || kind === 'auth';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
exports.CoreTransportError = CoreTransportError;
|
|
61
|
+
exports.CORE_DEFAULTS = Object.freeze({
|
|
62
|
+
path: '/v1/ingest/batch',
|
|
63
|
+
flushCount: 500,
|
|
64
|
+
maxBatchEvents: 1000,
|
|
65
|
+
flushIntervalMs: 5000,
|
|
66
|
+
maxQueueSize: 10000,
|
|
67
|
+
maxRetries: 3,
|
|
68
|
+
retryBaseMs: 500,
|
|
69
|
+
retryMaxDelayMs: 30000,
|
|
70
|
+
retryAfterMaxMs: 60000,
|
|
71
|
+
breakerInitialMs: 5000,
|
|
72
|
+
breakerMaxMs: 300000,
|
|
73
|
+
timeoutMs: 10000,
|
|
74
|
+
shutdownTimeoutMs: 5000,
|
|
75
|
+
targetRequestBodyBytes: 4 * 1024 * 1024,
|
|
76
|
+
});
|
|
77
|
+
const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
|
|
78
|
+
const AUTH_STATUSES = new Set([401, 403]);
|
|
79
|
+
/** Smallest breaker cooldown, so a 401/3xx loop can never spin. */
|
|
80
|
+
const BREAKER_FLOOR_MS = 250;
|
|
81
|
+
/** A 3xx (or the opaque-redirect status 0 some fetch implementations return for redirect: 'manual'). */
|
|
82
|
+
function isRedirect(status) {
|
|
83
|
+
return status === 0 || (status >= 300 && status < 400);
|
|
84
|
+
}
|
|
85
|
+
/** Host of a Location header, for the log line. Never the full URL (it could carry a query string). */
|
|
86
|
+
function redirectTarget(location, base) {
|
|
87
|
+
if (!location)
|
|
88
|
+
return 'no Location header';
|
|
89
|
+
try {
|
|
90
|
+
return new URL(location, base).host || 'an unknown host';
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return 'an unparseable Location header';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* JSON for one event, or a reason why it has none. BigInt and cycles make
|
|
98
|
+
* JSON.stringify throw; NaN and Infinity would silently become null, which
|
|
99
|
+
* the ingestor would then reject or misread, so they are refused here too.
|
|
100
|
+
*/
|
|
101
|
+
function serializeEvent(event) {
|
|
102
|
+
try {
|
|
103
|
+
const json = JSON.stringify(event, (_key, value) => {
|
|
104
|
+
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
105
|
+
throw new TypeError(`a number field is ${String(value)}, which JSON cannot represent`);
|
|
106
|
+
}
|
|
107
|
+
return value;
|
|
108
|
+
});
|
|
109
|
+
if (typeof json !== 'string')
|
|
110
|
+
return { error: 'the event is not a JSON object' };
|
|
111
|
+
return { json };
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
const e = err;
|
|
115
|
+
return { error: `the event cannot be serialized to JSON (${String(e?.message ?? err).slice(0, 200)})` };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Resolves with `promise`, or rejects as soon as `signal` aborts (even if `promise` never settles). */
|
|
119
|
+
function untilAborted(promise, signal) {
|
|
120
|
+
if (signal.aborted) {
|
|
121
|
+
promise.catch(() => undefined);
|
|
122
|
+
return Promise.reject(signal.reason ?? new Error('aborted'));
|
|
123
|
+
}
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
const onAbort = () => reject(signal.reason ?? new Error('aborted'));
|
|
126
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
127
|
+
promise.then((v) => {
|
|
128
|
+
signal.removeEventListener('abort', onAbort);
|
|
129
|
+
resolve(v);
|
|
130
|
+
}, (e) => {
|
|
131
|
+
signal.removeEventListener('abort', onAbort);
|
|
132
|
+
reject(e);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
137
|
+
const INSPECT = Symbol.for('nodejs.util.inspect.custom');
|
|
138
|
+
/**
|
|
139
|
+
* Throws when `baseUrl` is not https:// (plain http only for a local host or
|
|
140
|
+
* with `allowInsecureHttp`). Returns the URL without trailing slashes.
|
|
141
|
+
*/
|
|
142
|
+
function checkBaseUrl(baseUrl, allowInsecureHttp = false) {
|
|
143
|
+
if (typeof baseUrl !== 'string' || !baseUrl.trim()) {
|
|
144
|
+
throw new Error('Aforo ingestor URL is required (for example https://api.aforo.ai)');
|
|
145
|
+
}
|
|
146
|
+
let parsed;
|
|
147
|
+
try {
|
|
148
|
+
parsed = new URL(baseUrl.trim());
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
throw new Error(`Aforo ingestor URL is not a valid URL: ${JSON.stringify(baseUrl.slice(0, 200))}`);
|
|
152
|
+
}
|
|
153
|
+
if (parsed.username || parsed.password) {
|
|
154
|
+
throw new Error('Aforo ingestor URL must not contain credentials; pass the API key as apiKey');
|
|
155
|
+
}
|
|
156
|
+
if (parsed.protocol === 'http:') {
|
|
157
|
+
if (!allowInsecureHttp && !LOCAL_HOSTS.has(parsed.hostname)) {
|
|
158
|
+
throw new Error(`Aforo ingestor URL must use https:// (got http://${parsed.host}). Plain http is allowed only for ` +
|
|
159
|
+
'localhost, 127.0.0.1 and ::1, or with allowInsecureHttp: true.');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else if (parsed.protocol !== 'https:') {
|
|
163
|
+
throw new Error(`Aforo ingestor URL must use https:// (got ${parsed.protocol}//)`);
|
|
164
|
+
}
|
|
165
|
+
return baseUrl.trim().replace(/\/+$/, '');
|
|
166
|
+
}
|
|
167
|
+
/** Full-jitter backoff: random(0, min(maxDelay, base * 2^attempt)). */
|
|
168
|
+
function fullJitter(attempt, baseMs, maxMs, random) {
|
|
169
|
+
const ceiling = Math.min(maxMs, baseMs * Math.pow(2, Math.max(0, attempt)));
|
|
170
|
+
return Math.floor(clamp01(random()) * ceiling);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Retry-After in ms: delta-seconds or an HTTP-date, capped at `maxMs`.
|
|
174
|
+
* Undefined when absent or unreadable (the caller then uses its own delay).
|
|
175
|
+
*/
|
|
176
|
+
function parseRetryAfter(value, now, maxMs) {
|
|
177
|
+
if (value === null || value === undefined)
|
|
178
|
+
return undefined;
|
|
179
|
+
const text = String(value).trim();
|
|
180
|
+
if (!text)
|
|
181
|
+
return undefined;
|
|
182
|
+
if (/^\d+(\.\d+)?$/.test(text)) {
|
|
183
|
+
return Math.min(Math.round(Number(text) * 1000), maxMs);
|
|
184
|
+
}
|
|
185
|
+
const at = Date.parse(text);
|
|
186
|
+
if (Number.isNaN(at))
|
|
187
|
+
return undefined;
|
|
188
|
+
return Math.min(Math.max(0, at - now), maxMs);
|
|
189
|
+
}
|
|
190
|
+
/** Sleep on an unref'd timer; resolves early when `signal` aborts. */
|
|
191
|
+
const defaultSleep = (ms, signal) => new Promise((resolve) => {
|
|
192
|
+
if (ms <= 0 || signal?.aborted) {
|
|
193
|
+
resolve();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const done = () => {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
signal?.removeEventListener('abort', done);
|
|
199
|
+
resolve();
|
|
200
|
+
};
|
|
201
|
+
const timer = setTimeout(done, ms);
|
|
202
|
+
unref(timer);
|
|
203
|
+
signal?.addEventListener('abort', done, { once: true });
|
|
204
|
+
});
|
|
205
|
+
exports.defaultSleep = defaultSleep;
|
|
206
|
+
function clamp01(n) {
|
|
207
|
+
return Number.isFinite(n) ? Math.min(Math.max(n, 0), 0.999999999) : 0;
|
|
208
|
+
}
|
|
209
|
+
function unref(timer) {
|
|
210
|
+
const t = timer;
|
|
211
|
+
if (t && typeof t.unref === 'function')
|
|
212
|
+
t.unref();
|
|
213
|
+
}
|
|
214
|
+
function errorText(err) {
|
|
215
|
+
if (err && typeof err === 'object') {
|
|
216
|
+
const e = err;
|
|
217
|
+
const cause = e.cause?.code ?? e.cause?.message;
|
|
218
|
+
return `${e.name ?? 'Error'}: ${e.message ?? ''}${cause ? ` (${cause})` : ''}`.slice(0, 300);
|
|
219
|
+
}
|
|
220
|
+
return String(err).slice(0, 300);
|
|
221
|
+
}
|
|
222
|
+
/** The ingestor wraps 2xx bodies in {success, data, meta}; return `data` when present. */
|
|
223
|
+
function unwrapEnvelope(body) {
|
|
224
|
+
const b = body;
|
|
225
|
+
const data = b && typeof b === 'object' && !Array.isArray(b) ? b.data : undefined;
|
|
226
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? data : b;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Bounded FIFO deque over a circular array. Back = newest, front = oldest.
|
|
230
|
+
* Not exported: the core owns the only instance.
|
|
231
|
+
*/
|
|
232
|
+
class EventDeque {
|
|
233
|
+
constructor(capacity) {
|
|
234
|
+
this.capacity = capacity;
|
|
235
|
+
this.head = 0;
|
|
236
|
+
this.count = 0;
|
|
237
|
+
this.items = new Array(capacity);
|
|
238
|
+
}
|
|
239
|
+
get size() {
|
|
240
|
+
return this.count;
|
|
241
|
+
}
|
|
242
|
+
/** Append; on overflow evict and return the oldest. */
|
|
243
|
+
pushBack(event) {
|
|
244
|
+
let evicted;
|
|
245
|
+
if (this.count === this.capacity) {
|
|
246
|
+
evicted = this.items[this.head];
|
|
247
|
+
this.items[this.head] = undefined;
|
|
248
|
+
this.head = (this.head + 1) % this.capacity;
|
|
249
|
+
this.count--;
|
|
250
|
+
}
|
|
251
|
+
this.items[(this.head + this.count) % this.capacity] = event;
|
|
252
|
+
this.count++;
|
|
253
|
+
return evicted;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Put `events` back at the front, in order (events[0] ends up first).
|
|
257
|
+
* They are older than anything queued, so when space runs out the oldest
|
|
258
|
+
* of them are the ones that do not fit; those are returned.
|
|
259
|
+
*/
|
|
260
|
+
pushFront(events) {
|
|
261
|
+
const overflow = [];
|
|
262
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
263
|
+
if (this.count === this.capacity) {
|
|
264
|
+
overflow.unshift(...events.slice(0, i + 1));
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
this.head = (this.head - 1 + this.capacity) % this.capacity;
|
|
268
|
+
this.items[this.head] = events[i];
|
|
269
|
+
this.count++;
|
|
270
|
+
}
|
|
271
|
+
return overflow;
|
|
272
|
+
}
|
|
273
|
+
/** Remove and return up to `max` events from the front. */
|
|
274
|
+
take(max) {
|
|
275
|
+
const n = Math.min(max, this.count);
|
|
276
|
+
const out = new Array(n);
|
|
277
|
+
for (let i = 0; i < n; i++) {
|
|
278
|
+
out[i] = this.items[this.head];
|
|
279
|
+
this.items[this.head] = undefined;
|
|
280
|
+
this.head = (this.head + 1) % this.capacity;
|
|
281
|
+
}
|
|
282
|
+
this.count -= n;
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
/** Remove and return everything. */
|
|
286
|
+
drainAll() {
|
|
287
|
+
return this.take(this.count);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// One process 'beforeExit' listener per copy of this file, shared by every live core.
|
|
291
|
+
const liveCores = new Set();
|
|
292
|
+
let exitHookInstalled = false;
|
|
293
|
+
/**
|
|
294
|
+
* The event loop has nothing left to do. Every core that still holds events
|
|
295
|
+
* (queued, or a batch it is sending or waiting to retry) shuts down and keeps
|
|
296
|
+
* the process alive until that finishes or its shutdown deadline passes. The
|
|
297
|
+
* core's own timers are unref'd, so without the keep-alive the process would
|
|
298
|
+
* exit in the middle of a retry and lose the batch. Idle cores stay
|
|
299
|
+
* registered: the app may still track events later.
|
|
300
|
+
*/
|
|
301
|
+
function onBeforeExit() {
|
|
302
|
+
for (const core of Array.from(liveCores))
|
|
303
|
+
core.flushForExit();
|
|
304
|
+
}
|
|
305
|
+
function registerExitHook(core) {
|
|
306
|
+
liveCores.add(core);
|
|
307
|
+
if (!exitHookInstalled && typeof process !== 'undefined' && typeof process.on === 'function') {
|
|
308
|
+
process.on('beforeExit', onBeforeExit);
|
|
309
|
+
exitHookInstalled = true;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function unregisterExitHook(core) {
|
|
313
|
+
liveCores.delete(core);
|
|
314
|
+
if (liveCores.size === 0 && exitHookInstalled) {
|
|
315
|
+
process.removeListener('beforeExit', onBeforeExit);
|
|
316
|
+
exitHookInstalled = false;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* The Node transport core. One instance per SDK client.
|
|
321
|
+
*
|
|
322
|
+
* ```ts
|
|
323
|
+
* const core = new TransportCore<MyEvent>({ baseUrl, apiKey, onDrop });
|
|
324
|
+
* core.enqueue(event); // never sends, never throws
|
|
325
|
+
* await core.flush(); // waits for the flusher to drain the queue
|
|
326
|
+
* await core.shutdown(); // drains until shutdownTimeoutMs, reports leftovers
|
|
327
|
+
* ```
|
|
328
|
+
*/
|
|
329
|
+
class TransportCore {
|
|
330
|
+
constructor(options) {
|
|
331
|
+
this.dropped = 0;
|
|
332
|
+
this.overflowDrops = 0;
|
|
333
|
+
this.invalidDrops = 0;
|
|
334
|
+
this.closedDrops = 0;
|
|
335
|
+
this.sentTotal = 0;
|
|
336
|
+
this.inFlight = 0;
|
|
337
|
+
this.closedFlag = false;
|
|
338
|
+
this.deadline = Infinity;
|
|
339
|
+
this.stopController = new AbortController();
|
|
340
|
+
/** Aborted to wake the flusher from its idle wait (replaced after each wake). */
|
|
341
|
+
this.idleWake = new AbortController();
|
|
342
|
+
/** Aborted to cut a breaker wait short (explicit flush / shutdown). */
|
|
343
|
+
this.breakerWake = new AbortController();
|
|
344
|
+
this.breakerOpens = 0;
|
|
345
|
+
this.breakerOpen = false;
|
|
346
|
+
/** ERROR line for this breaker opening (401/403 or a redirect) already written. */
|
|
347
|
+
this.configErrorLogged = false;
|
|
348
|
+
this.flushWaiters = [];
|
|
349
|
+
this.shutdownPromise = null;
|
|
350
|
+
this.closingTotals = { sent: 0, failed: 0, requeued: 0 };
|
|
351
|
+
this.deadlineTimer = null;
|
|
352
|
+
/** Per-event retryable failures seen so far, keyed by event object. */
|
|
353
|
+
this.perEventRetries = new WeakMap();
|
|
354
|
+
/** Highest per-event retry count in the last requeue, for the backoff before the resend. */
|
|
355
|
+
this.perEventAttempt = 0;
|
|
356
|
+
/** Each queued event's JSON, taken at enqueue. */
|
|
357
|
+
this.serialized = new WeakMap();
|
|
358
|
+
/** Events taken off the queue that are being sent or waiting for a retry. */
|
|
359
|
+
this.eventsInHand = 0;
|
|
360
|
+
this.shutdownSettled = false;
|
|
361
|
+
this.exitKeepAlive = null;
|
|
362
|
+
if (!options || typeof options.apiKey !== 'string' || !options.apiKey.trim()) {
|
|
363
|
+
throw new Error('Aforo apiKey is required');
|
|
364
|
+
}
|
|
365
|
+
const base = checkBaseUrl(options.baseUrl, options.allowInsecureHttp === true);
|
|
366
|
+
const path = options.path ?? exports.CORE_DEFAULTS.path;
|
|
367
|
+
this.url = base + (path.startsWith('/') ? path : `/${path}`);
|
|
368
|
+
const apiKey = options.apiKey;
|
|
369
|
+
this.headers = {
|
|
370
|
+
...(options.headers ?? {}),
|
|
371
|
+
...(options.userAgent ? { 'User-Agent': options.userAgent } : {}),
|
|
372
|
+
'Content-Type': 'application/json',
|
|
373
|
+
'X-API-Key': apiKey,
|
|
374
|
+
};
|
|
375
|
+
// Header names are case-insensitive: drop any caller copy of the reserved ones.
|
|
376
|
+
for (const name of Object.keys(this.headers)) {
|
|
377
|
+
const lower = name.toLowerCase();
|
|
378
|
+
if ((lower === 'x-api-key' && name !== 'X-API-Key') || (lower === 'content-type' && name !== 'Content-Type') || lower === 'content-encoding' || lower === 'authorization') {
|
|
379
|
+
delete this.headers[name];
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// The key lives only in this non-enumerable header map.
|
|
383
|
+
Object.defineProperty(this, 'headers', { enumerable: false });
|
|
384
|
+
this.flushCount = Math.max(1, Math.min(Math.floor(options.flushCount ?? exports.CORE_DEFAULTS.flushCount), exports.CORE_DEFAULTS.maxBatchEvents));
|
|
385
|
+
this.flushIntervalMs = Math.max(1, options.flushIntervalMs ?? exports.CORE_DEFAULTS.flushIntervalMs);
|
|
386
|
+
this.maxRetries = Math.max(0, Math.floor(options.maxRetries ?? exports.CORE_DEFAULTS.maxRetries));
|
|
387
|
+
this.retryBaseMs = Math.max(0, options.retryBaseMs ?? exports.CORE_DEFAULTS.retryBaseMs);
|
|
388
|
+
this.retryMaxDelayMs = Math.max(0, options.retryMaxDelayMs ?? exports.CORE_DEFAULTS.retryMaxDelayMs);
|
|
389
|
+
this.retryAfterMaxMs = Math.max(0, options.retryAfterMaxMs ?? exports.CORE_DEFAULTS.retryAfterMaxMs);
|
|
390
|
+
this.breakerInitialMs = Math.max(1, options.breakerInitialMs ?? exports.CORE_DEFAULTS.breakerInitialMs);
|
|
391
|
+
this.breakerMaxMs = Math.max(this.breakerInitialMs, options.breakerMaxMs ?? exports.CORE_DEFAULTS.breakerMaxMs);
|
|
392
|
+
this.timeoutMs = Math.max(1, options.timeoutMs ?? exports.CORE_DEFAULTS.timeoutMs);
|
|
393
|
+
this.shutdownTimeoutMs = Math.max(0, options.shutdownTimeoutMs ?? exports.CORE_DEFAULTS.shutdownTimeoutMs);
|
|
394
|
+
this.targetBytes = Math.max(1024, options.targetRequestBodyBytes ?? exports.CORE_DEFAULTS.targetRequestBodyBytes);
|
|
395
|
+
this.queue = new EventDeque(Math.max(1, Math.floor(options.maxQueueSize ?? exports.CORE_DEFAULTS.maxQueueSize)));
|
|
396
|
+
this.fetchFn = options.fetch ?? ((url, init) => globalThis.fetch(url, init));
|
|
397
|
+
this.clock = options.clock ?? Date.now;
|
|
398
|
+
this.sleepFn = options.sleep ?? exports.defaultSleep;
|
|
399
|
+
this.random = options.random ?? Math.random;
|
|
400
|
+
this.onDrop = options.onDrop;
|
|
401
|
+
this.onError = options.onError;
|
|
402
|
+
this.onAccepted = options.onAccepted;
|
|
403
|
+
const prefix = options.logPrefix ?? '[aforo]';
|
|
404
|
+
this.logger = options.logger ?? {
|
|
405
|
+
// Debug lines only with AFORO_DEBUG set, so a library never fills stdout by default.
|
|
406
|
+
debug: (m) => {
|
|
407
|
+
if (typeof process !== 'undefined' && process.env && process.env.AFORO_DEBUG)
|
|
408
|
+
console.debug(`${prefix} ${m}`);
|
|
409
|
+
},
|
|
410
|
+
warn: (m) => console.warn(`${prefix} ${m}`),
|
|
411
|
+
error: (m) => console.error(`${prefix} ${m}`),
|
|
412
|
+
};
|
|
413
|
+
if (options.flushOnExit !== false)
|
|
414
|
+
registerExitHook(this);
|
|
415
|
+
this.loop = this.run().catch((err) => {
|
|
416
|
+
// run() handles its own errors; this is a last-resort guard.
|
|
417
|
+
this.logger.error(`metering flusher stopped unexpectedly: ${errorText(err)}`);
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
// ── Public API ───────────────────────────────────────────────────
|
|
421
|
+
/**
|
|
422
|
+
* Queue an event. Never sends and never throws. The event is serialized
|
|
423
|
+
* now, so later changes to the object are not sent; one JSON cannot
|
|
424
|
+
* represent is reported through onDrop(…, 'invalid'). After shutdown the
|
|
425
|
+
* event is reported through onDrop(…, 'closed'). Returns false in both cases.
|
|
426
|
+
*/
|
|
427
|
+
enqueue(event) {
|
|
428
|
+
if (this.closedFlag) {
|
|
429
|
+
const before = this.closedDrops;
|
|
430
|
+
this.recordDrop([event], 'closed', 'client is shut down');
|
|
431
|
+
if (before === 0 || Math.floor(this.closedDrops / 1000) > Math.floor(before / 1000)) {
|
|
432
|
+
this.reportError(new CoreTransportError('closed', `Aforo metering is shut down: event tracked after shutdown was dropped (${this.closedDrops} so far)`, [event]));
|
|
433
|
+
}
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
const encoded = serializeEvent(event);
|
|
437
|
+
if ('error' in encoded) {
|
|
438
|
+
this.recordDrop([event], 'invalid', encoded.error);
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
this.serialized.set(event, encoded.json);
|
|
442
|
+
const evicted = this.queue.pushBack(event);
|
|
443
|
+
if (evicted !== undefined)
|
|
444
|
+
this.recordDrop([evicted], 'overflow');
|
|
445
|
+
if (this.queue.size >= this.flushCount)
|
|
446
|
+
this.wakeIdle();
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
/** Count and report events the caller rejected before queueing (reason 'invalid' or another). */
|
|
450
|
+
reportDrop(events, reason, detail) {
|
|
451
|
+
this.recordDrop(events, reason, detail);
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Hand the queue to the flusher and wait for one drain cycle. Never runs a
|
|
455
|
+
* second concurrent send. While the breaker is open this makes one attempt
|
|
456
|
+
* now instead of waiting for the cooldown.
|
|
457
|
+
*/
|
|
458
|
+
flush() {
|
|
459
|
+
if (this.shutdownPromise) {
|
|
460
|
+
return this.shutdownPromise.then((r) => ({ sent: r.sent, failed: r.failed, requeued: r.requeued, queued: r.queued }));
|
|
461
|
+
}
|
|
462
|
+
return new Promise((resolve) => {
|
|
463
|
+
this.flushWaiters.push(resolve);
|
|
464
|
+
this.wakeIdle();
|
|
465
|
+
this.wakeBreaker();
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Stop accepting events and drain until `shutdownTimeoutMs`. Each request
|
|
470
|
+
* in that window uses min(timeoutMs, time left). Events still queued at the
|
|
471
|
+
* deadline are reported through onDrop(…, 'shutdown'). Idempotent.
|
|
472
|
+
*/
|
|
473
|
+
shutdown() {
|
|
474
|
+
if (this.shutdownPromise)
|
|
475
|
+
return this.shutdownPromise;
|
|
476
|
+
this.closedFlag = true;
|
|
477
|
+
this.deadline = this.clock() + this.shutdownTimeoutMs;
|
|
478
|
+
this.deadlineTimer = setTimeout(() => this.stopController.abort(), this.shutdownTimeoutMs);
|
|
479
|
+
unref(this.deadlineTimer);
|
|
480
|
+
this.wakeIdle();
|
|
481
|
+
this.wakeBreaker();
|
|
482
|
+
this.shutdownPromise = this.loop.then(() => {
|
|
483
|
+
if (this.deadlineTimer)
|
|
484
|
+
clearTimeout(this.deadlineTimer);
|
|
485
|
+
const leftovers = this.queue.drainAll();
|
|
486
|
+
if (leftovers.length > 0) {
|
|
487
|
+
const detail = `still queued when the ${this.shutdownTimeoutMs} ms shutdown deadline passed`;
|
|
488
|
+
this.recordDrop(leftovers, 'shutdown', detail);
|
|
489
|
+
this.reportError(new CoreTransportError('shutdown', `Aforo metering shutdown dropped ${leftovers.length} event(s) ${detail}`, leftovers));
|
|
490
|
+
}
|
|
491
|
+
unregisterExitHook(this);
|
|
492
|
+
this.shutdownSettled = true;
|
|
493
|
+
if (this.exitKeepAlive) {
|
|
494
|
+
clearTimeout(this.exitKeepAlive);
|
|
495
|
+
this.exitKeepAlive = null;
|
|
496
|
+
}
|
|
497
|
+
const t = this.closingTotals;
|
|
498
|
+
const result = {
|
|
499
|
+
sent: t.sent,
|
|
500
|
+
failed: t.failed + leftovers.length,
|
|
501
|
+
requeued: t.requeued,
|
|
502
|
+
queued: 0,
|
|
503
|
+
leftover: leftovers.length,
|
|
504
|
+
timedOut: leftovers.length > 0,
|
|
505
|
+
};
|
|
506
|
+
// Anyone still waiting on flush() gets the final state.
|
|
507
|
+
const late = this.flushWaiters;
|
|
508
|
+
this.flushWaiters = [];
|
|
509
|
+
for (const resolve of late)
|
|
510
|
+
resolve({ sent: result.sent, failed: result.failed, requeued: result.requeued, queued: 0 });
|
|
511
|
+
return result;
|
|
512
|
+
});
|
|
513
|
+
return this.shutdownPromise;
|
|
514
|
+
}
|
|
515
|
+
/** Events waiting in the queue. */
|
|
516
|
+
get queued() {
|
|
517
|
+
return this.queue.size;
|
|
518
|
+
}
|
|
519
|
+
/** Events lost for good since creation (every reason, including unattributed per-event rejections). */
|
|
520
|
+
get droppedCount() {
|
|
521
|
+
return this.dropped;
|
|
522
|
+
}
|
|
523
|
+
/** Events the ingestor accepted since creation. */
|
|
524
|
+
get sentCount() {
|
|
525
|
+
return this.sentTotal;
|
|
526
|
+
}
|
|
527
|
+
/** True after shutdown() was called. */
|
|
528
|
+
get closed() {
|
|
529
|
+
return this.closedFlag;
|
|
530
|
+
}
|
|
531
|
+
/** True while the breaker pauses the flusher after an outage. */
|
|
532
|
+
get isBreakerOpen() {
|
|
533
|
+
return this.breakerOpen;
|
|
534
|
+
}
|
|
535
|
+
/** Ingest requests in flight right now (0 or 1). */
|
|
536
|
+
get inFlightRequests() {
|
|
537
|
+
return this.inFlight;
|
|
538
|
+
}
|
|
539
|
+
/** True while events are queued or a taken batch is being sent / retried. */
|
|
540
|
+
get busy() {
|
|
541
|
+
return this.queue.size > 0 || this.eventsInHand > 0;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* @internal Called from the process 'beforeExit' hook. When this core still
|
|
545
|
+
* holds events (or a shutdown is already running), it shuts down and holds
|
|
546
|
+
* the event loop open with one timer until the shutdown settles. Never
|
|
547
|
+
* longer than the shutdown deadline plus a second.
|
|
548
|
+
*/
|
|
549
|
+
flushForExit() {
|
|
550
|
+
if (this.shutdownSettled || this.exitKeepAlive)
|
|
551
|
+
return;
|
|
552
|
+
if (!this.busy && !this.shutdownPromise)
|
|
553
|
+
return;
|
|
554
|
+
const left = this.shutdownPromise ? Math.max(0, this.deadline - this.clock()) : this.shutdownTimeoutMs;
|
|
555
|
+
this.exitKeepAlive = setTimeout(() => {
|
|
556
|
+
this.exitKeepAlive = null;
|
|
557
|
+
}, left + 1000);
|
|
558
|
+
this.shutdown().catch((err) => this.logDebug(`shutdown on beforeExit failed: ${errorText(err)}`));
|
|
559
|
+
}
|
|
560
|
+
/** @internal Debug log, used by the exit hook. */
|
|
561
|
+
logDebug(message) {
|
|
562
|
+
try {
|
|
563
|
+
this.logger.debug(message);
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
/* a logger must never break metering */
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
toJSON() {
|
|
570
|
+
return { url: this.url, queued: this.queued, dropped: this.dropped, closed: this.closedFlag };
|
|
571
|
+
}
|
|
572
|
+
[INSPECT]() {
|
|
573
|
+
return `TransportCore { url: '${this.url}', queued: ${this.queued}, dropped: ${this.dropped}, closed: ${this.closedFlag}, apiKey: '[redacted]' }`;
|
|
574
|
+
}
|
|
575
|
+
// ── Flusher ──────────────────────────────────────────────────────
|
|
576
|
+
wakeIdle() {
|
|
577
|
+
if (!this.idleWake.signal.aborted)
|
|
578
|
+
this.idleWake.abort();
|
|
579
|
+
}
|
|
580
|
+
wakeBreaker() {
|
|
581
|
+
if (!this.breakerWake.signal.aborted)
|
|
582
|
+
this.breakerWake.abort();
|
|
583
|
+
}
|
|
584
|
+
get pastDeadline() {
|
|
585
|
+
return this.clock() >= this.deadline || this.stopController.signal.aborted;
|
|
586
|
+
}
|
|
587
|
+
async run() {
|
|
588
|
+
let backoff = false;
|
|
589
|
+
for (;;) {
|
|
590
|
+
if (!this.closedFlag && this.flushWaiters.length === 0) {
|
|
591
|
+
if (this.breakerOpen) {
|
|
592
|
+
await this.waitBreaker();
|
|
593
|
+
}
|
|
594
|
+
else if (backoff) {
|
|
595
|
+
// The last cycle put per-event retryable events back: wait before resending them.
|
|
596
|
+
await this.waitBackoff();
|
|
597
|
+
}
|
|
598
|
+
else if (this.queue.size < this.flushCount) {
|
|
599
|
+
await this.waitIdle();
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
else if (this.closedFlag && (this.breakerOpen || backoff) && !this.pastDeadline) {
|
|
603
|
+
// Shutting down during an outage: keep trying until the deadline, with a short pause.
|
|
604
|
+
await this.sleepFn(Math.min(fullJitter(0, this.retryBaseMs, this.retryMaxDelayMs, this.random), Math.max(0, this.deadline - this.clock())), this.stopController.signal);
|
|
605
|
+
}
|
|
606
|
+
const waiters = this.flushWaiters;
|
|
607
|
+
this.flushWaiters = [];
|
|
608
|
+
const result = await this.drainCycle();
|
|
609
|
+
backoff = result.requeued > 0 && !this.breakerOpen;
|
|
610
|
+
if (this.closedFlag) {
|
|
611
|
+
this.closingTotals.sent += result.sent;
|
|
612
|
+
this.closingTotals.failed += result.failed;
|
|
613
|
+
this.closingTotals.requeued += result.requeued;
|
|
614
|
+
}
|
|
615
|
+
for (const resolve of waiters) {
|
|
616
|
+
try {
|
|
617
|
+
resolve(result);
|
|
618
|
+
}
|
|
619
|
+
catch (err) {
|
|
620
|
+
this.logDebug(`flush waiter failed: ${errorText(err)}`);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if (this.closedFlag && (this.queue.size === 0 || this.pastDeadline))
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
async waitBackoff() {
|
|
628
|
+
if (this.breakerWake.signal.aborted)
|
|
629
|
+
this.breakerWake = new AbortController();
|
|
630
|
+
const wake = this.breakerWake;
|
|
631
|
+
// The normal jittered backoff, growing with how often these events came back retryable.
|
|
632
|
+
const delay = fullJitter(Math.max(0, this.perEventAttempt - 1), this.retryBaseMs, this.retryMaxDelayMs, this.random);
|
|
633
|
+
await this.sleepFn(Math.max(1, delay), wake.signal);
|
|
634
|
+
if (wake.signal.aborted)
|
|
635
|
+
this.breakerWake = new AbortController();
|
|
636
|
+
}
|
|
637
|
+
async waitIdle() {
|
|
638
|
+
if (this.idleWake.signal.aborted)
|
|
639
|
+
this.idleWake = new AbortController();
|
|
640
|
+
const wake = this.idleWake;
|
|
641
|
+
if (this.queue.size >= this.flushCount)
|
|
642
|
+
return;
|
|
643
|
+
await this.sleepFn(this.flushIntervalMs, wake.signal);
|
|
644
|
+
if (wake.signal.aborted)
|
|
645
|
+
this.idleWake = new AbortController();
|
|
646
|
+
}
|
|
647
|
+
async waitBreaker() {
|
|
648
|
+
if (this.breakerWake.signal.aborted)
|
|
649
|
+
this.breakerWake = new AbortController();
|
|
650
|
+
const wake = this.breakerWake;
|
|
651
|
+
const ceiling = Math.min(this.breakerMaxMs, this.breakerInitialMs * Math.pow(2, Math.max(0, this.breakerOpens - 1)));
|
|
652
|
+
// Full jitter with a floor, so a 401/3xx loop cannot spin: max(250 ms, random(0, ceiling)).
|
|
653
|
+
const cooldown = Math.max(BREAKER_FLOOR_MS, Math.floor(clamp01(this.random()) * ceiling));
|
|
654
|
+
this.logDebug(`breaker open: next attempt in ${cooldown} ms (${this.queue.size} queued)`);
|
|
655
|
+
await this.sleepFn(cooldown, wake.signal);
|
|
656
|
+
if (wake.signal.aborted)
|
|
657
|
+
this.breakerWake = new AbortController();
|
|
658
|
+
}
|
|
659
|
+
/** Send everything queued, one request at a time. Stops on outage or deadline. */
|
|
660
|
+
async drainCycle() {
|
|
661
|
+
const result = { sent: 0, failed: 0, requeued: 0, queued: 0 };
|
|
662
|
+
try {
|
|
663
|
+
while (this.queue.size > 0) {
|
|
664
|
+
if (this.closedFlag && this.pastDeadline)
|
|
665
|
+
break;
|
|
666
|
+
const batch = this.queue.take(this.flushCount);
|
|
667
|
+
this.eventsInHand = batch.length;
|
|
668
|
+
let stop;
|
|
669
|
+
try {
|
|
670
|
+
stop = await this.sendBatch(batch, result);
|
|
671
|
+
}
|
|
672
|
+
finally {
|
|
673
|
+
this.eventsInHand = 0;
|
|
674
|
+
}
|
|
675
|
+
if (stop)
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
catch (err) {
|
|
680
|
+
this.logger.error(`metering flush failed unexpectedly: ${errorText(err)}`);
|
|
681
|
+
}
|
|
682
|
+
result.queued = this.queue.size;
|
|
683
|
+
return result;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Send one batch (splitting it when the body is too large or the ingestor
|
|
687
|
+
* answers 413). Returns true when the cycle must stop (outage or deadline);
|
|
688
|
+
* unsent events are already back at the front of the queue then.
|
|
689
|
+
*/
|
|
690
|
+
async sendBatch(batch, result) {
|
|
691
|
+
const pending = [batch];
|
|
692
|
+
while (pending.length > 0) {
|
|
693
|
+
const chunk = pending.shift();
|
|
694
|
+
const body = this.bodyFor(chunk);
|
|
695
|
+
if (chunk.length > 1 && byteLength(body) > this.targetBytes) {
|
|
696
|
+
const mid = Math.ceil(chunk.length / 2);
|
|
697
|
+
pending.unshift(chunk.slice(0, mid), chunk.slice(mid));
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
const outcome = await this.sendWithRetry(chunk, body);
|
|
701
|
+
if (outcome.kind === 'split') {
|
|
702
|
+
if (chunk.length === 1) {
|
|
703
|
+
this.recordDrop(chunk, 'rejected', 'HTTP 413: a single event is larger than the ingestor accepts');
|
|
704
|
+
result.failed += 1;
|
|
705
|
+
this.reportError(new CoreTransportError('rejected', 'Aforo ingestor rejected an event with HTTP 413 (request too large); the event was dropped', chunk, { status: 413 }));
|
|
706
|
+
}
|
|
707
|
+
else {
|
|
708
|
+
const mid = Math.ceil(chunk.length / 2);
|
|
709
|
+
pending.unshift(chunk.slice(0, mid), chunk.slice(mid));
|
|
710
|
+
}
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
if (outcome.kind === 'done') {
|
|
714
|
+
result.sent += outcome.sent;
|
|
715
|
+
result.failed += outcome.failed;
|
|
716
|
+
this.sentTotal += outcome.sent;
|
|
717
|
+
if (outcome.retryable.length > 0) {
|
|
718
|
+
const again = outcome.retryable;
|
|
719
|
+
result.requeued += again.length;
|
|
720
|
+
let exhausted = false;
|
|
721
|
+
let highest = 0;
|
|
722
|
+
for (const e of again) {
|
|
723
|
+
const n = (this.perEventRetries.get(e) ?? 0) + 1;
|
|
724
|
+
this.perEventRetries.set(e, n);
|
|
725
|
+
highest = Math.max(highest, n);
|
|
726
|
+
if (n > this.maxRetries)
|
|
727
|
+
exhausted = true;
|
|
728
|
+
}
|
|
729
|
+
this.perEventAttempt = highest;
|
|
730
|
+
const remaining = [].concat(again, ...pending);
|
|
731
|
+
this.requeue(remaining);
|
|
732
|
+
if (exhausted) {
|
|
733
|
+
// The retry cap was reached: an outage. This 2xx does not count as
|
|
734
|
+
// the recovery that resets the breaker, so cooldowns keep growing.
|
|
735
|
+
this.openBreaker(undefined, 'the ingestor kept reporting events as retryable', remaining);
|
|
736
|
+
}
|
|
737
|
+
else {
|
|
738
|
+
this.closeBreaker();
|
|
739
|
+
}
|
|
740
|
+
// End the cycle; the flusher waits the backoff before resending.
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
this.closeBreaker();
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
// outage or deadline: put this chunk and everything not yet sent back in front.
|
|
747
|
+
const unsent = [].concat(chunk, ...pending);
|
|
748
|
+
result.requeued += unsent.length;
|
|
749
|
+
this.requeue(unsent);
|
|
750
|
+
if (outcome.kind === 'outage')
|
|
751
|
+
this.openBreaker(outcome.status, outcome.reason, unsent, outcome.redirectTo);
|
|
752
|
+
return true;
|
|
753
|
+
}
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
/** Request body from the JSON taken at enqueue. */
|
|
757
|
+
bodyFor(chunk) {
|
|
758
|
+
const parts = new Array(chunk.length);
|
|
759
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
760
|
+
const cached = this.serialized.get(chunk[i]);
|
|
761
|
+
// Every queued event passed enqueue(); the fallback covers events placed by reportDrop paths only.
|
|
762
|
+
parts[i] = cached ?? JSON.stringify(chunk[i]);
|
|
763
|
+
}
|
|
764
|
+
return `{"events":[${parts.join(',')}]}`;
|
|
765
|
+
}
|
|
766
|
+
requeue(events) {
|
|
767
|
+
const overflow = this.queue.pushFront(events);
|
|
768
|
+
if (overflow.length > 0)
|
|
769
|
+
this.recordDrop(overflow, 'overflow');
|
|
770
|
+
}
|
|
771
|
+
/** `kept` are the events just put back on the queue (named in the onError report). */
|
|
772
|
+
openBreaker(status, reason, kept, redirectTo) {
|
|
773
|
+
const first = !this.breakerOpen;
|
|
774
|
+
this.breakerOpen = true;
|
|
775
|
+
this.breakerOpens += 1;
|
|
776
|
+
if (redirectTo !== undefined) {
|
|
777
|
+
// Once per breaker opening. A redirect means the base URL is wrong (or a
|
|
778
|
+
// proxy is rewriting it); following it would send the API key elsewhere.
|
|
779
|
+
if (!this.configErrorLogged) {
|
|
780
|
+
this.configErrorLogged = true;
|
|
781
|
+
const message = `Aforo ingestor answered HTTP ${status} redirecting to ${redirectTo}; redirects are not followed so the API key ` +
|
|
782
|
+
`never reaches another host. Usage stays queued (${this.queue.size} events). Fix the ingestor base URL.`;
|
|
783
|
+
this.logger.error(message);
|
|
784
|
+
this.reportError(new CoreTransportError('outage', message, kept, { status }));
|
|
785
|
+
}
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (first) {
|
|
789
|
+
this.logger.warn(`Aforo ingestor unavailable (${reason}); ${this.queue.size} events queued, retrying with backoff.`);
|
|
790
|
+
}
|
|
791
|
+
if (status !== undefined && AUTH_STATUSES.has(status)) {
|
|
792
|
+
// Once per breaker opening, also when an outage turns into 401/403 while the breaker is open.
|
|
793
|
+
if (!this.configErrorLogged) {
|
|
794
|
+
this.configErrorLogged = true;
|
|
795
|
+
this.logger.error(`Aforo ingestor answered HTTP ${status}: the API key is wrong or revoked. ` +
|
|
796
|
+
`Usage stays queued (${this.queue.size} events) and is sent once a valid key is in place.`);
|
|
797
|
+
this.reportError(new CoreTransportError('auth', `Aforo ingestor answered HTTP ${status}: the API key is wrong or revoked. Events are kept and retried.`, kept, { status }));
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
else if (first) {
|
|
801
|
+
this.reportError(new CoreTransportError('outage', `Aforo ingestor unavailable: ${reason}. Events are kept and retried.`, kept, { status }));
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
closeBreaker() {
|
|
805
|
+
if (this.breakerOpen) {
|
|
806
|
+
this.logger.warn('Aforo ingestor reachable again; sending queued events.');
|
|
807
|
+
}
|
|
808
|
+
this.breakerOpen = false;
|
|
809
|
+
this.breakerOpens = 0;
|
|
810
|
+
this.configErrorLogged = false;
|
|
811
|
+
}
|
|
812
|
+
/** One chunk, with retries. Never throws. */
|
|
813
|
+
async sendWithRetry(chunk, body) {
|
|
814
|
+
let lastReason = 'no attempt';
|
|
815
|
+
let lastStatus;
|
|
816
|
+
for (let attempt = 0;; attempt++) {
|
|
817
|
+
if (this.closedFlag && this.pastDeadline)
|
|
818
|
+
return { kind: 'deadline' };
|
|
819
|
+
const timeLeft = this.deadline - this.clock();
|
|
820
|
+
const requestTimeout = Math.max(1, Math.min(this.timeoutMs, timeLeft));
|
|
821
|
+
let retryAfterMs;
|
|
822
|
+
let response;
|
|
823
|
+
try {
|
|
824
|
+
response = await this.post(body, requestTimeout);
|
|
825
|
+
}
|
|
826
|
+
catch (err) {
|
|
827
|
+
if (this.closedFlag && this.pastDeadline)
|
|
828
|
+
return { kind: 'deadline' };
|
|
829
|
+
lastReason = `network error: ${errorText(err)}`;
|
|
830
|
+
lastStatus = undefined;
|
|
831
|
+
}
|
|
832
|
+
if (response) {
|
|
833
|
+
const status = response.status;
|
|
834
|
+
if (status >= 200 && status < 300) {
|
|
835
|
+
return this.handleAccepted(response, chunk);
|
|
836
|
+
}
|
|
837
|
+
if (status === 413)
|
|
838
|
+
return { kind: 'split' };
|
|
839
|
+
if (isRedirect(status)) {
|
|
840
|
+
return {
|
|
841
|
+
kind: 'outage',
|
|
842
|
+
status,
|
|
843
|
+
reason: `HTTP ${status} redirect`,
|
|
844
|
+
redirectTo: redirectTarget(response.headers?.get?.('location') ?? null, this.url),
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
if (AUTH_STATUSES.has(status)) {
|
|
848
|
+
await this.discardBody(response);
|
|
849
|
+
return { kind: 'outage', status, reason: `HTTP ${status}` };
|
|
850
|
+
}
|
|
851
|
+
if (RETRY_STATUSES.has(status) || status >= 500) {
|
|
852
|
+
retryAfterMs = parseRetryAfter(response.headers?.get?.('retry-after') ?? null, this.clock(), this.retryAfterMaxMs);
|
|
853
|
+
lastReason = `HTTP ${status}`;
|
|
854
|
+
lastStatus = status;
|
|
855
|
+
await this.discardBody(response);
|
|
856
|
+
}
|
|
857
|
+
else {
|
|
858
|
+
const detail = await this.readProblem(response);
|
|
859
|
+
this.recordDrop(chunk, 'rejected', `HTTP ${status}${detail ? `: ${detail}` : ''}`);
|
|
860
|
+
this.reportError(new CoreTransportError('rejected', `Aforo ingestor rejected a batch of ${chunk.length} with HTTP ${status}${detail ? `: ${detail}` : ''} (not retried)`, chunk, { status }));
|
|
861
|
+
return { kind: 'done', sent: 0, failed: chunk.length, retryable: [] };
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
if (attempt >= this.maxRetries) {
|
|
865
|
+
return { kind: 'outage', status: lastStatus, reason: `${lastReason} after ${attempt + 1} attempts` };
|
|
866
|
+
}
|
|
867
|
+
const jittered = fullJitter(attempt, this.retryBaseMs, this.retryMaxDelayMs, this.random);
|
|
868
|
+
const delay = retryAfterMs !== undefined ? Math.min(Math.max(retryAfterMs, jittered), this.retryAfterMaxMs) : jittered;
|
|
869
|
+
if (this.closedFlag && this.clock() + delay >= this.deadline)
|
|
870
|
+
return { kind: 'deadline' };
|
|
871
|
+
this.logDebug(`ingest attempt ${attempt + 1} failed (${lastReason}); retrying in ${delay} ms`);
|
|
872
|
+
await this.sleepFn(delay, this.stopController.signal);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
async post(body, timeoutMs) {
|
|
876
|
+
const controller = new AbortController();
|
|
877
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
878
|
+
const onTimeout = () => controller.abort(timeoutSignal.reason);
|
|
879
|
+
const onStop = () => controller.abort(new Error('shutdown deadline passed'));
|
|
880
|
+
timeoutSignal.addEventListener('abort', onTimeout, { once: true });
|
|
881
|
+
this.stopController.signal.addEventListener('abort', onStop, { once: true });
|
|
882
|
+
this.inFlight += 1;
|
|
883
|
+
try {
|
|
884
|
+
const response = await untilAborted(Promise.resolve().then(() => this.fetchFn(this.url, { method: 'POST', headers: this.headers, body, signal: controller.signal, redirect: 'manual' })), controller.signal);
|
|
885
|
+
// Read the body inside the same deadline: a server that sends headers
|
|
886
|
+
// and then stalls, or drips the body, must not hold the flusher.
|
|
887
|
+
return await bufferBody(response, controller.signal);
|
|
888
|
+
}
|
|
889
|
+
finally {
|
|
890
|
+
this.inFlight -= 1;
|
|
891
|
+
timeoutSignal.removeEventListener('abort', onTimeout);
|
|
892
|
+
this.stopController.signal.removeEventListener('abort', onStop);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
async handleAccepted(response, chunk) {
|
|
896
|
+
let body;
|
|
897
|
+
try {
|
|
898
|
+
body = unwrapEnvelope(await readJson(response));
|
|
899
|
+
}
|
|
900
|
+
catch (err) {
|
|
901
|
+
this.logDebug(`ingest response body unreadable: ${errorText(err)}`);
|
|
902
|
+
}
|
|
903
|
+
const errors = Array.isArray(body?.errors) ? body.errors : [];
|
|
904
|
+
const reportedFailed = typeof body?.failed === 'number' && body.failed > 0 ? Math.floor(body.failed) : 0;
|
|
905
|
+
const seen = new Set();
|
|
906
|
+
const rejected = [];
|
|
907
|
+
const retryable = [];
|
|
908
|
+
const messages = [];
|
|
909
|
+
for (const e of errors) {
|
|
910
|
+
const index = Number(e?.index);
|
|
911
|
+
if (!Number.isInteger(index) || index < 0 || index >= chunk.length || seen.has(index))
|
|
912
|
+
continue;
|
|
913
|
+
seen.add(index);
|
|
914
|
+
if (e?.retryable === true) {
|
|
915
|
+
retryable.push(chunk[index]);
|
|
916
|
+
}
|
|
917
|
+
else {
|
|
918
|
+
rejected.push(chunk[index]);
|
|
919
|
+
if (messages.length < 5)
|
|
920
|
+
messages.push(`#${index}: ${String(e?.message ?? 'rejected').slice(0, 200)}`);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
const unattributed = Math.max(0, Math.min(chunk.length, reportedFailed) - seen.size);
|
|
924
|
+
if (rejected.length > 0) {
|
|
925
|
+
this.recordDrop(rejected, 'rejected', messages.join('; '));
|
|
926
|
+
this.reportError(new CoreTransportError('rejected', `Aforo ingestor rejected ${rejected.length} event(s): ${messages.join('; ')}`, rejected, {
|
|
927
|
+
status: response.status,
|
|
928
|
+
}));
|
|
929
|
+
}
|
|
930
|
+
if (unattributed > 0) {
|
|
931
|
+
// The ingestor reported failures without saying which: count them, nothing to hand to onDrop.
|
|
932
|
+
this.dropped += unattributed;
|
|
933
|
+
this.logger.warn(`Aforo ingestor rejected ${unattributed} event(s) without naming them (${this.dropped} total dropped).`);
|
|
934
|
+
}
|
|
935
|
+
const failed = rejected.length + unattributed;
|
|
936
|
+
if (this.onAccepted) {
|
|
937
|
+
try {
|
|
938
|
+
this.onAccepted(body, chunk);
|
|
939
|
+
}
|
|
940
|
+
catch (err) {
|
|
941
|
+
this.logDebug(`onAccepted hook threw: ${errorText(err)}`);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return { kind: 'done', sent: chunk.length - failed - retryable.length, failed, retryable };
|
|
945
|
+
}
|
|
946
|
+
async discardBody(response) {
|
|
947
|
+
try {
|
|
948
|
+
if (typeof response.text === 'function')
|
|
949
|
+
await response.text();
|
|
950
|
+
}
|
|
951
|
+
catch (err) {
|
|
952
|
+
this.logDebug(`could not read error body: ${errorText(err)}`);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
async readProblem(response) {
|
|
956
|
+
try {
|
|
957
|
+
const body = unwrapEnvelope(await readJson(response));
|
|
958
|
+
if (!body)
|
|
959
|
+
return undefined;
|
|
960
|
+
const first = Array.isArray(body?.errors) ? body.errors[0] : undefined;
|
|
961
|
+
const msg = first?.message ?? body?.detail ?? body?.message ?? body?.error ?? body?.title;
|
|
962
|
+
return typeof msg === 'string' && msg ? msg.slice(0, 300) : undefined;
|
|
963
|
+
}
|
|
964
|
+
catch (err) {
|
|
965
|
+
this.logDebug(`error body is not JSON: ${errorText(err)}`);
|
|
966
|
+
return undefined;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
// ── Drop accounting ──────────────────────────────────────────────
|
|
970
|
+
recordDrop(events, reason, detail) {
|
|
971
|
+
if (!events || events.length === 0)
|
|
972
|
+
return;
|
|
973
|
+
this.dropped += events.length;
|
|
974
|
+
try {
|
|
975
|
+
if (reason === 'overflow' || reason === 'invalid' || reason === 'closed') {
|
|
976
|
+
// Hot-path reasons: first, then every 1000th, so a loop cannot storm the log.
|
|
977
|
+
const before = reason === 'overflow' ? this.overflowDrops : reason === 'invalid' ? this.invalidDrops : this.closedDrops;
|
|
978
|
+
const after = before + events.length;
|
|
979
|
+
if (reason === 'overflow')
|
|
980
|
+
this.overflowDrops = after;
|
|
981
|
+
else if (reason === 'invalid')
|
|
982
|
+
this.invalidDrops = after;
|
|
983
|
+
else
|
|
984
|
+
this.closedDrops = after;
|
|
985
|
+
if (before === 0 || Math.floor(after / 1000) > Math.floor(before / 1000)) {
|
|
986
|
+
this.logger.warn(`Dropped ${events.length} event(s) — ${reason}${detail ? `: ${detail}` : ''} (${this.dropped} total dropped).` +
|
|
987
|
+
(reason === 'overflow' ? ' Raise maxQueueSize or check ingest connectivity.' : ''));
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
else {
|
|
991
|
+
this.logger.warn(`Dropped ${events.length} event(s) — ${reason}${detail ? `: ${detail}` : ''} (${this.dropped} total dropped).`);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
catch {
|
|
995
|
+
/* a logger must never break metering */
|
|
996
|
+
}
|
|
997
|
+
if (this.onDrop) {
|
|
998
|
+
try {
|
|
999
|
+
this.onDrop(events, reason, detail);
|
|
1000
|
+
}
|
|
1001
|
+
catch (err) {
|
|
1002
|
+
this.logDebug(`onDrop hook threw: ${errorText(err)}`);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
reportError(err) {
|
|
1007
|
+
if (!this.onError)
|
|
1008
|
+
return;
|
|
1009
|
+
try {
|
|
1010
|
+
this.onError(err);
|
|
1011
|
+
}
|
|
1012
|
+
catch (hookErr) {
|
|
1013
|
+
this.logDebug(`onError hook threw: ${errorText(hookErr)}`);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
exports.TransportCore = TransportCore;
|
|
1018
|
+
/**
|
|
1019
|
+
* Read the whole body now, giving up when `signal` aborts, and hand back a
|
|
1020
|
+
* response whose text()/json() return what was read. Callers then parse
|
|
1021
|
+
* without touching the network again.
|
|
1022
|
+
*/
|
|
1023
|
+
async function bufferBody(response, signal) {
|
|
1024
|
+
if (typeof response.text === 'function') {
|
|
1025
|
+
const text = await untilAborted(Promise.resolve().then(() => response.text()), signal);
|
|
1026
|
+
return { status: response.status, ok: response.ok, headers: response.headers, text: async () => text };
|
|
1027
|
+
}
|
|
1028
|
+
if (typeof response.json === 'function') {
|
|
1029
|
+
const json = await untilAborted(Promise.resolve().then(() => response.json()), signal);
|
|
1030
|
+
return { status: response.status, ok: response.ok, headers: response.headers, json: async () => json };
|
|
1031
|
+
}
|
|
1032
|
+
return response;
|
|
1033
|
+
}
|
|
1034
|
+
/** Parse a response body as JSON (text() first, json() as a fallback). Empty body → undefined. */
|
|
1035
|
+
async function readJson(response) {
|
|
1036
|
+
if (typeof response.text === 'function') {
|
|
1037
|
+
const text = await response.text();
|
|
1038
|
+
return text ? JSON.parse(text) : undefined;
|
|
1039
|
+
}
|
|
1040
|
+
if (typeof response.json === 'function')
|
|
1041
|
+
return response.json();
|
|
1042
|
+
return undefined;
|
|
1043
|
+
}
|
|
1044
|
+
function byteLength(text) {
|
|
1045
|
+
return Buffer.byteLength(text, 'utf8');
|
|
1046
|
+
}
|
|
1047
|
+
//# sourceMappingURL=core.js.map
|