@aforoai/mqtt-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/dist/index.js CHANGED
@@ -27,16 +27,34 @@
27
27
  *
28
28
  * const broker = aedes();
29
29
  * billing.wrapAedesBroker(broker, {
30
- * resolveCustomerId: (clientId) => customerIdLookup(clientId),
30
+ * // Who is billed: map the client your broker authenticated to an Aforo customer.
31
+ * customerResolver: (clientId, username) => deviceRegistry.customerFor(clientId),
31
32
  * });
33
+ *
34
+ * // The SDK installs no signal handlers. Flush from your own:
35
+ * process.once('SIGTERM', async () => { await billing.shutdown(); process.exit(0); });
32
36
  */
33
37
  Object.defineProperty(exports, "__esModule", { value: true });
34
- exports.AforoMqttBilling = exports.mqttDisconnectTopic = exports.mqttConnectTopic = exports.DEFAULT_PRODUCT_TYPE = void 0;
35
- const SDK_VERSION = '1.0.0';
38
+ exports.AforoMqttBilling = exports.mqttDisconnectTopic = exports.mqttConnectTopic = exports.DEFAULT_PRODUCT_TYPE = exports.DEFAULT_RECONNECT_DEBOUNCE_MS = exports.CoreTransportError = void 0;
39
+ exports.truncateToLimit = truncateToLimit;
40
+ const node_crypto_1 = require("node:crypto");
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
+ /** Default reconnect window, the same as python-mqtt's `reconnect_debounce_sec`. */
46
+ exports.DEFAULT_RECONNECT_DEBOUNCE_MS = 30_000;
47
+ function checkDebounce(value, fallback, where) {
48
+ if (value === undefined)
49
+ return fallback;
50
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
51
+ throw new Error(`[aforo-mqtt] ${where}: reconnectDebounceMs must be a non-negative number of milliseconds (got ${String(value)})`);
52
+ }
53
+ return value;
54
+ }
55
+ const SDK_VERSION = '2.0.0';
36
56
  /** Default top-level `productType` for this SDK. */
37
57
  exports.DEFAULT_PRODUCT_TYPE = 'MQTT_BROKER';
38
- /** Upper bound on a server-requested Retry-After wait. */
39
- const MAX_RETRY_AFTER_MS = 30_000;
40
58
  /**
41
59
  * mqttTopic is required on every MQTT_BROKER event, but CONNECT / DISCONNECT
42
60
  * have no topic. These per-client `$SYS/clients/<id>/connected|disconnected`
@@ -48,216 +66,382 @@ const mqttConnectTopic = (clientId) => `$SYS/clients/${clientId || 'unknown'}/co
48
66
  exports.mqttConnectTopic = mqttConnectTopic;
49
67
  const mqttDisconnectTopic = (clientId) => `$SYS/clients/${clientId || 'unknown'}/disconnected`;
50
68
  exports.mqttDisconnectTopic = mqttDisconnectTopic;
51
- /** The ingestor rejects batch requests with more than 1000 events. */
52
- const MAX_BATCH_EVENTS = 1000;
53
69
  /** usage-ingestor limits (IngestUsageEventRequest). */
54
70
  const MAX_CUSTOMER_ID = 64;
55
71
  const MAX_IDEMPOTENCY_KEY = 255;
72
+ const MAX_PRODUCT_TYPE = 20;
73
+ const MAX_MQTT_TOPIC = 500;
74
+ const MAX_MQTT_CLIENT_ID = 128;
56
75
  class AforoMqttBilling {
57
76
  config;
58
77
  productType;
59
- flushCount;
60
- flushIntervalMs;
61
78
  emitDeliverEvents;
62
79
  onError;
63
- buffer = [];
64
- flushTimer = null;
80
+ core;
81
+ /** Request-derived labels already reported as truncated (one WARN per label). */
82
+ truncationWarned = new Set();
83
+ reconnectDebounceMs;
84
+ /** DISCONNECTs inside their reconnect window; billed at shutdown at the latest. */
85
+ pendingDisconnects = new Set();
65
86
  constructor(config) {
66
87
  this.config = {
67
88
  tenantId: config.tenantId,
68
89
  productId: config.productId,
69
- apiKey: config.apiKey,
70
- ingestorUrl: config.ingestorUrl,
71
90
  };
72
91
  this.productType = normalizeProductType(config.productType) ?? exports.DEFAULT_PRODUCT_TYPE;
73
- this.flushCount = config.flushCount ?? 200;
74
- this.flushIntervalMs = config.flushIntervalMs ?? 2000;
75
92
  this.emitDeliverEvents = config.emitDeliverEvents ?? false;
93
+ this.reconnectDebounceMs = checkDebounce(config.reconnectDebounceMs, exports.DEFAULT_RECONNECT_DEBOUNCE_MS, 'AforoMqttBilling');
76
94
  this.onError = config.onError ?? ((err) => console.error('[aforo-mqtt]', err.message));
77
- this.startTimer();
95
+ const onDrop = config.onDrop;
96
+ // The core is the only holder of the API key.
97
+ this.core = new core_1.TransportCore({
98
+ baseUrl: config.ingestorUrl,
99
+ apiKey: config.apiKey,
100
+ allowInsecureHttp: config.allowInsecureHttp,
101
+ headers: { 'X-Tenant-Id': config.tenantId },
102
+ userAgent: `aforo-mqtt-metering/${SDK_VERSION}`,
103
+ flushCount: config.flushCount,
104
+ flushIntervalMs: config.flushIntervalMs,
105
+ maxQueueSize: config.maxQueueSize,
106
+ maxRetries: config.maxRetries,
107
+ timeoutMs: config.timeoutMs,
108
+ shutdownTimeoutMs: config.shutdownTimeoutMs,
109
+ flushOnExit: config.flushOnExit,
110
+ logPrefix: '[aforo-mqtt]',
111
+ onError: (err) => this.reportError(err),
112
+ onDrop: onDrop ? (events, reason) => onDrop(events, reason) : undefined,
113
+ });
78
114
  }
79
115
  // ── Broker-side integration (Aedes) ─────────────────────────────
116
+ /**
117
+ * Attach to an Aedes broker. Throws when neither `customerResolver` nor
118
+ * `trustCustomerHeader: true` is given. Every listener catches its own
119
+ * errors (a resolver rejection is reported through onError), so metering can
120
+ * never produce an unhandled rejection in the broker process.
121
+ */
80
122
  wrapAedesBroker(broker, options) {
81
- const resolveCustomer = async (clientId, username) => Promise.resolve(options.resolveCustomerId(clientId, username));
82
- broker.on('publish', async (packet, client) => {
123
+ const resolver = options?.customerResolver ?? options?.resolveCustomerId;
124
+ const trustUsername = options?.trustCustomerHeader === true;
125
+ if (typeof resolver !== 'function' && !trustUsername) {
126
+ throw new Error('[aforo-mqtt] wrapAedesBroker needs a customerResolver: (clientId, username) => the Aforo customer id ' +
127
+ 'for the client your broker authenticated. To bill the MQTT username instead — only when your broker ' +
128
+ 'authenticates it — pass trustCustomerHeader: true.');
129
+ }
130
+ const resolveCustomer = async (clientId, username) => {
131
+ const fromResolver = typeof resolver === 'function' ? textOrUndefined(await resolver(clientId, username)) : undefined;
132
+ if (fromResolver)
133
+ return fromResolver;
134
+ return trustUsername ? textOrUndefined(username) : undefined;
135
+ };
136
+ const debounceMs = checkDebounce(options.reconnectDebounceMs, this.reconnectDebounceMs, 'wrapAedesBroker');
137
+ /** Per client id: live connections and a DISCONNECT inside its reconnect window. */
138
+ const sessions = new Map();
139
+ const status = options.executionStatus;
140
+ const brokerStatus = (clientId) => typeof status === 'function' ? (event) => status(event, clientId) : status;
141
+ /**
142
+ * Run a listener body. The returned promise always resolves: a throw or
143
+ * rejection is reported through onError, never left unhandled.
144
+ */
145
+ const guarded = (hook, body) => body().catch((err) => {
146
+ this.reportError(new Error(`[aforo-mqtt] ${hook} metering failed: ${err?.message ?? err}`));
147
+ });
148
+ /** Resolve the customer; a resolver failure is reported and treated as "no customer". */
149
+ const customerFor = async (hook, clientId, username) => {
150
+ try {
151
+ return (await resolveCustomer(clientId, username)) ?? '';
152
+ }
153
+ catch (err) {
154
+ this.reportError(new Error(`[aforo-mqtt] customerResolver failed on ${hook}: ${err?.message ?? err}`));
155
+ return '';
156
+ }
157
+ };
158
+ broker.on('publish', (packet, client) => {
83
159
  if (!client)
84
160
  return; // broker-originated publishes skip billing
85
- const clientId = client.id;
86
- const customerId = await resolveCustomer(clientId, client.username);
87
- if (!customerId)
88
- return;
89
- const payload = packet.payload;
90
- const bytes = estimateBytes(payload);
91
- this.push({
92
- customerId,
93
- mqttTopic: packet.topic,
94
- mqttQos: packet.qos ?? 0,
95
- mqttRetained: !!packet.retain,
96
- mqttEventType: 'PUBLISH',
97
- mqttClientId: clientId,
98
- productType: options.productType,
99
- dataBytes: bytes,
100
- metadata: options.resolveMetadata?.(clientId),
161
+ return guarded('publish', async () => {
162
+ const clientId = client.id;
163
+ const customerId = await customerFor('publish', clientId, client.username);
164
+ this.push({
165
+ customerId,
166
+ mqttTopic: this.topicLabel(packet?.topic),
167
+ mqttQos: packet?.qos ?? 0,
168
+ mqttRetained: !!packet?.retain,
169
+ mqttEventType: 'PUBLISH',
170
+ mqttClientId: this.clientIdLabel(clientId),
171
+ productType: options.productType,
172
+ dataBytes: estimateBytes(packet?.payload),
173
+ metadata: options.resolveMetadata?.(clientId),
174
+ }, brokerStatus(clientId), clientId);
101
175
  });
102
176
  });
103
- broker.on('subscribe', async (subs, client) => {
177
+ broker.on('subscribe', (subs, client) => {
104
178
  const clientId = client?.id;
105
179
  if (!clientId)
106
180
  return;
107
- const customerId = await resolveCustomer(clientId, client.username);
108
- if (!customerId)
181
+ return guarded('subscribe', async () => {
182
+ const customerId = await customerFor('subscribe', clientId, client.username);
183
+ const arr = Array.isArray(subs) ? subs : [subs];
184
+ for (const s of arr) {
185
+ this.push({
186
+ customerId,
187
+ mqttTopic: this.topicLabel(s?.topic),
188
+ mqttQos: s?.qos ?? 0,
189
+ mqttRetained: false,
190
+ mqttEventType: 'SUBSCRIBE',
191
+ mqttClientId: this.clientIdLabel(clientId),
192
+ productType: options.productType,
193
+ dataBytes: 0,
194
+ metadata: options.resolveMetadata?.(clientId),
195
+ }, brokerStatus(clientId), clientId);
196
+ }
197
+ });
198
+ });
199
+ broker.on('unsubscribe', (unsubs, client) => {
200
+ const clientId = client?.id;
201
+ if (!clientId)
109
202
  return;
110
- const arr = Array.isArray(subs) ? subs : [subs];
111
- for (const s of arr) {
203
+ return guarded('unsubscribe', async () => {
204
+ const customerId = await customerFor('unsubscribe', clientId, client.username);
205
+ const arr = Array.isArray(unsubs) ? unsubs : [unsubs];
206
+ for (const topic of arr) {
207
+ this.push({
208
+ customerId,
209
+ mqttTopic: this.topicLabel(typeof topic === 'string' ? topic : topic?.topic ?? ''),
210
+ mqttQos: 0,
211
+ mqttRetained: false,
212
+ mqttEventType: 'UNSUBSCRIBE',
213
+ mqttClientId: this.clientIdLabel(clientId),
214
+ productType: options.productType,
215
+ dataBytes: 0,
216
+ metadata: options.resolveMetadata?.(clientId),
217
+ }, brokerStatus(clientId), clientId);
218
+ }
219
+ });
220
+ });
221
+ broker.on('client', (client) => {
222
+ if (debounceMs > 0) {
223
+ const key = String(client?.id ?? '');
224
+ let session = sessions.get(key);
225
+ if (!session) {
226
+ session = { live: 0, pending: null };
227
+ sessions.set(key, session);
228
+ }
229
+ // Back inside the window, or a takeover of its own live session: same session, no CONNECT.
230
+ const sameSession = session.pending !== null || session.live > 0;
231
+ session.pending?.cancel();
232
+ session.pending = null;
233
+ session.live += 1;
234
+ if (sameSession)
235
+ return;
236
+ }
237
+ return guarded('client', async () => {
238
+ const clientId = client?.id;
239
+ const customerId = await customerFor('client', clientId, client?.username);
112
240
  this.push({
113
241
  customerId,
114
- mqttTopic: s.topic,
115
- mqttQos: s.qos ?? 0,
242
+ // CONNECT has no topic; mqttTopic is required. Built from the bounded
243
+ // client id so the stand-in topic keeps its `/connected` suffix.
244
+ mqttTopic: (0, exports.mqttConnectTopic)(this.clientIdLabel(clientId)),
245
+ mqttQos: 0,
116
246
  mqttRetained: false,
117
- mqttEventType: 'SUBSCRIBE',
118
- mqttClientId: clientId,
247
+ mqttEventType: 'CONNECT',
248
+ mqttClientId: this.clientIdLabel(clientId),
119
249
  productType: options.productType,
120
250
  dataBytes: 0,
121
251
  metadata: options.resolveMetadata?.(clientId),
122
- });
123
- }
252
+ }, brokerStatus(clientId), clientId);
253
+ });
124
254
  });
125
- broker.on('unsubscribe', async (unsubs, client) => {
126
- const clientId = client?.id;
127
- if (!clientId)
128
- return;
129
- const customerId = await resolveCustomer(clientId, client.username);
130
- if (!customerId)
131
- return;
132
- const arr = Array.isArray(unsubs) ? unsubs : [unsubs];
133
- for (const topic of arr) {
255
+ broker.on('clientDisconnect', (client) => {
256
+ const at = new Date();
257
+ const emit = () => guarded('clientDisconnect', async () => {
258
+ const clientId = client?.id;
259
+ const customerId = await customerFor('clientDisconnect', clientId, client?.username);
134
260
  this.push({
135
261
  customerId,
136
- mqttTopic: typeof topic === 'string' ? topic : topic?.topic ?? '',
262
+ mqttTopic: (0, exports.mqttDisconnectTopic)(this.clientIdLabel(clientId)),
137
263
  mqttQos: 0,
138
264
  mqttRetained: false,
139
- mqttEventType: 'UNSUBSCRIBE',
140
- mqttClientId: clientId,
265
+ mqttEventType: 'DISCONNECT',
266
+ mqttClientId: this.clientIdLabel(clientId),
141
267
  productType: options.productType,
142
268
  dataBytes: 0,
143
269
  metadata: options.resolveMetadata?.(clientId),
144
- });
145
- }
146
- });
147
- broker.on('client', async (client) => {
148
- const clientId = client.id;
149
- const customerId = await resolveCustomer(clientId, client.username);
150
- if (!customerId)
151
- return;
152
- this.push({
153
- customerId,
154
- mqttTopic: (0, exports.mqttConnectTopic)(clientId), // CONNECT has no topic; mqttTopic is required
155
- mqttQos: 0,
156
- mqttRetained: false,
157
- mqttEventType: 'CONNECT',
158
- mqttClientId: clientId,
159
- productType: options.productType,
160
- dataBytes: 0,
161
- metadata: options.resolveMetadata?.(clientId),
270
+ }, brokerStatus(clientId), clientId, at);
162
271
  });
163
- });
164
- broker.on('clientDisconnect', async (client) => {
165
- const clientId = client.id;
166
- const customerId = await resolveCustomer(clientId, client.username);
167
- if (!customerId)
168
- return;
169
- this.push({
170
- customerId,
171
- mqttTopic: (0, exports.mqttDisconnectTopic)(clientId),
172
- mqttQos: 0,
173
- mqttRetained: false,
174
- mqttEventType: 'DISCONNECT',
175
- mqttClientId: clientId,
176
- productType: options.productType,
177
- dataBytes: 0,
178
- metadata: options.resolveMetadata?.(clientId),
272
+ if (debounceMs === 0)
273
+ return emit();
274
+ const key = String(client?.id ?? '');
275
+ const session = sessions.get(key) ?? { live: 1, pending: null };
276
+ session.live = Math.max(0, session.live - 1);
277
+ if (session.live > 0)
278
+ return; // a takeover: the session lives on in the new connection
279
+ // aedes sets _disconnected only for a DISCONNECT packet: a clean disconnect is billed at once.
280
+ if (client?._disconnected === true) {
281
+ sessions.delete(key);
282
+ return emit();
283
+ }
284
+ sessions.set(key, session);
285
+ session.pending = this.scheduleDisconnect(debounceMs, () => {
286
+ session.pending = null;
287
+ if (session.live === 0)
288
+ sessions.delete(key);
289
+ return emit();
179
290
  });
180
291
  });
181
292
  }
182
293
  // ── Client-side integration (mqtt.js) ───────────────────────────
183
294
  wrapMqttClient(client, options) {
184
295
  const clientId = options.clientId ?? client.options?.clientId ?? 'mqtt-client';
296
+ const debounceMs = checkDebounce(options?.reconnectDebounceMs, this.reconnectDebounceMs, 'wrapMqttClient');
297
+ let connected = false;
298
+ let pendingDisconnect = null;
185
299
  client.on('connect', () => {
300
+ if (pendingDisconnect) {
301
+ // Reconnected inside the window: the same session, nothing new to bill.
302
+ pendingDisconnect.cancel();
303
+ pendingDisconnect = null;
304
+ connected = true;
305
+ return;
306
+ }
307
+ if (connected && debounceMs > 0)
308
+ return;
309
+ connected = true;
186
310
  this.push({
187
311
  customerId: options.customerId,
188
- mqttTopic: (0, exports.mqttConnectTopic)(clientId),
312
+ mqttTopic: (0, exports.mqttConnectTopic)(this.clientIdLabel(clientId)),
189
313
  mqttQos: 0,
190
314
  mqttRetained: false,
191
315
  mqttEventType: 'CONNECT',
192
- mqttClientId: clientId,
316
+ mqttClientId: this.clientIdLabel(clientId),
193
317
  productType: options.productType,
194
318
  dataBytes: 0,
195
- });
319
+ }, options.executionStatus, clientId);
196
320
  });
321
+ // mqtt.js emits 'close' for every lost connection and for every failed
322
+ // reconnect attempt; only the end of a live connection can be a DISCONNECT.
197
323
  client.on('close', () => {
198
- this.push({
324
+ if (!connected)
325
+ return;
326
+ connected = false;
327
+ const at = new Date();
328
+ const emit = () => this.push({
199
329
  customerId: options.customerId,
200
- mqttTopic: (0, exports.mqttDisconnectTopic)(clientId),
330
+ mqttTopic: (0, exports.mqttDisconnectTopic)(this.clientIdLabel(clientId)),
201
331
  mqttQos: 0,
202
332
  mqttRetained: false,
203
333
  mqttEventType: 'DISCONNECT',
204
- mqttClientId: clientId,
334
+ mqttClientId: this.clientIdLabel(clientId),
205
335
  productType: options.productType,
206
336
  dataBytes: 0,
337
+ }, options.executionStatus, clientId, at);
338
+ if (debounceMs === 0 || client.disconnecting === true) {
339
+ emit(); // the caller ended it: a clean disconnect
340
+ return;
341
+ }
342
+ pendingDisconnect = this.scheduleDisconnect(debounceMs, () => {
343
+ pendingDisconnect = null;
344
+ emit();
207
345
  });
208
346
  });
347
+ // end() while waiting for a reconnect: the session is over now.
348
+ client.on('end', () => {
349
+ const pending = pendingDisconnect;
350
+ pendingDisconnect = null;
351
+ if (pending)
352
+ void pending.fire();
353
+ });
209
354
  client.on('message', (topic, payload, packet) => {
210
355
  this.push({
211
356
  customerId: options.customerId,
212
- mqttTopic: topic,
357
+ mqttTopic: this.topicLabel(topic),
213
358
  mqttQos: packet?.qos ?? 0,
214
359
  mqttRetained: !!packet?.retain,
215
360
  mqttEventType: 'DELIVER',
216
- mqttClientId: clientId,
361
+ mqttClientId: this.clientIdLabel(clientId),
217
362
  productType: options.productType,
218
363
  dataBytes: estimateBytes(payload),
219
- });
364
+ }, options.executionStatus, clientId);
220
365
  });
366
+ // PUBLISH is billed when mqtt.js reports success: the broker's PUBACK /
367
+ // PUBCOMP for QoS 1 / 2, the socket write for QoS 0. A publish that
368
+ // throws or whose callback gets an error is not billed. publishAsync()
369
+ // goes through publish(), so it is covered too.
221
370
  const origPublish = client.publish.bind(client);
222
371
  client.publish = (topic, message, opts, cb) => {
223
- this.push({
224
- customerId: options.customerId,
225
- mqttTopic: topic,
226
- mqttQos: opts?.qos ?? 0,
227
- mqttRetained: !!opts?.retain,
228
- mqttEventType: 'PUBLISH',
229
- mqttClientId: clientId,
230
- productType: options.productType,
231
- dataBytes: estimateBytes(message),
232
- });
233
- return origPublish(topic, message, opts, cb);
372
+ const optsIsCallback = typeof opts === 'function';
373
+ const userCallback = optsIsCallback ? opts : cb;
374
+ const publishOpts = optsIsCallback ? undefined : opts;
375
+ const at = new Date();
376
+ const done = (err, ...rest) => {
377
+ if (!err) {
378
+ try {
379
+ this.push({
380
+ customerId: options.customerId,
381
+ mqttTopic: this.topicLabel(topic),
382
+ mqttQos: publishOpts?.qos ?? 0,
383
+ mqttRetained: !!publishOpts?.retain,
384
+ mqttEventType: 'PUBLISH',
385
+ mqttClientId: this.clientIdLabel(clientId),
386
+ productType: options.productType,
387
+ dataBytes: estimateBytes(message),
388
+ }, options.executionStatus, clientId, at);
389
+ }
390
+ catch (meterErr) {
391
+ this.reportError(new Error(`[aforo-mqtt] publish metering failed: ${meterErr?.message ?? meterErr}`));
392
+ }
393
+ }
394
+ if (typeof userCallback === 'function')
395
+ return userCallback(err, ...rest);
396
+ };
397
+ return optsIsCallback ? origPublish(topic, message, done) : origPublish(topic, message, opts, done);
234
398
  };
235
399
  }
236
400
  // ── Event pipeline ──────────────────────────────────────────────
237
- push(partial) {
401
+ /**
402
+ * Bound an MQTT label (topic, client id) to the ingestor's limit. The event
403
+ * is still sent; one WARN per label name per client. Identity fields
404
+ * (customerId, productType) never go through here — an over-long one drops
405
+ * the event as 'invalid'.
406
+ */
407
+ requestLabel(field, value, max) {
408
+ if (typeof value !== 'string' || value.length <= max)
409
+ return value;
410
+ if (!this.truncationWarned.has(field)) {
411
+ this.truncationWarned.add(field);
412
+ console.warn(`[aforo-mqtt] ${field} was longer than the ingestor's limit and was ` +
413
+ `truncated to ${max} characters; the event is still sent. Logged once per label.`);
414
+ }
415
+ return truncateToLimit(value, max);
416
+ }
417
+ /** Topic off an incoming packet, bound to the ingestor's mqttTopic limit. */
418
+ topicLabel(topic) {
419
+ return typeof topic === 'string' ? this.requestLabel('mqttTopic', topic, MAX_MQTT_TOPIC) : topic;
420
+ }
421
+ /** Client id a connecting client chose, bound to the ingestor's mqttClientId limit. */
422
+ clientIdLabel(clientId) {
423
+ return typeof clientId === 'string' ? this.requestLabel('mqttClientId', clientId, MAX_MQTT_CLIENT_ID) : clientId;
424
+ }
425
+ push(partial, executionStatus,
426
+ /** Untruncated client id for the idempotency key, when `partial.mqttClientId` was cut to the limit. */
427
+ keyClientId,
428
+ /** When it happened, if not now (a debounced DISCONNECT, a confirmed PUBLISH). */
429
+ at) {
238
430
  if (!this.emitDeliverEvents && partial.mqttEventType === 'DELIVER')
239
431
  return;
240
- if (!partial.customerId || !partial.customerId.trim())
241
- return;
242
- if (partial.customerId.length > MAX_CUSTOMER_ID) {
243
- this.onError(new Error(`MQTT metering: customerId longer than ${MAX_CUSTOMER_ID} chars; event dropped`));
244
- return;
245
- }
246
- const mqttTopic = partial.mqttTopic;
247
- if (!mqttTopic || !mqttTopic.trim())
248
- return; // mqttTopic is required on every MQTT_BROKER event
249
- const now = new Date();
250
- const mqttClientId = partial.mqttClientId.slice(0, 128);
432
+ const now = at ?? new Date();
433
+ const mqttClientId = typeof partial.mqttClientId === 'string' ? partial.mqttClientId : String(partial.mqttClientId ?? '');
251
434
  const event = {
252
435
  ...partial,
253
- mqttTopic: mqttTopic.slice(0, 500),
254
436
  mqttQos: partial.mqttQos === 1 || partial.mqttQos === 2 ? partial.mqttQos : 0,
255
437
  mqttClientId,
256
438
  metricName: `mqtt_broker.${partial.mqttEventType.toLowerCase()}`,
257
439
  quantity: 1,
258
440
  occurredAt: now.toISOString(),
259
- // Topic left out (it can be 500 chars); tail-trimmed to keep the unique millis:random suffix.
260
- idempotencyKey: `mqtt:${this.config.tenantId}:${mqttClientId}:${partial.mqttEventType}:${now.getTime()}:${randomSuffix()}`.slice(-MAX_IDEMPOTENCY_KEY),
441
+ // Minted once, here, from the untruncated client id; the topic is left
442
+ // out. When the client id makes the key longer than the ingestor allows,
443
+ // it is replaced by its SHA-256 digest; the key itself is never cut.
444
+ idempotencyKey: boundedIdempotencyKey(`mqtt:${this.config.tenantId}:`, typeof keyClientId === 'string' ? keyClientId : mqttClientId, `:${partial.mqttEventType}:${now.getTime()}:${randomSuffix()}`),
261
445
  productType: normalizeProductType(partial.productType) ?? this.productType,
262
446
  metadata: {
263
447
  ...(partial.metadata ?? {}),
@@ -265,82 +449,94 @@ class AforoMqttBilling {
265
449
  productId: this.config.productId,
266
450
  },
267
451
  };
268
- this.buffer.push(event);
269
- if (this.buffer.length >= this.flushCount) {
270
- void this.flush();
271
- }
272
- }
273
- async flush() {
274
- if (this.buffer.length === 0)
452
+ const checked = checkExecutionStatus(resolveExecutionStatus(executionStatus, event));
453
+ if (checked.problem)
454
+ this.reportError(new Error(`[aforo-mqtt] ${checked.problem}`));
455
+ if (checked.status)
456
+ event.executionStatus = checked.status;
457
+ // An event the ingestor would reject is not sent (one bad event fails its
458
+ // whole batch): it is dropped here with reason 'invalid', never thrown
459
+ // into the broker / client handlers. mqttTopic is required on every
460
+ // MQTT_BROKER event.
461
+ const invalid = (typeof event.customerId !== 'string' || !event.customerId.trim()
462
+ ? { field: 'customerId', problem: 'no customer: the customer resolver returned nothing (or customerId is blank); the event is not metered' }
463
+ : undefined) ??
464
+ tooLong('customerId', event.customerId, MAX_CUSTOMER_ID) ??
465
+ requiredText('mqttTopic', event.mqttTopic) ??
466
+ tooLong('mqttTopic', event.mqttTopic, MAX_MQTT_TOPIC) ??
467
+ tooLong('mqttClientId', event.mqttClientId, MAX_MQTT_CLIENT_ID) ??
468
+ tooLong('productType', event.productType, MAX_PRODUCT_TYPE);
469
+ if (invalid) {
470
+ this.core.reportDrop([event], 'invalid', invalid.problem);
275
471
  return;
276
- const pending = this.buffer.splice(0, this.buffer.length);
277
- // The ingestor accepts at most MAX_BATCH_EVENTS events per request.
278
- for (let i = 0; i < pending.length; i += MAX_BATCH_EVENTS) {
279
- await this.send(pending.slice(i, i + MAX_BATCH_EVENTS));
280
472
  }
473
+ // Queue only; the core's single background sender does the HTTP.
474
+ this.core.enqueue(event);
281
475
  }
282
- async send(batch) {
283
- // Serialized once, so every retry re-sends the same idempotencyKeys.
284
- const body = JSON.stringify({ events: batch });
285
- const maxRetries = 3;
286
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
287
- let delayMs = Math.pow(2, attempt - 1) * 1000;
288
- try {
289
- const res = await fetch(this.config.ingestorUrl.replace(/\/$/, '') + '/v1/ingest/batch', {
290
- method: 'POST',
291
- headers: {
292
- 'Content-Type': 'application/json',
293
- 'X-API-Key': this.config.apiKey,
294
- 'X-Tenant-Id': this.config.tenantId,
295
- },
296
- body,
297
- });
298
- if (res.ok) {
299
- await this.reportPartialFailures(res);
300
- return;
301
- }
302
- if (!isRetryableStatus(res.status)) {
303
- const { details } = await readErrorMessages(res);
304
- this.onError(new Error(`MQTT metering batch rejected with HTTP ${res.status}${details ? ` — ${details}` : ''} (dropped ${batch.length} events, not retried)`));
305
- return;
306
- }
307
- delayMs = parseRetryAfter(res) ?? delayMs;
308
- }
309
- catch (err) {
310
- if (attempt === maxRetries) {
311
- this.onError(err);
312
- return;
313
- }
314
- }
315
- if (attempt < maxRetries)
316
- await sleep(delayMs);
317
- }
318
- this.onError(new Error(`MQTT metering flush failed after ${maxRetries} attempts (dropped ${batch.length} events)`));
476
+ /** Hold a DISCONNECT for `ms`; it is billed when the window passes unless cancelled. */
477
+ scheduleDisconnect(ms, emit) {
478
+ let done = false;
479
+ let timer;
480
+ const pending = {
481
+ fire: () => {
482
+ if (done)
483
+ return Promise.resolve();
484
+ done = true;
485
+ if (timer)
486
+ clearTimeout(timer);
487
+ this.pendingDisconnects.delete(pending);
488
+ return Promise.resolve()
489
+ .then(emit)
490
+ .catch((err) => this.reportError(new Error(`[aforo-mqtt] DISCONNECT metering failed: ${err?.message ?? err}`)));
491
+ },
492
+ cancel: () => {
493
+ done = true;
494
+ if (timer)
495
+ clearTimeout(timer);
496
+ this.pendingDisconnects.delete(pending);
497
+ },
498
+ };
499
+ timer = setTimeout(() => void pending.fire(), ms);
500
+ timer.unref?.();
501
+ this.pendingDisconnects.add(pending);
502
+ return pending;
319
503
  }
320
- /** A 202 can still carry per-event rejections: `{accepted, duplicates, failed, errors:[{index, message}]}`. */
321
- async reportPartialFailures(res) {
322
- const { failed, details } = await readErrorMessages(res);
323
- if (failed && failed > 0) {
324
- this.onError(new Error(`Aforo ingestor rejected ${failed} event(s)${details ? ` — ${details}` : ''}`));
504
+ /** Invoke onError; a hook bug must never break metering or flushing. */
505
+ reportError(err) {
506
+ try {
507
+ this.onError(err);
325
508
  }
326
- }
327
- startTimer() {
328
- if (this.flushTimer)
329
- return;
330
- this.flushTimer = setInterval(() => { void this.flush(); }, this.flushIntervalMs);
331
- // Unref so the background timer never blocks host-process exit (final flush still needs shutdown()).
332
- if (this.flushTimer && typeof this.flushTimer === 'object' && 'unref' in this.flushTimer) {
333
- this.flushTimer.unref();
509
+ catch (hookErr) {
510
+ console.debug('[aforo-mqtt] onError hook threw', hookErr);
334
511
  }
335
- if (typeof this.flushTimer.unref === 'function')
336
- this.flushTimer.unref();
337
512
  }
513
+ /** Number of events permanently dropped since this instance was created (every DropReason). */
514
+ get droppedCount() {
515
+ return this.core.droppedCount;
516
+ }
517
+ /** Number of events waiting to be sent. */
518
+ get queuedCount() {
519
+ return this.core.queued;
520
+ }
521
+ /** Send what is queued now and wait for it (one request at a time; never concurrent). */
522
+ async flush() {
523
+ return this.core.flush();
524
+ }
525
+ /**
526
+ * Stop accepting events and send what is queued, for at most
527
+ * `shutdownTimeoutMs`. Events still queued then are dropped with reason
528
+ * 'shutdown' (counted, passed to onDrop). Call it from your SIGTERM handler.
529
+ */
338
530
  async shutdown() {
339
- if (this.flushTimer) {
340
- clearInterval(this.flushTimer);
341
- this.flushTimer = null;
342
- }
343
- await this.flush();
531
+ // A DISCONNECT still inside its reconnect window is billed now, not lost.
532
+ await Promise.all(Array.from(this.pendingDisconnects, (p) => p.fire()));
533
+ await this.core.shutdown();
534
+ }
535
+ toJSON() {
536
+ return { tenantId: this.config.tenantId, productId: this.config.productId, queued: this.queuedCount, dropped: this.droppedCount };
537
+ }
538
+ [Symbol.for('nodejs.util.inspect.custom')]() {
539
+ return `AforoMqttBilling { tenantId: '${this.config.tenantId}', productId: '${this.config.productId}', queued: ${this.queuedCount}, dropped: ${this.droppedCount} }`;
344
540
  }
345
541
  }
346
542
  exports.AforoMqttBilling = AforoMqttBilling;
@@ -355,6 +551,51 @@ function estimateBytes(data) {
355
551
  return data.byteLength;
356
552
  return 0;
357
553
  }
554
+ /** Statuses the Aforo ingestor accepts (contract/ingest-contract.json, max 20 chars). */
555
+ const CANONICAL_EXECUTION_STATUSES = new Set([
556
+ 'SUCCESS', 'PARTIAL', 'TIMEOUT', 'ERROR', 'VALIDATION_FAILED', 'FAILED',
557
+ 'FAILURE', 'CANCELLED', 'PENDING', 'BLOCKED', 'HITL_REQUIRED',
558
+ ]);
559
+ /**
560
+ * Check a caller-supplied status. `{ status }` when canonical; `{ problem }`
561
+ * when it has to be left off the event (an unknown value would make the
562
+ * ingestor reject the event; a Promise from an async resolver can't be
563
+ * awaited on this path); `{}` when blank or absent.
564
+ */
565
+ function checkExecutionStatus(value) {
566
+ if (value !== null && typeof value === 'object' && typeof value.then === 'function') {
567
+ // Swallow a later rejection so it doesn't surface as an unhandled rejection.
568
+ value.then(undefined, () => { });
569
+ return { problem: 'executionStatus resolver returned a Promise — resolvers must be synchronous; field omitted' };
570
+ }
571
+ const status = normalizeExecutionStatus(value);
572
+ if (!status)
573
+ return {};
574
+ if (!CANONICAL_EXECUTION_STATUSES.has(status)) {
575
+ return {
576
+ problem: `unknown executionStatus "${status.slice(0, 40)}" — field omitted. ` +
577
+ `Expected one of: ${[...CANONICAL_EXECUTION_STATUSES].join(', ')}`,
578
+ };
579
+ }
580
+ return { status };
581
+ }
582
+ /** Trim + upper-case; blank/absent → undefined (key omitted from the wire body). */
583
+ function normalizeExecutionStatus(value) {
584
+ if (typeof value !== 'string')
585
+ return undefined;
586
+ const trimmed = value.trim();
587
+ return trimmed ? trimmed.toUpperCase() : undefined;
588
+ }
589
+ function resolveExecutionStatus(option, event) {
590
+ if (typeof option !== 'function')
591
+ return option;
592
+ try {
593
+ return option(event);
594
+ }
595
+ catch {
596
+ return undefined; // a resolver bug must never break metering
597
+ }
598
+ }
358
599
  /** Trim + uppercase a product type; blank/non-string → undefined (unknown values pass through). */
359
600
  function normalizeProductType(value) {
360
601
  if (typeof value !== 'string')
@@ -362,41 +603,63 @@ function normalizeProductType(value) {
362
603
  const trimmed = value.trim();
363
604
  return trimmed ? trimmed.toUpperCase() : undefined;
364
605
  }
365
- /** Network errors, 408, 429 and 5xx are transient; every other 4xx (400/401/403/422...) is not. */
366
- function isRetryableStatus(status) {
367
- return status === 408 || status === 429 || status >= 500;
606
+ /** The offending value for a WARN line, cut to 80 chars. */
607
+ function preview(value) {
608
+ const text = typeof value === 'string' ? value : String(value);
609
+ return JSON.stringify(text.length > 80 ? `${text.slice(0, 80)}…` : text);
368
610
  }
369
- /** Retry-After (delta-seconds or HTTP date) in ms, capped at MAX_RETRY_AFTER_MS. */
370
- function parseRetryAfter(res) {
371
- const raw = res?.headers?.get?.('retry-after');
372
- if (!raw)
611
+ /** Limits mirror the ingestor's IngestUsageEventRequest; identity values are never truncated. */
612
+ function tooLong(field, value, max) {
613
+ if (typeof value !== 'string' || value.length <= max)
373
614
  return undefined;
374
- const secs = Number(raw);
375
- if (Number.isFinite(secs) && secs >= 0)
376
- return Math.min(secs * 1000, MAX_RETRY_AFTER_MS);
377
- const date = Date.parse(raw);
378
- if (!Number.isNaN(date))
379
- return Math.min(Math.max(date - Date.now(), 0), MAX_RETRY_AFTER_MS);
380
- return undefined;
615
+ return { field, problem: `${field} is ${value.length} chars, limit ${max}: ${preview(value)}` };
381
616
  }
382
- /** Reads the ingestor's `errors[].message` entries (first 5) from a response body, if any. */
383
- async function readErrorMessages(res) {
384
- if (typeof res?.json !== 'function')
385
- return { details: '' };
386
- try {
387
- const body = await res.json();
388
- const details = Array.isArray(body?.errors)
389
- ? body.errors.slice(0, 5).map((e) => `#${e?.index}: ${e?.message}`).join('; ')
390
- : typeof body?.message === 'string' ? body.message : '';
391
- return { failed: typeof body?.failed === 'number' ? body.failed : undefined, details };
392
- }
393
- catch {
394
- return { details: '' }; // Non-JSON or empty body — nothing to report.
395
- }
617
+ function requiredText(field, value) {
618
+ if (typeof value === 'string' && value.trim())
619
+ return undefined;
620
+ return { field, problem: `${field} is required and must not be blank: ${preview(value)}` };
621
+ }
622
+ /**
623
+ * Cut `value` to at most `max` UTF-16 code units — how the ingestor counts
624
+ * (`String.length()` in Java) — without leaving half a surrogate pair.
625
+ */
626
+ function truncateToLimit(value, max) {
627
+ if (value.length <= max)
628
+ return value;
629
+ let end = Math.max(0, max);
630
+ const last = end > 0 ? value.charCodeAt(end - 1) : 0;
631
+ // A high surrogate at the cut means its low half was cut off: drop it too.
632
+ if (last >= 0xd800 && last <= 0xdbff)
633
+ end -= 1;
634
+ return value.slice(0, end);
635
+ }
636
+ /**
637
+ * Build `head + component + tail`, where `component` is text taken from the
638
+ * incoming request and may be any length. Returned unchanged when it fits the
639
+ * ingestor's idempotencyKey limit; otherwise the component is replaced by the
640
+ * SHA-256 hex digest of its full, untruncated value (and, if the fixed parts
641
+ * alone are too long, `head + component` is). The key is never cut, so the
642
+ * unique tail always survives and two different components never share a key
643
+ * prefix by accident.
644
+ */
645
+ function boundedIdempotencyKey(head, component, tail) {
646
+ const key = `${head}${component}${tail}`;
647
+ if (key.length <= MAX_IDEMPOTENCY_KEY)
648
+ return key;
649
+ const hashed = `${head}${sha256Hex(component)}${tail}`;
650
+ if (hashed.length <= MAX_IDEMPOTENCY_KEY)
651
+ return hashed;
652
+ return `${sha256Hex(head + component)}${tail}`;
653
+ }
654
+ function sha256Hex(text) {
655
+ return (0, node_crypto_1.createHash)('sha256').update(text, 'utf8').digest('hex');
656
+ }
657
+ function textOrUndefined(value) {
658
+ if (value === undefined || value === null)
659
+ return undefined;
660
+ const text = String(value).trim();
661
+ return text ? text : undefined;
396
662
  }
397
663
  function randomSuffix() {
398
664
  return Math.random().toString(36).slice(2, 10);
399
665
  }
400
- function sleep(ms) {
401
- return new Promise((r) => setTimeout(r, ms));
402
- }