@aforoai/ws-metering 1.2.1 → 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 +41 -21
- package/dist/index.d.ts +102 -53
- package/dist/index.js +249 -264
- package/dist/transport-core/core.d.ts +354 -0
- package/dist/transport-core/core.js +1077 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
* @aforoai/ws-metering — Aforo WebSocket Metering SDK
|
|
4
4
|
*
|
|
5
5
|
* Wraps WebSocket server connections to emit three classes of billing events:
|
|
6
|
-
* - CONNECTION_OPENED — once on upgrade completion
|
|
6
|
+
* - CONNECTION_OPENED — once on upgrade completion (wsFrameType OPEN,
|
|
7
|
+
* metric websocket_api.connection_opened)
|
|
7
8
|
* - MESSAGE — one event per frame (direction + bytes + type)
|
|
8
|
-
* - CONNECTION_CLOSED — once
|
|
9
|
+
* - CONNECTION_CLOSED — once per connection, carrying aggregated counters +
|
|
10
|
+
* duration: on close, on a socket error, when an idle
|
|
11
|
+
* connection is swept, or at shutdown
|
|
9
12
|
*
|
|
10
13
|
* Works with the `ws` library out of the box (wrap WebSocketServer), or use
|
|
11
14
|
* trackConnection() directly for Fastify-WebSocket, Socket.io, Deno, Bun, or
|
|
@@ -25,19 +28,25 @@
|
|
|
25
28
|
*
|
|
26
29
|
* const wss = new WebSocketServer({ port: 8080 });
|
|
27
30
|
* billing.wrapServer(wss, {
|
|
28
|
-
*
|
|
31
|
+
* // Who is billed, from identity your app verified at upgrade time.
|
|
32
|
+
* customerResolver: (req) => sessionStore.customerFor(req),
|
|
29
33
|
* });
|
|
34
|
+
*
|
|
35
|
+
* // The SDK installs no signal handlers. Flush from your own:
|
|
36
|
+
* process.once('SIGTERM', async () => { await billing.shutdown(); process.exit(0); });
|
|
30
37
|
*/
|
|
31
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
|
-
exports.WS_CLOSE_REASONS = exports.AforoWsBilling = exports.DEFAULT_PRODUCT_TYPE = void 0;
|
|
39
|
+
exports.WS_CLOSE_REASONS = exports.AforoWsBilling = exports.DEFAULT_PRODUCT_TYPE = exports.DEFAULT_CONNECTION_IDLE_TIMEOUT_MS = exports.CoreTransportError = void 0;
|
|
33
40
|
const node_crypto_1 = require("node:crypto");
|
|
34
|
-
const
|
|
41
|
+
const core_1 = require("./transport-core/core");
|
|
42
|
+
/** What onError receives for delivery problems: branch on `kind`, read `events`. */
|
|
43
|
+
var core_2 = require("./transport-core/core");
|
|
44
|
+
Object.defineProperty(exports, "CoreTransportError", { enumerable: true, get: function () { return core_2.CoreTransportError; } });
|
|
45
|
+
const SDK_VERSION = '2.0.0';
|
|
46
|
+
/** Default for AforoWsConfig.connectionIdleTimeoutMs: one hour. */
|
|
47
|
+
exports.DEFAULT_CONNECTION_IDLE_TIMEOUT_MS = 60 * 60 * 1000;
|
|
35
48
|
/** Default top-level `productType` for this SDK. */
|
|
36
49
|
exports.DEFAULT_PRODUCT_TYPE = 'WEBSOCKET_API';
|
|
37
|
-
/** Upper bound on a server-requested Retry-After wait. */
|
|
38
|
-
const MAX_RETRY_AFTER_MS = 30_000;
|
|
39
|
-
/** The ingestor rejects batch requests with more than 1000 events. */
|
|
40
|
-
const MAX_BATCH_EVENTS = 1000;
|
|
41
50
|
/** usage-ingestor limits (IngestUsageEventRequest). */
|
|
42
51
|
const MAX_CUSTOMER_ID = 64;
|
|
43
52
|
const MAX_IDEMPOTENCY_KEY = 255;
|
|
@@ -60,41 +69,94 @@ const CLOSE_REASONS = {
|
|
|
60
69
|
class AforoWsBilling {
|
|
61
70
|
config;
|
|
62
71
|
productType;
|
|
63
|
-
flushCount;
|
|
64
|
-
flushIntervalMs;
|
|
65
72
|
perFrameEvents;
|
|
66
73
|
onError;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
invalidSeen = new Map();
|
|
74
|
+
core;
|
|
75
|
+
idleTimeoutMs;
|
|
76
|
+
connections = new Map();
|
|
77
|
+
sweepTimer;
|
|
78
|
+
shutdownPromise = null;
|
|
73
79
|
constructor(config) {
|
|
80
|
+
const idle = config.connectionIdleTimeoutMs;
|
|
81
|
+
if (idle !== undefined && (typeof idle !== 'number' || !Number.isFinite(idle) || idle < 0)) {
|
|
82
|
+
throw new Error(`[aforo-ws] connectionIdleTimeoutMs must be a positive number of milliseconds (got ${String(idle)}); ` +
|
|
83
|
+
'without it connections that die without a close event are never released.');
|
|
84
|
+
}
|
|
85
|
+
this.idleTimeoutMs = idle ? idle : exports.DEFAULT_CONNECTION_IDLE_TIMEOUT_MS;
|
|
74
86
|
this.config = {
|
|
75
87
|
tenantId: config.tenantId,
|
|
76
88
|
productId: config.productId,
|
|
77
|
-
apiKey: config.apiKey,
|
|
78
|
-
ingestorUrl: config.ingestorUrl,
|
|
79
89
|
};
|
|
80
90
|
this.productType = normalizeProductType(config.productType) ?? exports.DEFAULT_PRODUCT_TYPE;
|
|
81
|
-
this.flushCount = config.flushCount ?? 100;
|
|
82
|
-
this.flushIntervalMs = config.flushIntervalMs ?? 3000;
|
|
83
91
|
this.perFrameEvents = config.perFrameEvents ?? false;
|
|
84
92
|
this.onError = config.onError ?? ((err) => console.error('[aforo-ws]', err.message));
|
|
85
|
-
|
|
86
|
-
|
|
93
|
+
const onDrop = config.onDrop;
|
|
94
|
+
// The core is the only holder of the API key.
|
|
95
|
+
this.core = new core_1.TransportCore({
|
|
96
|
+
baseUrl: config.ingestorUrl,
|
|
97
|
+
apiKey: config.apiKey,
|
|
98
|
+
allowInsecureHttp: config.allowInsecureHttp,
|
|
99
|
+
headers: { 'X-Tenant-Id': config.tenantId },
|
|
100
|
+
userAgent: `aforo-ws-metering/${SDK_VERSION}`,
|
|
101
|
+
flushCount: config.flushCount,
|
|
102
|
+
flushIntervalMs: config.flushIntervalMs,
|
|
103
|
+
maxQueueSize: config.maxQueueSize,
|
|
104
|
+
maxRetries: config.maxRetries,
|
|
105
|
+
timeoutMs: config.timeoutMs,
|
|
106
|
+
shutdownTimeoutMs: config.shutdownTimeoutMs,
|
|
107
|
+
flushOnExit: config.flushOnExit,
|
|
108
|
+
logPrefix: '[aforo-ws]',
|
|
109
|
+
onError: (err) => this.reportError(err),
|
|
110
|
+
onDrop: onDrop ? (events, reason) => onDrop(events, reason) : undefined,
|
|
111
|
+
});
|
|
112
|
+
const every = Math.min(60_000, Math.max(1_000, Math.floor(this.idleTimeoutMs / 4)));
|
|
113
|
+
this.sweepTimer = setInterval(() => this.sweepIdleConnections(), every);
|
|
114
|
+
this.sweepTimer.unref?.();
|
|
115
|
+
}
|
|
116
|
+
/** Connections being tracked: opened and not yet closed by the socket, the idle sweep or shutdown(). */
|
|
117
|
+
get openConnections() {
|
|
118
|
+
return this.connections.size;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Close every tracked connection idle longer than `connectionIdleTimeoutMs`
|
|
122
|
+
* at `now` (CONNECTION_CLOSED, ABNORMAL_CLOSURE, `closedBy: "sdk_idle_timeout"`).
|
|
123
|
+
* Runs on a timer; callable directly. Returns how many it closed.
|
|
124
|
+
*/
|
|
125
|
+
sweepIdleConnections(now = Date.now()) {
|
|
126
|
+
const cutoff = now - this.idleTimeoutMs;
|
|
127
|
+
let closed = 0;
|
|
128
|
+
for (const conn of Array.from(this.connections.values())) {
|
|
129
|
+
if (conn.lastActivity <= cutoff && conn.close(1006, 'sdk_idle_timeout'))
|
|
130
|
+
closed++;
|
|
131
|
+
}
|
|
132
|
+
return closed;
|
|
87
133
|
}
|
|
88
|
-
/**
|
|
134
|
+
/**
|
|
135
|
+
* Wrap a `ws` WebSocketServer (or any object emitting 'connection' events).
|
|
136
|
+
* Throws when neither `customerResolver` nor `trustCustomerHeader: true` is given.
|
|
137
|
+
*/
|
|
89
138
|
wrapServer(wss, options) {
|
|
139
|
+
const resolver = options?.customerResolver ?? options?.extractCustomerId;
|
|
140
|
+
const trustHeader = options?.trustCustomerHeader === true;
|
|
141
|
+
if (typeof resolver !== 'function' && !trustHeader) {
|
|
142
|
+
throw new Error('[aforo-ws] wrapServer needs a customerResolver: (req) => the Aforo customer id from identity your app ' +
|
|
143
|
+
'verified on the upgrade request. To bill the x-customer-id header instead — only safe behind a proxy ' +
|
|
144
|
+
'that sets it — pass trustCustomerHeader: true.');
|
|
145
|
+
}
|
|
90
146
|
wss.on('connection', (ws, req) => {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
147
|
+
let customerId;
|
|
148
|
+
try {
|
|
149
|
+
customerId = textOrUndefined(typeof resolver === 'function' ? resolver(req) : undefined);
|
|
150
|
+
if (!customerId && trustHeader)
|
|
151
|
+
customerId = textOrUndefined(headerValue(req?.headers?.['x-customer-id']));
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
this.reportError(new Error(`[aforo-ws] customerResolver threw: ${err?.message ?? err}`));
|
|
155
|
+
}
|
|
94
156
|
const metadata = options.extractMetadata?.(req);
|
|
95
157
|
const status = options.executionStatus;
|
|
96
158
|
this.trackConnection(ws, {
|
|
97
|
-
customerId,
|
|
159
|
+
customerId: customerId ?? '',
|
|
98
160
|
metadata,
|
|
99
161
|
executionStatus: typeof status === 'function' ? (event) => status(event, req) : status,
|
|
100
162
|
productType: options.productType,
|
|
@@ -103,29 +165,83 @@ class AforoWsBilling {
|
|
|
103
165
|
}
|
|
104
166
|
/** Track a single WebSocket connection. Returns an unsubscribe function. */
|
|
105
167
|
trackConnection(ws, opts) {
|
|
106
|
-
// No customer resolved — the connection is not billable, not a drop.
|
|
107
|
-
if (typeof opts.customerId !== 'string' || !opts.customerId.trim())
|
|
108
|
-
return () => { };
|
|
109
168
|
const connectionId = (0, node_crypto_1.randomUUID)();
|
|
110
169
|
const productType = normalizeProductType(opts.productType) ?? this.productType;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
170
|
+
if (typeof opts.customerId !== 'string' || !opts.customerId.trim()) {
|
|
171
|
+
// No customer: the connection is not metered. Counted as one 'invalid' drop.
|
|
172
|
+
this.core.reportDrop([this.buildEvent({
|
|
173
|
+
customerId: typeof opts.customerId === 'string' ? opts.customerId : '',
|
|
174
|
+
wsConnectionId: connectionId,
|
|
175
|
+
productType,
|
|
176
|
+
wsDirection: 'SERVER_TO_CLIENT',
|
|
177
|
+
wsFrameType: 'OPEN',
|
|
178
|
+
messageCount: 0,
|
|
179
|
+
dataBytes: 0,
|
|
180
|
+
executionDurationMs: 0,
|
|
181
|
+
metadata: { ...(opts.metadata ?? {}), event: 'CONNECTION_OPENED' },
|
|
182
|
+
})], 'invalid', 'customer resolver returned no customer; the connection is not metered');
|
|
183
|
+
return () => { };
|
|
184
|
+
}
|
|
185
|
+
const openEvent = {
|
|
118
186
|
customerId: opts.customerId,
|
|
119
187
|
wsConnectionId: connectionId,
|
|
120
188
|
productType,
|
|
121
189
|
wsDirection: 'SERVER_TO_CLIENT',
|
|
122
|
-
wsFrameType: '
|
|
190
|
+
wsFrameType: 'OPEN', // handshake complete; not a frame on the wire
|
|
123
191
|
messageCount: 0,
|
|
124
192
|
dataBytes: 0,
|
|
125
193
|
executionDurationMs: 0,
|
|
126
194
|
metadata: { ...(opts.metadata ?? {}), event: 'CONNECTION_OPENED' },
|
|
127
|
-
}
|
|
195
|
+
};
|
|
196
|
+
if (this.shutdownPromise) {
|
|
197
|
+
// Shutting down: a connection opened now could never get its CONNECTION_CLOSED.
|
|
198
|
+
this.core.reportDrop([this.buildEvent(openEvent)], 'closed', 'connection opened after shutdown(); not metered');
|
|
199
|
+
return () => { };
|
|
200
|
+
}
|
|
201
|
+
const start = Date.now();
|
|
202
|
+
let sentCount = 0;
|
|
203
|
+
let recvCount = 0;
|
|
204
|
+
let sentBytes = 0;
|
|
205
|
+
let recvBytes = 0;
|
|
206
|
+
let closed = false;
|
|
207
|
+
const conn = {
|
|
208
|
+
lastActivity: start,
|
|
209
|
+
close: (code, closedBy) => {
|
|
210
|
+
if (closed)
|
|
211
|
+
return false;
|
|
212
|
+
closed = true;
|
|
213
|
+
this.connections.delete(connectionId);
|
|
214
|
+
this.push({
|
|
215
|
+
customerId: opts.customerId,
|
|
216
|
+
wsConnectionId: connectionId,
|
|
217
|
+
productType,
|
|
218
|
+
wsDirection: 'SERVER_TO_CLIENT',
|
|
219
|
+
wsFrameType: 'CLOSE',
|
|
220
|
+
wsCloseReason: CLOSE_REASONS[code] ?? 'NORMAL_CLOSURE',
|
|
221
|
+
messageCount: sentCount + recvCount,
|
|
222
|
+
dataBytes: sentBytes + recvBytes,
|
|
223
|
+
executionDurationMs: Date.now() - start,
|
|
224
|
+
metadata: {
|
|
225
|
+
...(opts.metadata ?? {}),
|
|
226
|
+
event: 'CONNECTION_CLOSED',
|
|
227
|
+
sentCount,
|
|
228
|
+
recvCount,
|
|
229
|
+
sentBytes,
|
|
230
|
+
recvBytes,
|
|
231
|
+
closeCode: code,
|
|
232
|
+
...(closedBy ? { closedBy } : {}),
|
|
233
|
+
},
|
|
234
|
+
}, opts.executionStatus);
|
|
235
|
+
return true;
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
this.connections.set(connectionId, conn);
|
|
239
|
+
// Emit CONNECTION_OPENED immediately
|
|
240
|
+
this.push(openEvent);
|
|
128
241
|
ws.on('message', (data, isBinary) => {
|
|
242
|
+
if (closed)
|
|
243
|
+
return;
|
|
244
|
+
conn.lastActivity = Date.now();
|
|
129
245
|
recvCount++;
|
|
130
246
|
const bytes = estimateBytes(data);
|
|
131
247
|
recvBytes += bytes;
|
|
@@ -143,53 +259,56 @@ class AforoWsBilling {
|
|
|
143
259
|
});
|
|
144
260
|
}
|
|
145
261
|
});
|
|
146
|
-
// Wrap send() to count outbound frames
|
|
262
|
+
// Wrap send() to count outbound frames. A frame is billed only once the
|
|
263
|
+
// socket has accepted it (ws calls the send callback without an error), so a
|
|
264
|
+
// send to a closing or broken socket is never billed. The caller's own
|
|
265
|
+
// callback, if any, still runs with the same error.
|
|
147
266
|
const origSend = ws.send.bind(ws);
|
|
148
|
-
ws.send = (data,
|
|
149
|
-
|
|
267
|
+
ws.send = (data, ...rest) => {
|
|
268
|
+
if (closed)
|
|
269
|
+
return origSend(data, ...rest);
|
|
270
|
+
conn.lastActivity = Date.now();
|
|
271
|
+
const callerCb = typeof rest[rest.length - 1] === 'function' ? rest.pop() : undefined;
|
|
150
272
|
const bytes = estimateBytes(data);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
273
|
+
const frameType = typeof data === 'string' ? 'TEXT' : 'BINARY';
|
|
274
|
+
const onSent = (err) => {
|
|
275
|
+
// A frame accepted after the connection was reported closed is not
|
|
276
|
+
// billed: the CONNECTION_CLOSED totals have already gone out.
|
|
277
|
+
if (!err && !closed) {
|
|
278
|
+
sentCount++;
|
|
279
|
+
sentBytes += bytes;
|
|
280
|
+
if (this.perFrameEvents) {
|
|
281
|
+
this.push({
|
|
282
|
+
customerId: opts.customerId,
|
|
283
|
+
wsConnectionId: connectionId,
|
|
284
|
+
productType,
|
|
285
|
+
wsDirection: 'SERVER_TO_CLIENT',
|
|
286
|
+
wsFrameType: frameType,
|
|
287
|
+
messageCount: 1,
|
|
288
|
+
dataBytes: bytes,
|
|
289
|
+
executionDurationMs: Date.now() - start,
|
|
290
|
+
metadata: opts.metadata,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (callerCb)
|
|
295
|
+
callerCb(err);
|
|
296
|
+
};
|
|
297
|
+
return origSend(data, ...rest, onSent);
|
|
166
298
|
};
|
|
167
299
|
// opts.executionStatus is the connection's outcome: only the closing events
|
|
168
300
|
// below carry it (same as the Python SDK), never OPENED or per-frame events.
|
|
301
|
+
// 'error' is normally followed by 'close': bill the connection once,
|
|
302
|
+
// with whichever of the two arrives first (or the idle sweep / shutdown).
|
|
169
303
|
ws.on('close', (code) => {
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
customerId: opts.customerId,
|
|
173
|
-
wsConnectionId: connectionId,
|
|
174
|
-
productType,
|
|
175
|
-
wsDirection: 'SERVER_TO_CLIENT',
|
|
176
|
-
wsFrameType: 'CLOSE',
|
|
177
|
-
wsCloseReason: CLOSE_REASONS[code] ?? 'NORMAL_CLOSURE',
|
|
178
|
-
messageCount: sentCount + recvCount,
|
|
179
|
-
dataBytes: sentBytes + recvBytes,
|
|
180
|
-
executionDurationMs: Date.now() - start,
|
|
181
|
-
metadata: {
|
|
182
|
-
...(opts.metadata ?? {}),
|
|
183
|
-
event: 'CONNECTION_CLOSED',
|
|
184
|
-
sentCount,
|
|
185
|
-
recvCount,
|
|
186
|
-
sentBytes,
|
|
187
|
-
recvBytes,
|
|
188
|
-
closeCode: code,
|
|
189
|
-
},
|
|
190
|
-
}, opts.executionStatus);
|
|
304
|
+
// CONNECTION_CLOSED with aggregated counters — this is the billing anchor.
|
|
305
|
+
conn.close(code);
|
|
191
306
|
});
|
|
192
307
|
ws.on('error', (err) => {
|
|
308
|
+
if (closed)
|
|
309
|
+
return;
|
|
310
|
+
closed = true;
|
|
311
|
+
this.connections.delete(connectionId);
|
|
193
312
|
this.push({
|
|
194
313
|
customerId: opts.customerId,
|
|
195
314
|
wsConnectionId: connectionId,
|
|
@@ -200,20 +319,23 @@ class AforoWsBilling {
|
|
|
200
319
|
messageCount: sentCount + recvCount,
|
|
201
320
|
dataBytes: sentBytes + recvBytes,
|
|
202
321
|
executionDurationMs: Date.now() - start,
|
|
203
|
-
metadata: { ...(opts.metadata ?? {}), event: 'CONNECTION_ERROR', error: err
|
|
322
|
+
metadata: { ...(opts.metadata ?? {}), event: 'CONNECTION_ERROR', error: String(err?.message ?? err).slice(0, 200) },
|
|
204
323
|
}, opts.executionStatus);
|
|
205
324
|
});
|
|
206
325
|
return () => {
|
|
207
|
-
// Tracking lifetime is managed by the close handler
|
|
326
|
+
// Tracking lifetime is managed by the close handler, the idle sweep and shutdown().
|
|
208
327
|
};
|
|
209
328
|
}
|
|
210
|
-
|
|
329
|
+
/** Fill in the fields every event carries. */
|
|
330
|
+
buildEvent(partial) {
|
|
211
331
|
const now = new Date();
|
|
212
|
-
|
|
332
|
+
return {
|
|
213
333
|
...partial,
|
|
214
334
|
metricName: partial.wsFrameType === 'CLOSE'
|
|
215
335
|
? 'websocket_api.connection_closed'
|
|
216
|
-
:
|
|
336
|
+
: partial.wsFrameType === 'OPEN'
|
|
337
|
+
? 'websocket_api.connection_opened'
|
|
338
|
+
: 'websocket_api.message',
|
|
217
339
|
quantity: 1,
|
|
218
340
|
occurredAt: now.toISOString(),
|
|
219
341
|
// Minted once, here. SDK-generated; tail-trimmed to the ingestor's limit,
|
|
@@ -225,6 +347,9 @@ class AforoWsBilling {
|
|
|
225
347
|
productId: this.config.productId,
|
|
226
348
|
},
|
|
227
349
|
};
|
|
350
|
+
}
|
|
351
|
+
push(partial, executionStatus) {
|
|
352
|
+
const event = this.buildEvent(partial);
|
|
228
353
|
const checked = checkExecutionStatus(resolveExecutionStatus(executionStatus, event));
|
|
229
354
|
if (checked.problem)
|
|
230
355
|
this.reportError(new Error(`[aforo-ws] ${checked.problem}`));
|
|
@@ -236,153 +361,58 @@ class AforoWsBilling {
|
|
|
236
361
|
const invalid = tooLong('customerId', event.customerId, MAX_CUSTOMER_ID) ??
|
|
237
362
|
tooLong('productType', event.productType, MAX_PRODUCT_TYPE);
|
|
238
363
|
if (invalid) {
|
|
239
|
-
this.
|
|
364
|
+
this.core.reportDrop([event], 'invalid', invalid.problem);
|
|
240
365
|
return;
|
|
241
366
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
void this.flush();
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
async flush() {
|
|
248
|
-
if (this.buffer.length === 0)
|
|
249
|
-
return;
|
|
250
|
-
const pending = this.buffer.splice(0, this.buffer.length);
|
|
251
|
-
// The ingestor accepts at most MAX_BATCH_EVENTS events per request.
|
|
252
|
-
for (let i = 0; i < pending.length; i += MAX_BATCH_EVENTS) {
|
|
253
|
-
await this.send(pending.slice(i, i + MAX_BATCH_EVENTS));
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
async send(batch) {
|
|
257
|
-
// Serialized once, so every retry re-sends the same idempotencyKeys.
|
|
258
|
-
const body = JSON.stringify({ events: batch });
|
|
259
|
-
const maxRetries = 3;
|
|
260
|
-
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
261
|
-
let delayMs = Math.pow(2, attempt - 1) * 1000; // 1s, 2s, 4s
|
|
262
|
-
let res;
|
|
263
|
-
let networkError;
|
|
264
|
-
try {
|
|
265
|
-
res = await fetch(this.config.ingestorUrl.replace(/\/$/, '') + '/v1/ingest/batch', {
|
|
266
|
-
method: 'POST',
|
|
267
|
-
headers: {
|
|
268
|
-
'Content-Type': 'application/json',
|
|
269
|
-
'X-API-Key': this.config.apiKey,
|
|
270
|
-
'X-Tenant-Id': this.config.tenantId,
|
|
271
|
-
},
|
|
272
|
-
body,
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
catch (err) {
|
|
276
|
-
networkError = err;
|
|
277
|
-
}
|
|
278
|
-
if (res) {
|
|
279
|
-
if (res.ok) {
|
|
280
|
-
await this.handlePartialFailures(res, batch);
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
if (!isRetryableStatus(res.status)) {
|
|
284
|
-
// 4xx other than 408/429: the same body would be refused again.
|
|
285
|
-
const { details } = await readBatchResult(res, batch.length);
|
|
286
|
-
this.recordDrop(batch, 'rejected');
|
|
287
|
-
this.reportError(new Error(`WebSocket metering batch rejected with HTTP ${res.status}${details ? ` — ${details}` : ''} (dropped ${batch.length} events, not retried)`));
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
delayMs = parseRetryAfter(res) ?? delayMs;
|
|
291
|
-
}
|
|
292
|
-
else if (attempt === maxRetries) {
|
|
293
|
-
this.recordDrop(batch, 'retry_exhausted');
|
|
294
|
-
this.reportError(networkError ?? new Error('WebSocket metering request failed'));
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
if (attempt < maxRetries)
|
|
298
|
-
await sleep(delayMs);
|
|
299
|
-
}
|
|
300
|
-
// Not re-queued: dropping avoids unbounded memory growth.
|
|
301
|
-
this.recordDrop(batch, 'retry_exhausted');
|
|
302
|
-
this.reportError(new Error(`WebSocket metering flush failed after ${maxRetries} attempts (dropped ${batch.length} events)`));
|
|
303
|
-
}
|
|
304
|
-
/**
|
|
305
|
-
* A 2xx can still carry per-event rejections:
|
|
306
|
-
* `{accepted, duplicates, failed, errors:[{index, message}]}`. Events the
|
|
307
|
-
* response names by index are dropped with reason 'rejected'; a `failed`
|
|
308
|
-
* count the response does not attribute to an index is counted only.
|
|
309
|
-
*/
|
|
310
|
-
async handlePartialFailures(res, batch) {
|
|
311
|
-
const { failed, details, indexes } = await readBatchResult(res, batch.length);
|
|
312
|
-
if (failed <= 0)
|
|
313
|
-
return;
|
|
314
|
-
if (indexes.length > 0)
|
|
315
|
-
this.recordDrop(indexes.map((i) => batch[i]), 'rejected');
|
|
316
|
-
const unattributed = Math.min(failed, batch.length) - indexes.length;
|
|
317
|
-
if (unattributed > 0) {
|
|
318
|
-
this.dropped += unattributed;
|
|
319
|
-
console.warn(`[aforo-ws] Dropped ${unattributed} event(s) — rejected, not identified by the ingestor (${this.dropped} total dropped).`);
|
|
320
|
-
}
|
|
321
|
-
this.reportError(new Error(`Aforo ingestor rejected ${failed} event(s)${details ? ` — ${details}` : ''}`));
|
|
367
|
+
// Queue only; the core's single background sender does the HTTP.
|
|
368
|
+
this.core.enqueue(event);
|
|
322
369
|
}
|
|
323
370
|
/** Invoke onError; a hook bug must never break metering or flushing. */
|
|
324
371
|
reportError(err) {
|
|
325
372
|
try {
|
|
326
373
|
this.onError(err);
|
|
327
374
|
}
|
|
328
|
-
catch {
|
|
329
|
-
|
|
375
|
+
catch (hookErr) {
|
|
376
|
+
console.debug('[aforo-ws] onError hook threw', hookErr);
|
|
330
377
|
}
|
|
331
378
|
}
|
|
332
|
-
/** Number of events permanently dropped since this instance was created. */
|
|
379
|
+
/** Number of events permanently dropped since this instance was created (every DropReason). */
|
|
333
380
|
get droppedCount() {
|
|
334
|
-
return this.
|
|
381
|
+
return this.core.droppedCount;
|
|
335
382
|
}
|
|
336
|
-
/**
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
this.
|
|
343
|
-
console.warn(`[aforo-ws] Dropped ${events.length} event(s) — ${reason} (${this.dropped} total dropped).`);
|
|
344
|
-
this.notifyDrop(events, reason);
|
|
383
|
+
/** Number of events waiting to be sent. */
|
|
384
|
+
get queuedCount() {
|
|
385
|
+
return this.core.queued;
|
|
386
|
+
}
|
|
387
|
+
/** Send what is queued now and wait for it (one request at a time; never concurrent). */
|
|
388
|
+
async flush() {
|
|
389
|
+
return this.core.flush();
|
|
345
390
|
}
|
|
346
391
|
/**
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
392
|
+
* Bill every connection still open with a CONNECTION_CLOSED (close code
|
|
393
|
+
* 1001, GOING_AWAY, metadata `closedBy: "sdk_shutdown"`), stop accepting
|
|
394
|
+
* events and send what is queued, for at most `shutdownTimeoutMs`. Events
|
|
395
|
+
* still queued then are dropped with reason 'shutdown' (counted, passed to
|
|
396
|
+
* onDrop). A connection opened from now on is reported as 'closed' and not
|
|
397
|
+
* tracked. Calling it again returns the first call's promise. Call it from
|
|
398
|
+
* your SIGTERM handler.
|
|
350
399
|
*/
|
|
351
|
-
|
|
352
|
-
this.
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
this.notifyDrop([event], 'invalid');
|
|
360
|
-
}
|
|
361
|
-
notifyDrop(events, reason) {
|
|
362
|
-
if (!this.onDrop)
|
|
363
|
-
return;
|
|
364
|
-
try {
|
|
365
|
-
this.onDrop(events, reason);
|
|
366
|
-
}
|
|
367
|
-
catch {
|
|
368
|
-
// A hook bug must never break metering or flushing.
|
|
400
|
+
shutdown() {
|
|
401
|
+
if (!this.shutdownPromise) {
|
|
402
|
+
this.shutdownPromise = (async () => {
|
|
403
|
+
clearInterval(this.sweepTimer);
|
|
404
|
+
for (const conn of Array.from(this.connections.values()))
|
|
405
|
+
conn.close(1001, 'sdk_shutdown');
|
|
406
|
+
await this.core.shutdown();
|
|
407
|
+
})();
|
|
369
408
|
}
|
|
409
|
+
return this.shutdownPromise;
|
|
370
410
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
return;
|
|
374
|
-
this.flushTimer = setInterval(() => { void this.flush(); }, this.flushIntervalMs);
|
|
375
|
-
// Unref so the background timer never blocks host-process exit (final flush still needs shutdown()).
|
|
376
|
-
if (typeof this.flushTimer.unref === 'function')
|
|
377
|
-
this.flushTimer.unref();
|
|
411
|
+
toJSON() {
|
|
412
|
+
return { tenantId: this.config.tenantId, productId: this.config.productId, queued: this.queuedCount, dropped: this.droppedCount };
|
|
378
413
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
if (this.flushTimer) {
|
|
382
|
-
clearInterval(this.flushTimer);
|
|
383
|
-
this.flushTimer = null;
|
|
384
|
-
}
|
|
385
|
-
await this.flush();
|
|
414
|
+
[Symbol.for('nodejs.util.inspect.custom')]() {
|
|
415
|
+
return `AforoWsBilling { tenantId: '${this.config.tenantId}', productId: '${this.config.productId}', queued: ${this.queuedCount}, dropped: ${this.droppedCount} }`;
|
|
386
416
|
}
|
|
387
417
|
}
|
|
388
418
|
exports.AforoWsBilling = AforoWsBilling;
|
|
@@ -463,61 +493,16 @@ function tooLong(field, value, max) {
|
|
|
463
493
|
return undefined;
|
|
464
494
|
return { field, problem: `${field} is ${value.length} chars, limit ${max}: ${preview(value)}` };
|
|
465
495
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
return status === 408 || status === 429 || status >= 500;
|
|
469
|
-
}
|
|
470
|
-
/** Retry-After (delta-seconds or HTTP date) in ms, capped at MAX_RETRY_AFTER_MS. */
|
|
471
|
-
function parseRetryAfter(res) {
|
|
472
|
-
const raw = res?.headers?.get?.('retry-after');
|
|
473
|
-
if (!raw)
|
|
496
|
+
function textOrUndefined(value) {
|
|
497
|
+
if (value === undefined || value === null)
|
|
474
498
|
return undefined;
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
return Math.min(secs * 1000, MAX_RETRY_AFTER_MS);
|
|
478
|
-
const date = Date.parse(raw);
|
|
479
|
-
if (!Number.isNaN(date))
|
|
480
|
-
return Math.min(Math.max(date - Date.now(), 0), MAX_RETRY_AFTER_MS);
|
|
481
|
-
return undefined;
|
|
499
|
+
const text = String(value).trim();
|
|
500
|
+
return text ? text : undefined;
|
|
482
501
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
* first 5 `errors[].message` entries, and the batch indexes `errors[]` names.
|
|
486
|
-
*/
|
|
487
|
-
async function readBatchResult(res, batchSize) {
|
|
488
|
-
const none = { failed: 0, details: '', indexes: [] };
|
|
489
|
-
if (typeof res?.json !== 'function')
|
|
490
|
-
return none;
|
|
491
|
-
try {
|
|
492
|
-
const body = unwrapEnvelope(await res.json());
|
|
493
|
-
const errors = Array.isArray(body?.errors) ? body.errors : undefined;
|
|
494
|
-
const details = errors
|
|
495
|
-
? errors.slice(0, 5).map((e) => `#${e?.index}: ${e?.message}`).join('; ')
|
|
496
|
-
: typeof body?.message === 'string' ? body.message : '';
|
|
497
|
-
const indexes = [
|
|
498
|
-
...new Set((errors ?? [])
|
|
499
|
-
.map((e) => e?.index)
|
|
500
|
-
.filter((i) => Number.isInteger(i) && i >= 0 && i < batchSize)),
|
|
501
|
-
].sort((a, b) => a - b);
|
|
502
|
-
const failed = typeof body?.failed === 'number' && body.failed > 0 ? body.failed : 0;
|
|
503
|
-
return { failed, details, indexes };
|
|
504
|
-
}
|
|
505
|
-
catch {
|
|
506
|
-
return none; // Non-JSON or empty body — nothing to report.
|
|
507
|
-
}
|
|
502
|
+
function headerValue(value) {
|
|
503
|
+
return Array.isArray(value) ? value[0] : value;
|
|
508
504
|
}
|
|
509
505
|
function randomSuffix() {
|
|
510
506
|
return Math.random().toString(36).slice(2, 10);
|
|
511
507
|
}
|
|
512
|
-
function sleep(ms) {
|
|
513
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
514
|
-
}
|
|
515
508
|
exports.WS_CLOSE_REASONS = CLOSE_REASONS;
|
|
516
|
-
/**
|
|
517
|
-
* The ingestor wraps every 2xx JSON body in `{success, data, meta}`. Returns
|
|
518
|
-
* the inner `data` object when present, else the body unchanged (bare shape).
|
|
519
|
-
*/
|
|
520
|
-
function unwrapEnvelope(body) {
|
|
521
|
-
const data = body && typeof body === 'object' && !Array.isArray(body) ? body.data : undefined;
|
|
522
|
-
return data && typeof data === 'object' && !Array.isArray(data) ? data : body;
|
|
523
|
-
}
|