@aforoai/ws-metering 1.0.0 → 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 +75 -16
- package/dist/index.d.ts +177 -25
- package/dist/index.js +336 -182
- 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,22 +28,29 @@
|
|
|
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;
|
|
53
|
+
const MAX_PRODUCT_TYPE = 20;
|
|
44
54
|
// Close reason code → descriptor enum label
|
|
45
55
|
const CLOSE_REASONS = {
|
|
46
56
|
1000: 'NORMAL_CLOSURE',
|
|
@@ -59,64 +69,179 @@ const CLOSE_REASONS = {
|
|
|
59
69
|
class AforoWsBilling {
|
|
60
70
|
config;
|
|
61
71
|
productType;
|
|
62
|
-
flushCount;
|
|
63
|
-
flushIntervalMs;
|
|
64
72
|
perFrameEvents;
|
|
65
73
|
onError;
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
core;
|
|
75
|
+
idleTimeoutMs;
|
|
76
|
+
connections = new Map();
|
|
77
|
+
sweepTimer;
|
|
78
|
+
shutdownPromise = null;
|
|
68
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;
|
|
69
86
|
this.config = {
|
|
70
87
|
tenantId: config.tenantId,
|
|
71
88
|
productId: config.productId,
|
|
72
|
-
apiKey: config.apiKey,
|
|
73
|
-
ingestorUrl: config.ingestorUrl,
|
|
74
89
|
};
|
|
75
90
|
this.productType = normalizeProductType(config.productType) ?? exports.DEFAULT_PRODUCT_TYPE;
|
|
76
|
-
this.flushCount = config.flushCount ?? 100;
|
|
77
|
-
this.flushIntervalMs = config.flushIntervalMs ?? 3000;
|
|
78
91
|
this.perFrameEvents = config.perFrameEvents ?? false;
|
|
79
92
|
this.onError = config.onError ?? ((err) => console.error('[aforo-ws]', err.message));
|
|
80
|
-
|
|
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;
|
|
81
133
|
}
|
|
82
|
-
/**
|
|
134
|
+
/**
|
|
135
|
+
* Wrap a `ws` WebSocketServer (or any object emitting 'connection' events).
|
|
136
|
+
* Throws when neither `customerResolver` nor `trustCustomerHeader: true` is given.
|
|
137
|
+
*/
|
|
83
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
|
+
}
|
|
84
146
|
wss.on('connection', (ws, req) => {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
+
}
|
|
88
156
|
const metadata = options.extractMetadata?.(req);
|
|
89
|
-
|
|
157
|
+
const status = options.executionStatus;
|
|
158
|
+
this.trackConnection(ws, {
|
|
159
|
+
customerId: customerId ?? '',
|
|
160
|
+
metadata,
|
|
161
|
+
executionStatus: typeof status === 'function' ? (event) => status(event, req) : status,
|
|
162
|
+
productType: options.productType,
|
|
163
|
+
});
|
|
90
164
|
});
|
|
91
165
|
}
|
|
92
166
|
/** Track a single WebSocket connection. Returns an unsubscribe function. */
|
|
93
167
|
trackConnection(ws, opts) {
|
|
94
|
-
if (!opts.customerId || !opts.customerId.trim())
|
|
95
|
-
return () => { };
|
|
96
|
-
if (opts.customerId.length > MAX_CUSTOMER_ID) {
|
|
97
|
-
this.onError(new Error(`WebSocket metering: customerId longer than ${MAX_CUSTOMER_ID} chars; connection not metered`));
|
|
98
|
-
return () => { };
|
|
99
|
-
}
|
|
100
168
|
const connectionId = (0, node_crypto_1.randomUUID)();
|
|
101
169
|
const productType = normalizeProductType(opts.productType) ?? this.productType;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 = {
|
|
109
186
|
customerId: opts.customerId,
|
|
110
187
|
wsConnectionId: connectionId,
|
|
111
188
|
productType,
|
|
112
189
|
wsDirection: 'SERVER_TO_CLIENT',
|
|
113
|
-
wsFrameType: '
|
|
190
|
+
wsFrameType: 'OPEN', // handshake complete; not a frame on the wire
|
|
114
191
|
messageCount: 0,
|
|
115
192
|
dataBytes: 0,
|
|
116
193
|
executionDurationMs: 0,
|
|
117
194
|
metadata: { ...(opts.metadata ?? {}), event: 'CONNECTION_OPENED' },
|
|
118
|
-
}
|
|
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);
|
|
119
241
|
ws.on('message', (data, isBinary) => {
|
|
242
|
+
if (closed)
|
|
243
|
+
return;
|
|
244
|
+
conn.lastActivity = Date.now();
|
|
120
245
|
recvCount++;
|
|
121
246
|
const bytes = estimateBytes(data);
|
|
122
247
|
recvBytes += bytes;
|
|
@@ -134,51 +259,56 @@ class AforoWsBilling {
|
|
|
134
259
|
});
|
|
135
260
|
}
|
|
136
261
|
});
|
|
137
|
-
// 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.
|
|
138
266
|
const origSend = ws.send.bind(ws);
|
|
139
|
-
ws.send = (data,
|
|
140
|
-
|
|
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;
|
|
141
272
|
const bytes = estimateBytes(data);
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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);
|
|
157
298
|
};
|
|
299
|
+
// opts.executionStatus is the connection's outcome: only the closing events
|
|
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).
|
|
158
303
|
ws.on('close', (code) => {
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
customerId: opts.customerId,
|
|
162
|
-
wsConnectionId: connectionId,
|
|
163
|
-
productType,
|
|
164
|
-
wsDirection: 'SERVER_TO_CLIENT',
|
|
165
|
-
wsFrameType: 'CLOSE',
|
|
166
|
-
wsCloseReason: CLOSE_REASONS[code] ?? 'NORMAL_CLOSURE',
|
|
167
|
-
messageCount: sentCount + recvCount,
|
|
168
|
-
dataBytes: sentBytes + recvBytes,
|
|
169
|
-
executionDurationMs: Date.now() - start,
|
|
170
|
-
metadata: {
|
|
171
|
-
...(opts.metadata ?? {}),
|
|
172
|
-
event: 'CONNECTION_CLOSED',
|
|
173
|
-
sentCount,
|
|
174
|
-
recvCount,
|
|
175
|
-
sentBytes,
|
|
176
|
-
recvBytes,
|
|
177
|
-
closeCode: code,
|
|
178
|
-
},
|
|
179
|
-
});
|
|
304
|
+
// CONNECTION_CLOSED with aggregated counters — this is the billing anchor.
|
|
305
|
+
conn.close(code);
|
|
180
306
|
});
|
|
181
307
|
ws.on('error', (err) => {
|
|
308
|
+
if (closed)
|
|
309
|
+
return;
|
|
310
|
+
closed = true;
|
|
311
|
+
this.connections.delete(connectionId);
|
|
182
312
|
this.push({
|
|
183
313
|
customerId: opts.customerId,
|
|
184
314
|
wsConnectionId: connectionId,
|
|
@@ -189,23 +319,27 @@ class AforoWsBilling {
|
|
|
189
319
|
messageCount: sentCount + recvCount,
|
|
190
320
|
dataBytes: sentBytes + recvBytes,
|
|
191
321
|
executionDurationMs: Date.now() - start,
|
|
192
|
-
metadata: { ...(opts.metadata ?? {}), event: 'CONNECTION_ERROR', error: err
|
|
193
|
-
});
|
|
322
|
+
metadata: { ...(opts.metadata ?? {}), event: 'CONNECTION_ERROR', error: String(err?.message ?? err).slice(0, 200) },
|
|
323
|
+
}, opts.executionStatus);
|
|
194
324
|
});
|
|
195
325
|
return () => {
|
|
196
|
-
// Tracking lifetime is managed by the close handler
|
|
326
|
+
// Tracking lifetime is managed by the close handler, the idle sweep and shutdown().
|
|
197
327
|
};
|
|
198
328
|
}
|
|
199
|
-
|
|
329
|
+
/** Fill in the fields every event carries. */
|
|
330
|
+
buildEvent(partial) {
|
|
200
331
|
const now = new Date();
|
|
201
|
-
|
|
332
|
+
return {
|
|
202
333
|
...partial,
|
|
203
334
|
metricName: partial.wsFrameType === 'CLOSE'
|
|
204
335
|
? 'websocket_api.connection_closed'
|
|
205
|
-
:
|
|
336
|
+
: partial.wsFrameType === 'OPEN'
|
|
337
|
+
? 'websocket_api.connection_opened'
|
|
338
|
+
: 'websocket_api.message',
|
|
206
339
|
quantity: 1,
|
|
207
340
|
occurredAt: now.toISOString(),
|
|
208
|
-
//
|
|
341
|
+
// Minted once, here. SDK-generated; tail-trimmed to the ingestor's limit,
|
|
342
|
+
// keeping the unique millis:random suffix.
|
|
209
343
|
idempotencyKey: `ws:${this.config.tenantId}:${partial.wsConnectionId}:${partial.wsFrameType}:${now.getTime()}:${randomSuffix()}`.slice(-MAX_IDEMPOTENCY_KEY),
|
|
210
344
|
metadata: {
|
|
211
345
|
...(partial.metadata ?? {}),
|
|
@@ -213,82 +347,72 @@ class AforoWsBilling {
|
|
|
213
347
|
productId: this.config.productId,
|
|
214
348
|
},
|
|
215
349
|
};
|
|
216
|
-
this.buffer.push(event);
|
|
217
|
-
if (this.buffer.length >= this.flushCount) {
|
|
218
|
-
void this.flush();
|
|
219
|
-
}
|
|
220
350
|
}
|
|
221
|
-
|
|
222
|
-
|
|
351
|
+
push(partial, executionStatus) {
|
|
352
|
+
const event = this.buildEvent(partial);
|
|
353
|
+
const checked = checkExecutionStatus(resolveExecutionStatus(executionStatus, event));
|
|
354
|
+
if (checked.problem)
|
|
355
|
+
this.reportError(new Error(`[aforo-ws] ${checked.problem}`));
|
|
356
|
+
if (checked.status)
|
|
357
|
+
event.executionStatus = checked.status;
|
|
358
|
+
// An event the ingestor would reject is not sent (one bad event fails its
|
|
359
|
+
// whole batch): it is dropped here with reason 'invalid', never thrown
|
|
360
|
+
// into the socket handlers.
|
|
361
|
+
const invalid = tooLong('customerId', event.customerId, MAX_CUSTOMER_ID) ??
|
|
362
|
+
tooLong('productType', event.productType, MAX_PRODUCT_TYPE);
|
|
363
|
+
if (invalid) {
|
|
364
|
+
this.core.reportDrop([event], 'invalid', invalid.problem);
|
|
223
365
|
return;
|
|
224
|
-
const pending = this.buffer.splice(0, this.buffer.length);
|
|
225
|
-
// The ingestor accepts at most MAX_BATCH_EVENTS events per request.
|
|
226
|
-
for (let i = 0; i < pending.length; i += MAX_BATCH_EVENTS) {
|
|
227
|
-
await this.send(pending.slice(i, i + MAX_BATCH_EVENTS));
|
|
228
366
|
}
|
|
367
|
+
// Queue only; the core's single background sender does the HTTP.
|
|
368
|
+
this.core.enqueue(event);
|
|
229
369
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
235
|
-
let delayMs = Math.pow(2, attempt - 1) * 1000;
|
|
236
|
-
try {
|
|
237
|
-
const res = await fetch(this.config.ingestorUrl.replace(/\/$/, '') + '/v1/ingest/batch', {
|
|
238
|
-
method: 'POST',
|
|
239
|
-
headers: {
|
|
240
|
-
'Content-Type': 'application/json',
|
|
241
|
-
'X-API-Key': this.config.apiKey,
|
|
242
|
-
'X-Tenant-Id': this.config.tenantId,
|
|
243
|
-
},
|
|
244
|
-
body,
|
|
245
|
-
});
|
|
246
|
-
if (res.ok) {
|
|
247
|
-
await this.reportPartialFailures(res);
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
if (!isRetryableStatus(res.status)) {
|
|
251
|
-
const { details } = await readErrorMessages(res);
|
|
252
|
-
this.onError(new Error(`WebSocket metering batch rejected with HTTP ${res.status}${details ? ` — ${details}` : ''} (dropped ${batch.length} events, not retried)`));
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
delayMs = parseRetryAfter(res) ?? delayMs;
|
|
256
|
-
}
|
|
257
|
-
catch (err) {
|
|
258
|
-
if (attempt === maxRetries) {
|
|
259
|
-
this.onError(err);
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
if (attempt < maxRetries)
|
|
264
|
-
await sleep(delayMs);
|
|
370
|
+
/** Invoke onError; a hook bug must never break metering or flushing. */
|
|
371
|
+
reportError(err) {
|
|
372
|
+
try {
|
|
373
|
+
this.onError(err);
|
|
265
374
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
/** A 202 can still carry per-event rejections: `{accepted, duplicates, failed, errors:[{index, message}]}`. */
|
|
269
|
-
async reportPartialFailures(res) {
|
|
270
|
-
const { failed, details } = await readErrorMessages(res);
|
|
271
|
-
if (failed && failed > 0) {
|
|
272
|
-
this.onError(new Error(`Aforo ingestor rejected ${failed} event(s)${details ? ` — ${details}` : ''}`));
|
|
375
|
+
catch (hookErr) {
|
|
376
|
+
console.debug('[aforo-ws] onError hook threw', hookErr);
|
|
273
377
|
}
|
|
274
378
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
this.flushTimer = setInterval(() => { void this.flush(); }, this.flushIntervalMs);
|
|
279
|
-
// Unref so the background timer never blocks host-process exit (final flush still needs shutdown()).
|
|
280
|
-
if (this.flushTimer && typeof this.flushTimer === 'object' && 'unref' in this.flushTimer) {
|
|
281
|
-
this.flushTimer.unref();
|
|
282
|
-
}
|
|
283
|
-
if (typeof this.flushTimer.unref === 'function')
|
|
284
|
-
this.flushTimer.unref();
|
|
379
|
+
/** Number of events permanently dropped since this instance was created (every DropReason). */
|
|
380
|
+
get droppedCount() {
|
|
381
|
+
return this.core.droppedCount;
|
|
285
382
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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();
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
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.
|
|
399
|
+
*/
|
|
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
|
+
})();
|
|
290
408
|
}
|
|
291
|
-
|
|
409
|
+
return this.shutdownPromise;
|
|
410
|
+
}
|
|
411
|
+
toJSON() {
|
|
412
|
+
return { tenantId: this.config.tenantId, productId: this.config.productId, queued: this.queuedCount, dropped: this.droppedCount };
|
|
413
|
+
}
|
|
414
|
+
[Symbol.for('nodejs.util.inspect.custom')]() {
|
|
415
|
+
return `AforoWsBilling { tenantId: '${this.config.tenantId}', productId: '${this.config.productId}', queued: ${this.queuedCount}, dropped: ${this.droppedCount} }`;
|
|
292
416
|
}
|
|
293
417
|
}
|
|
294
418
|
exports.AforoWsBilling = AforoWsBilling;
|
|
@@ -306,6 +430,51 @@ function estimateBytes(data) {
|
|
|
306
430
|
return data.reduce((s, d) => s + estimateBytes(d), 0);
|
|
307
431
|
return 0;
|
|
308
432
|
}
|
|
433
|
+
/** Statuses the Aforo ingestor accepts (contract/ingest-contract.json, max 20 chars). */
|
|
434
|
+
const CANONICAL_EXECUTION_STATUSES = new Set([
|
|
435
|
+
'SUCCESS', 'PARTIAL', 'TIMEOUT', 'ERROR', 'VALIDATION_FAILED', 'FAILED',
|
|
436
|
+
'FAILURE', 'CANCELLED', 'PENDING', 'BLOCKED', 'HITL_REQUIRED',
|
|
437
|
+
]);
|
|
438
|
+
/**
|
|
439
|
+
* Check a caller-supplied status. `{ status }` when canonical; `{ problem }`
|
|
440
|
+
* when it has to be left off the event (an unknown value would make the
|
|
441
|
+
* ingestor reject the event; a Promise from an async resolver can't be
|
|
442
|
+
* awaited on this path); `{}` when blank or absent.
|
|
443
|
+
*/
|
|
444
|
+
function checkExecutionStatus(value) {
|
|
445
|
+
if (value !== null && typeof value === 'object' && typeof value.then === 'function') {
|
|
446
|
+
// Swallow a later rejection so it doesn't surface as an unhandled rejection.
|
|
447
|
+
value.then(undefined, () => { });
|
|
448
|
+
return { problem: 'executionStatus resolver returned a Promise — resolvers must be synchronous; field omitted' };
|
|
449
|
+
}
|
|
450
|
+
const status = normalizeExecutionStatus(value);
|
|
451
|
+
if (!status)
|
|
452
|
+
return {};
|
|
453
|
+
if (!CANONICAL_EXECUTION_STATUSES.has(status)) {
|
|
454
|
+
return {
|
|
455
|
+
problem: `unknown executionStatus "${status.slice(0, 40)}" — field omitted. ` +
|
|
456
|
+
`Expected one of: ${[...CANONICAL_EXECUTION_STATUSES].join(', ')}`,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
return { status };
|
|
460
|
+
}
|
|
461
|
+
/** Trim + upper-case; blank/absent → undefined (key omitted from the wire body). */
|
|
462
|
+
function normalizeExecutionStatus(value) {
|
|
463
|
+
if (typeof value !== 'string')
|
|
464
|
+
return undefined;
|
|
465
|
+
const trimmed = value.trim();
|
|
466
|
+
return trimmed ? trimmed.toUpperCase() : undefined;
|
|
467
|
+
}
|
|
468
|
+
function resolveExecutionStatus(option, event) {
|
|
469
|
+
if (typeof option !== 'function')
|
|
470
|
+
return option;
|
|
471
|
+
try {
|
|
472
|
+
return option(event);
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
return undefined; // a resolver bug must never break metering
|
|
476
|
+
}
|
|
477
|
+
}
|
|
309
478
|
/** Trim + uppercase a product type; blank/non-string → undefined (unknown values pass through). */
|
|
310
479
|
function normalizeProductType(value) {
|
|
311
480
|
if (typeof value !== 'string')
|
|
@@ -313,42 +482,27 @@ function normalizeProductType(value) {
|
|
|
313
482
|
const trimmed = value.trim();
|
|
314
483
|
return trimmed ? trimmed.toUpperCase() : undefined;
|
|
315
484
|
}
|
|
316
|
-
/**
|
|
317
|
-
function
|
|
318
|
-
|
|
485
|
+
/** The offending value for a WARN line, cut to 80 chars. */
|
|
486
|
+
function preview(value) {
|
|
487
|
+
const text = typeof value === 'string' ? value : String(value);
|
|
488
|
+
return JSON.stringify(text.length > 80 ? `${text.slice(0, 80)}…` : text);
|
|
319
489
|
}
|
|
320
|
-
/**
|
|
321
|
-
function
|
|
322
|
-
|
|
323
|
-
if (!raw)
|
|
490
|
+
/** Limits mirror the ingestor's IngestUsageEventRequest; over-limit values are never truncated. */
|
|
491
|
+
function tooLong(field, value, max) {
|
|
492
|
+
if (typeof value !== 'string' || value.length <= max)
|
|
324
493
|
return undefined;
|
|
325
|
-
|
|
326
|
-
if (Number.isFinite(secs) && secs >= 0)
|
|
327
|
-
return Math.min(secs * 1000, MAX_RETRY_AFTER_MS);
|
|
328
|
-
const date = Date.parse(raw);
|
|
329
|
-
if (!Number.isNaN(date))
|
|
330
|
-
return Math.min(Math.max(date - Date.now(), 0), MAX_RETRY_AFTER_MS);
|
|
331
|
-
return undefined;
|
|
494
|
+
return { field, problem: `${field} is ${value.length} chars, limit ${max}: ${preview(value)}` };
|
|
332
495
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
: typeof body?.message === 'string' ? body.message : '';
|
|
342
|
-
return { failed: typeof body?.failed === 'number' ? body.failed : undefined, details };
|
|
343
|
-
}
|
|
344
|
-
catch {
|
|
345
|
-
return { details: '' }; // Non-JSON or empty body — nothing to report.
|
|
346
|
-
}
|
|
496
|
+
function textOrUndefined(value) {
|
|
497
|
+
if (value === undefined || value === null)
|
|
498
|
+
return undefined;
|
|
499
|
+
const text = String(value).trim();
|
|
500
|
+
return text ? text : undefined;
|
|
501
|
+
}
|
|
502
|
+
function headerValue(value) {
|
|
503
|
+
return Array.isArray(value) ? value[0] : value;
|
|
347
504
|
}
|
|
348
505
|
function randomSuffix() {
|
|
349
506
|
return Math.random().toString(36).slice(2, 10);
|
|
350
507
|
}
|
|
351
|
-
function sleep(ms) {
|
|
352
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
353
|
-
}
|
|
354
508
|
exports.WS_CLOSE_REASONS = CLOSE_REASONS;
|