@aforoai/mqtt-metering 1.0.0 → 1.2.2

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Meter MQTT traffic into Aforo two ways: hook an Aedes broker you operate to meter every PUBLISH/SUBSCRIBE/CONNECT/DISCONNECT, or wrap an `mqtt.js` client to meter what it publishes and receives against a third-party broker (AWS IoT, HiveMQ Cloud, EMQ X Cloud).
4
4
 
5
- **Version:** 1.0.0 · Apache-2.0 · [Changelog](CHANGELOG.md) · [User guide](USER_GUIDE.md)
5
+ **Version:** 1.2.2 · Apache-2.0 · [Changelog](CHANGELOG.md) · [User guide](USER_GUIDE.md)
6
6
 
7
7
  | Mode | When to use | Entry point |
8
8
  |---|---|---|
@@ -19,7 +19,7 @@ npm i @aforoai/mqtt-metering aedes # broker mode
19
19
  npm i @aforoai/mqtt-metering mqtt # client mode
20
20
  ```
21
21
 
22
- > **Not yet on the public npm registry — install from source for now.** `aedes` and `mqtt` are **optional** peer dependencies — install only the one your mode needs.
22
+ > **Install `1.2.2` or later. `1.0.0` on npm was built from an older copy of this code and lacks the fixes listed in the changelog.** If `1.2.2` is not on npm yet, install from source with the steps below. `aedes` and `mqtt` are **optional** peer dependencies — install only the one your mode needs.
23
23
 
24
24
  ```bash
25
25
  # from the SDKs repo root
@@ -89,7 +89,8 @@ Each event uses `metricName: "mqtt_broker.<event>"` (`mqtt_broker.publish`, `mqt
89
89
  | `emitDeliverEvents` | `boolean` | `false` | Emit a `DELIVER` event per fan-out delivery. Off → `DELIVER` events are dropped (both modes). |
90
90
  | `flushCount` | `number` | `200` | Buffered events that trigger an immediate flush. Highest default of the SDKs — MQTT is very high-volume. |
91
91
  | `flushIntervalMs` | `number` | `2000` | Max ms before a partial batch is flushed. |
92
- | `onError` | `(error: Error) => void` | logs to `console.error` | Called when a batch is dropped: after 3 attempts on network errors / 408 / 429 (honouring `Retry-After`) / 5xx, immediately on any other 4xx (not retried), and when a 202 reports per-event failures (`errors[].message`). |
92
+ | `onError` | `(error: Error) => void` | logs to `console.error` | Called once per delivery problem: a batch dropped after 3 attempts (network errors / 408 / 429 honouring `Retry-After` / 5xx), a batch refused with any other 4xx (not retried; the message includes the ingestor's `errors[].message`), per-event failures reported in a 2xx response, and an unusable `executionStatus`. Exceptions it throws are swallowed. |
93
+ | `onDrop` | `(events, reason) => void` | none | Receives events that were permanently dropped, with reason `invalid`, `rejected` or `retry_exhausted`. See [Dropped events](#dropped-events). |
93
94
 
94
95
  Mode-specific options:
95
96
 
@@ -103,7 +104,42 @@ Mode-specific options:
103
104
 
104
105
  Every event carries `mqttQos` (0/1/2) and `mqttRetained` — use them in Aforo rate-plan filter conditions to price QoS ≥ 1 or retained messages separately.
105
106
 
106
- Exported symbols: `AforoMqttBilling` (with `wrapAedesBroker` / `wrapMqttClient` / `shutdown`) and the `AforoMqttConfig` / `AedesBrokerOptions` / `MqttClientOptions` types.
107
+ Exported symbols: `AforoMqttBilling` (with `wrapAedesBroker` / `wrapMqttClient` / `shutdown` / `droppedCount`), `mqttConnectTopic` / `mqttDisconnectTopic`, `DEFAULT_PRODUCT_TYPE`, and the `AforoMqttConfig` / `AedesBrokerOptions` / `MqttClientOptions` / `MqttUsageEvent` / `DropReason` types.
108
+
109
+ ## Execution status
110
+
111
+ Outcome-based pricing bills each event at the weight set for its `executionStatus`; events without one bill at full price. The SDK trims and upper-cases the value and leaves the key out of the event when it is unset or blank. Accepted values: `SUCCESS`, `PARTIAL`, `TIMEOUT`, `ERROR`, `VALIDATION_FAILED`, `FAILED`, `FAILURE`, `CANCELLED`, `PENDING`, `BLOCKED`, `HITL_REQUIRED`. Any other value (or a Promise returned by an async resolver) is reported through `onError` and left off the event — the event itself is still sent — because the server would reject the event. Resolvers must be synchronous.
112
+
113
+ The SDK never sets `executionStatus` on its own: the broker hook sees no success or failure signal, and client publishes are metered before the broker acknowledges them. Pass it yourself, as a string or as a function called for each event:
114
+
115
+ ```typescript
116
+ billing.wrapAedesBroker(broker, {
117
+ resolveCustomerId: (clientId) => customerIdLookup(clientId),
118
+ // (event, clientId) => status | undefined
119
+ executionStatus: (event) => (event.mqttTopic.startsWith('alerts/') ? 'SUCCESS' : undefined),
120
+ });
121
+
122
+ billing.wrapMqttClient(client, { customerId: 'cust_42', executionStatus: 'SUCCESS' });
123
+ ```
124
+
125
+ ## Dropped events
126
+
127
+ The SDK does not throw into your broker or client handlers for event content. An event that cannot be delivered is counted in `billing.droppedCount`, logged with `console.warn`, and passed to the optional `onDrop(events, reason)` hook. Events handed to the hook keep their `idempotencyKey`, so storing them and re-submitting later is dedup-safe. Exceptions thrown by the hook are swallowed.
128
+
129
+ | Reason | When |
130
+ |---|---|
131
+ | `invalid` | The event failed a client-side check and was never buffered or sent: `customerId` longer than 64 characters, a missing or whitespace-only `mqttTopic`, or `productType` longer than 20. These are never truncated. A topic longer than 500 characters, or a client id longer than 128, is not a drop: it is cut to the limit and the event is sent (one warning per label). The warning names the field, the limit and the value (first 80 characters); it is logged for the first occurrence per field and for every 1000th after that. |
132
+ | `rejected` | The ingestor refused the batch with a 4xx other than 408 / 429 (not retried), or named individual events in the `errors[]` of a 2xx response — then only those events are dropped. When a 2xx reports `failed > 0` without a usable `index`, the count is added to `droppedCount` and `onDrop` is not called. |
133
+ | `retry_exhausted` | Network errors, 408, 429 or 5xx persisted through 3 attempts (1s / 2s / 4s backoff; `Retry-After` honoured up to 30 s). |
134
+
135
+ A call with no resolvable customer id is not billable; it is skipped and is not counted as a drop.
136
+
137
+ ```ts
138
+ const billing = new AforoMqttBilling({
139
+ // …
140
+ onDrop: (events, reason) => deadLetterQueue.push({ reason, events }),
141
+ });
142
+ ```
107
143
 
108
144
  ## Walk me through it
109
145
 
package/dist/index.d.ts CHANGED
@@ -47,12 +47,45 @@ export interface AforoMqttConfig {
47
47
  emitDeliverEvents?: boolean;
48
48
  /** Callback for terminal flush failures. */
49
49
  onError?: (error: Error) => void;
50
+ /**
51
+ * Opt-in hook receiving events that were permanently dropped (retry
52
+ * exhaustion, a rejection by the ingestor, or a failed client-side check —
53
+ * see DropReason). Events keep their idempotency keys,
54
+ * so persisting and re-submitting them after recovery is dedup-safe.
55
+ * Exceptions thrown by the hook are swallowed. Default: none (drops are
56
+ * still counted in droppedCount and WARN-logged).
57
+ */
58
+ onDrop?: (events: MqttUsageEvent[], reason: DropReason) => void;
50
59
  }
60
+ /**
61
+ * Why events were permanently dropped. `retry_exhausted`: the ingestor stayed
62
+ * unreachable / kept answering 408, 429 or 5xx. `rejected`: the ingestor
63
+ * refused the batch (non-retryable 4xx) or individual events of an accepted
64
+ * batch. `invalid`: the event failed a client-side check and was never sent.
65
+ * The buffer is unbounded (drained at flush start), so unlike the core SDK
66
+ * there is no 'overflow' reason here.
67
+ */
68
+ export type DropReason = 'retry_exhausted' | 'rejected' | 'invalid';
51
69
  export interface AedesBrokerOptions {
52
70
  /** Map an MQTT client ID to an Aforo customer ID. Required. */
53
71
  resolveCustomerId: (clientId: string, username?: string) => string | undefined | Promise<string | undefined>;
54
72
  /** Optional per-client metadata (tags, device type). */
55
73
  resolveMetadata?: (clientId: string) => Record<string, unknown> | undefined;
74
+ /**
75
+ * Optional outcome of the request (used by OUTCOME_BASED pricing, which bills
76
+ * each event at the weight set for its status). Events without a status bill
77
+ * at full price. Trimmed and upper-cased by the SDK; blank is treated as absent.
78
+ * Accepted values: SUCCESS, PARTIAL, TIMEOUT, ERROR, VALIDATION_FAILED, FAILED,
79
+ * FAILURE, CANCELLED, PENDING, BLOCKED, HITL_REQUIRED (max 20 chars). Any other
80
+ * value — or a Promise from an async resolver — is reported through `onError`
81
+ * and left off the event (the event is still sent), because the server would
82
+ * reject the event. Resolvers must be synchronous.
83
+ *
84
+ * MQTT packets carry no success/failure signal the broker hook sees, so the
85
+ * SDK never derives one: pass a fixed string, or a function called for each
86
+ * event (with the MQTT client ID) that returns the status or undefined.
87
+ */
88
+ executionStatus?: string | ((event: MqttUsageEvent, clientId: string) => string | undefined);
56
89
  /** Product type for events from this broker. Default: the client-level `productType`. */
57
90
  productType?: string;
58
91
  }
@@ -61,6 +94,21 @@ export interface MqttClientOptions {
61
94
  customerId: string;
62
95
  /** Fixed client identifier (defaults to mqtt.js connection options.clientId). */
63
96
  clientId?: string;
97
+ /**
98
+ * Optional outcome of the request (used by OUTCOME_BASED pricing, which bills
99
+ * each event at the weight set for its status). Events without a status bill
100
+ * at full price. Trimmed and upper-cased by the SDK; blank is treated as absent.
101
+ * Accepted values: SUCCESS, PARTIAL, TIMEOUT, ERROR, VALIDATION_FAILED, FAILED,
102
+ * FAILURE, CANCELLED, PENDING, BLOCKED, HITL_REQUIRED (max 20 chars). Any other
103
+ * value — or a Promise from an async resolver — is reported through `onError`
104
+ * and left off the event (the event is still sent), because the server would
105
+ * reject the event. Resolvers must be synchronous.
106
+ *
107
+ * Never derived by the SDK (publishes are metered before the broker
108
+ * acknowledges them): pass a fixed string, or a function called for each
109
+ * event that returns the status or undefined.
110
+ */
111
+ executionStatus?: string | ((event: MqttUsageEvent) => string | undefined);
64
112
  /** Product type for events from this client. Default: the client-level `productType`. */
65
113
  productType?: string;
66
114
  }
@@ -75,6 +123,24 @@ export declare const DEFAULT_PRODUCT_TYPE = "MQTT_BROKER";
75
123
  */
76
124
  export declare const mqttConnectTopic: (clientId: string) => string;
77
125
  export declare const mqttDisconnectTopic: (clientId: string) => string;
126
+ type MqttEventType = 'PUBLISH' | 'DELIVER' | 'SUBSCRIBE' | 'UNSUBSCRIBE' | 'CONNECT' | 'DISCONNECT';
127
+ export interface MqttUsageEvent {
128
+ customerId: string;
129
+ metricName: string;
130
+ quantity: number;
131
+ occurredAt: string;
132
+ idempotencyKey: string;
133
+ productType: string;
134
+ mqttTopic: string;
135
+ mqttQos: number;
136
+ mqttRetained: boolean;
137
+ mqttEventType: MqttEventType;
138
+ mqttClientId: string;
139
+ dataBytes: number;
140
+ metadata?: Record<string, unknown>;
141
+ /** Normalized (trimmed, upper-cased) execution status; omitted when not set. */
142
+ executionStatus?: string;
143
+ }
78
144
  /** Minimal Aedes broker surface — matches the `aedes` package. */
79
145
  interface MinimalAedes {
80
146
  on(event: 'publish', fn: (packet: any, client: any) => void): void;
@@ -100,17 +166,62 @@ export declare class AforoMqttBilling {
100
166
  private readonly flushIntervalMs;
101
167
  private readonly emitDeliverEvents;
102
168
  private readonly onError;
169
+ private readonly onDrop?;
103
170
  private buffer;
104
171
  private flushTimer;
172
+ private dropped;
173
+ /** Invalid-event count per field, for WARN throttling. */
174
+ private readonly invalidSeen;
175
+ /** Request-derived labels already reported as truncated (one WARN per label). */
176
+ private readonly truncationWarned;
105
177
  constructor(config: AforoMqttConfig);
106
178
  wrapAedesBroker(broker: MinimalAedes, options: AedesBrokerOptions): void;
107
179
  wrapMqttClient(client: MinimalMqttClient, options: MqttClientOptions): void;
180
+ /**
181
+ * Bound an MQTT label (topic, client id) to the ingestor's limit. The event
182
+ * is still sent; one WARN per label name per client. Identity fields
183
+ * (customerId, productType) never go through here — an over-long one drops
184
+ * the event as 'invalid'.
185
+ */
186
+ private requestLabel;
187
+ /** Topic off an incoming packet, bound to the ingestor's mqttTopic limit. */
188
+ private topicLabel;
189
+ /** Client id a connecting client chose, bound to the ingestor's mqttClientId limit. */
190
+ private clientIdLabel;
108
191
  private push;
109
192
  private flush;
110
193
  private send;
111
- /** A 202 can still carry per-event rejections: `{accepted, duplicates, failed, errors:[{index, message}]}`. */
112
- private reportPartialFailures;
194
+ /**
195
+ * A 2xx can still carry per-event rejections:
196
+ * `{accepted, duplicates, failed, errors:[{index, message}]}`. Events the
197
+ * response names by index are dropped with reason 'rejected'; a `failed`
198
+ * count the response does not attribute to an index is counted only.
199
+ */
200
+ private handlePartialFailures;
201
+ /** Invoke onError; a hook bug must never break metering or flushing. */
202
+ private reportError;
203
+ /** Number of events permanently dropped since this instance was created. */
204
+ get droppedCount(): number;
205
+ /**
206
+ * Account for permanently lost events: bump the counter, WARN-log, and
207
+ * invoke the opt-in onDrop hook. The buffer is drained at flush start, so
208
+ * drops here are bounded by flush cadence — no log throttle needed.
209
+ */
210
+ private recordDrop;
211
+ /**
212
+ * Account for an event that failed a client-side check: never buffered,
213
+ * never sent. Runs on the hot path, so the WARN is throttled per field
214
+ * (first occurrence, then every 1000th).
215
+ */
216
+ private recordInvalid;
217
+ private notifyDrop;
113
218
  private startTimer;
219
+ /** Flush any buffered events and stop the background timer. Call before process exit. */
114
220
  shutdown(): Promise<void>;
115
221
  }
222
+ /**
223
+ * Cut `value` to at most `max` UTF-16 code units — how the ingestor counts
224
+ * (`String.length()` in Java) — without leaving half a surrogate pair.
225
+ */
226
+ export declare function truncateToLimit(value: string, max: number): string;
116
227
  export {};
package/dist/index.js CHANGED
@@ -32,7 +32,9 @@
32
32
  */
33
33
  Object.defineProperty(exports, "__esModule", { value: true });
34
34
  exports.AforoMqttBilling = exports.mqttDisconnectTopic = exports.mqttConnectTopic = exports.DEFAULT_PRODUCT_TYPE = void 0;
35
- const SDK_VERSION = '1.0.0';
35
+ exports.truncateToLimit = truncateToLimit;
36
+ const node_crypto_1 = require("node:crypto");
37
+ const SDK_VERSION = '1.2.2';
36
38
  /** Default top-level `productType` for this SDK. */
37
39
  exports.DEFAULT_PRODUCT_TYPE = 'MQTT_BROKER';
38
40
  /** Upper bound on a server-requested Retry-After wait. */
@@ -53,6 +55,9 @@ const MAX_BATCH_EVENTS = 1000;
53
55
  /** usage-ingestor limits (IngestUsageEventRequest). */
54
56
  const MAX_CUSTOMER_ID = 64;
55
57
  const MAX_IDEMPOTENCY_KEY = 255;
58
+ const MAX_PRODUCT_TYPE = 20;
59
+ const MAX_MQTT_TOPIC = 500;
60
+ const MAX_MQTT_CLIENT_ID = 128;
56
61
  class AforoMqttBilling {
57
62
  config;
58
63
  productType;
@@ -60,8 +65,14 @@ class AforoMqttBilling {
60
65
  flushIntervalMs;
61
66
  emitDeliverEvents;
62
67
  onError;
68
+ onDrop;
63
69
  buffer = [];
64
70
  flushTimer = null;
71
+ dropped = 0;
72
+ /** Invalid-event count per field, for WARN throttling. */
73
+ invalidSeen = new Map();
74
+ /** Request-derived labels already reported as truncated (one WARN per label). */
75
+ truncationWarned = new Set();
65
76
  constructor(config) {
66
77
  this.config = {
67
78
  tenantId: config.tenantId,
@@ -74,11 +85,14 @@ class AforoMqttBilling {
74
85
  this.flushIntervalMs = config.flushIntervalMs ?? 2000;
75
86
  this.emitDeliverEvents = config.emitDeliverEvents ?? false;
76
87
  this.onError = config.onError ?? ((err) => console.error('[aforo-mqtt]', err.message));
88
+ this.onDrop = config.onDrop;
77
89
  this.startTimer();
78
90
  }
79
91
  // ── Broker-side integration (Aedes) ─────────────────────────────
80
92
  wrapAedesBroker(broker, options) {
81
93
  const resolveCustomer = async (clientId, username) => Promise.resolve(options.resolveCustomerId(clientId, username));
94
+ const status = options.executionStatus;
95
+ const brokerStatus = (clientId) => typeof status === 'function' ? (event) => status(event, clientId) : status;
82
96
  broker.on('publish', async (packet, client) => {
83
97
  if (!client)
84
98
  return; // broker-originated publishes skip billing
@@ -90,15 +104,15 @@ class AforoMqttBilling {
90
104
  const bytes = estimateBytes(payload);
91
105
  this.push({
92
106
  customerId,
93
- mqttTopic: packet.topic,
107
+ mqttTopic: this.topicLabel(packet.topic),
94
108
  mqttQos: packet.qos ?? 0,
95
109
  mqttRetained: !!packet.retain,
96
110
  mqttEventType: 'PUBLISH',
97
- mqttClientId: clientId,
111
+ mqttClientId: this.clientIdLabel(clientId),
98
112
  productType: options.productType,
99
113
  dataBytes: bytes,
100
114
  metadata: options.resolveMetadata?.(clientId),
101
- });
115
+ }, brokerStatus(clientId), clientId);
102
116
  });
103
117
  broker.on('subscribe', async (subs, client) => {
104
118
  const clientId = client?.id;
@@ -111,15 +125,15 @@ class AforoMqttBilling {
111
125
  for (const s of arr) {
112
126
  this.push({
113
127
  customerId,
114
- mqttTopic: s.topic,
128
+ mqttTopic: this.topicLabel(s.topic),
115
129
  mqttQos: s.qos ?? 0,
116
130
  mqttRetained: false,
117
131
  mqttEventType: 'SUBSCRIBE',
118
- mqttClientId: clientId,
132
+ mqttClientId: this.clientIdLabel(clientId),
119
133
  productType: options.productType,
120
134
  dataBytes: 0,
121
135
  metadata: options.resolveMetadata?.(clientId),
122
- });
136
+ }, brokerStatus(clientId), clientId);
123
137
  }
124
138
  });
125
139
  broker.on('unsubscribe', async (unsubs, client) => {
@@ -133,15 +147,15 @@ class AforoMqttBilling {
133
147
  for (const topic of arr) {
134
148
  this.push({
135
149
  customerId,
136
- mqttTopic: typeof topic === 'string' ? topic : topic?.topic ?? '',
150
+ mqttTopic: this.topicLabel(typeof topic === 'string' ? topic : topic?.topic ?? ''),
137
151
  mqttQos: 0,
138
152
  mqttRetained: false,
139
153
  mqttEventType: 'UNSUBSCRIBE',
140
- mqttClientId: clientId,
154
+ mqttClientId: this.clientIdLabel(clientId),
141
155
  productType: options.productType,
142
156
  dataBytes: 0,
143
157
  metadata: options.resolveMetadata?.(clientId),
144
- });
158
+ }, brokerStatus(clientId), clientId);
145
159
  }
146
160
  });
147
161
  broker.on('client', async (client) => {
@@ -151,15 +165,17 @@ class AforoMqttBilling {
151
165
  return;
152
166
  this.push({
153
167
  customerId,
154
- mqttTopic: (0, exports.mqttConnectTopic)(clientId), // CONNECT has no topic; mqttTopic is required
168
+ // CONNECT has no topic; mqttTopic is required. Built from the bounded
169
+ // client id so the stand-in topic keeps its `/connected` suffix.
170
+ mqttTopic: (0, exports.mqttConnectTopic)(this.clientIdLabel(clientId)),
155
171
  mqttQos: 0,
156
172
  mqttRetained: false,
157
173
  mqttEventType: 'CONNECT',
158
- mqttClientId: clientId,
174
+ mqttClientId: this.clientIdLabel(clientId),
159
175
  productType: options.productType,
160
176
  dataBytes: 0,
161
177
  metadata: options.resolveMetadata?.(clientId),
162
- });
178
+ }, brokerStatus(clientId), clientId);
163
179
  });
164
180
  broker.on('clientDisconnect', async (client) => {
165
181
  const clientId = client.id;
@@ -168,15 +184,15 @@ class AforoMqttBilling {
168
184
  return;
169
185
  this.push({
170
186
  customerId,
171
- mqttTopic: (0, exports.mqttDisconnectTopic)(clientId),
187
+ mqttTopic: (0, exports.mqttDisconnectTopic)(this.clientIdLabel(clientId)),
172
188
  mqttQos: 0,
173
189
  mqttRetained: false,
174
190
  mqttEventType: 'DISCONNECT',
175
- mqttClientId: clientId,
191
+ mqttClientId: this.clientIdLabel(clientId),
176
192
  productType: options.productType,
177
193
  dataBytes: 0,
178
194
  metadata: options.resolveMetadata?.(clientId),
179
- });
195
+ }, brokerStatus(clientId), clientId);
180
196
  });
181
197
  }
182
198
  // ── Client-side integration (mqtt.js) ───────────────────────────
@@ -185,79 +201,100 @@ class AforoMqttBilling {
185
201
  client.on('connect', () => {
186
202
  this.push({
187
203
  customerId: options.customerId,
188
- mqttTopic: (0, exports.mqttConnectTopic)(clientId),
204
+ mqttTopic: (0, exports.mqttConnectTopic)(this.clientIdLabel(clientId)),
189
205
  mqttQos: 0,
190
206
  mqttRetained: false,
191
207
  mqttEventType: 'CONNECT',
192
- mqttClientId: clientId,
208
+ mqttClientId: this.clientIdLabel(clientId),
193
209
  productType: options.productType,
194
210
  dataBytes: 0,
195
- });
211
+ }, options.executionStatus, clientId);
196
212
  });
197
213
  client.on('close', () => {
198
214
  this.push({
199
215
  customerId: options.customerId,
200
- mqttTopic: (0, exports.mqttDisconnectTopic)(clientId),
216
+ mqttTopic: (0, exports.mqttDisconnectTopic)(this.clientIdLabel(clientId)),
201
217
  mqttQos: 0,
202
218
  mqttRetained: false,
203
219
  mqttEventType: 'DISCONNECT',
204
- mqttClientId: clientId,
220
+ mqttClientId: this.clientIdLabel(clientId),
205
221
  productType: options.productType,
206
222
  dataBytes: 0,
207
- });
223
+ }, options.executionStatus, clientId);
208
224
  });
209
225
  client.on('message', (topic, payload, packet) => {
210
226
  this.push({
211
227
  customerId: options.customerId,
212
- mqttTopic: topic,
228
+ mqttTopic: this.topicLabel(topic),
213
229
  mqttQos: packet?.qos ?? 0,
214
230
  mqttRetained: !!packet?.retain,
215
231
  mqttEventType: 'DELIVER',
216
- mqttClientId: clientId,
232
+ mqttClientId: this.clientIdLabel(clientId),
217
233
  productType: options.productType,
218
234
  dataBytes: estimateBytes(payload),
219
- });
235
+ }, options.executionStatus, clientId);
220
236
  });
221
237
  const origPublish = client.publish.bind(client);
222
238
  client.publish = (topic, message, opts, cb) => {
223
239
  this.push({
224
240
  customerId: options.customerId,
225
- mqttTopic: topic,
241
+ mqttTopic: this.topicLabel(topic),
226
242
  mqttQos: opts?.qos ?? 0,
227
243
  mqttRetained: !!opts?.retain,
228
244
  mqttEventType: 'PUBLISH',
229
- mqttClientId: clientId,
245
+ mqttClientId: this.clientIdLabel(clientId),
230
246
  productType: options.productType,
231
247
  dataBytes: estimateBytes(message),
232
- });
248
+ }, options.executionStatus, clientId);
233
249
  return origPublish(topic, message, opts, cb);
234
250
  };
235
251
  }
236
252
  // ── Event pipeline ──────────────────────────────────────────────
237
- push(partial) {
253
+ /**
254
+ * Bound an MQTT label (topic, client id) to the ingestor's limit. The event
255
+ * is still sent; one WARN per label name per client. Identity fields
256
+ * (customerId, productType) never go through here — an over-long one drops
257
+ * the event as 'invalid'.
258
+ */
259
+ requestLabel(field, value, max) {
260
+ if (typeof value !== 'string' || value.length <= max)
261
+ return value;
262
+ if (!this.truncationWarned.has(field)) {
263
+ this.truncationWarned.add(field);
264
+ console.warn(`[aforo-mqtt] ${field} was longer than the ingestor's limit and was ` +
265
+ `truncated to ${max} characters; the event is still sent. Logged once per label.`);
266
+ }
267
+ return truncateToLimit(value, max);
268
+ }
269
+ /** Topic off an incoming packet, bound to the ingestor's mqttTopic limit. */
270
+ topicLabel(topic) {
271
+ return typeof topic === 'string' ? this.requestLabel('mqttTopic', topic, MAX_MQTT_TOPIC) : topic;
272
+ }
273
+ /** Client id a connecting client chose, bound to the ingestor's mqttClientId limit. */
274
+ clientIdLabel(clientId) {
275
+ return typeof clientId === 'string' ? this.requestLabel('mqttClientId', clientId, MAX_MQTT_CLIENT_ID) : clientId;
276
+ }
277
+ push(partial, executionStatus,
278
+ /** Untruncated client id for the idempotency key, when `partial.mqttClientId` was cut to the limit. */
279
+ keyClientId) {
238
280
  if (!this.emitDeliverEvents && partial.mqttEventType === 'DELIVER')
239
281
  return;
240
- if (!partial.customerId || !partial.customerId.trim())
282
+ // No customer resolved — not billable, not a drop.
283
+ if (typeof partial.customerId !== 'string' || !partial.customerId.trim())
241
284
  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
285
  const now = new Date();
250
- const mqttClientId = partial.mqttClientId.slice(0, 128);
286
+ const mqttClientId = typeof partial.mqttClientId === 'string' ? partial.mqttClientId : String(partial.mqttClientId ?? '');
251
287
  const event = {
252
288
  ...partial,
253
- mqttTopic: mqttTopic.slice(0, 500),
254
289
  mqttQos: partial.mqttQos === 1 || partial.mqttQos === 2 ? partial.mqttQos : 0,
255
290
  mqttClientId,
256
291
  metricName: `mqtt_broker.${partial.mqttEventType.toLowerCase()}`,
257
292
  quantity: 1,
258
293
  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),
294
+ // Minted once, here, from the untruncated client id; the topic is left
295
+ // out. When the client id makes the key longer than the ingestor allows,
296
+ // it is replaced by its SHA-256 digest; the key itself is never cut.
297
+ idempotencyKey: boundedIdempotencyKey(`mqtt:${this.config.tenantId}:`, typeof keyClientId === 'string' ? keyClientId : mqttClientId, `:${partial.mqttEventType}:${now.getTime()}:${randomSuffix()}`),
261
298
  productType: normalizeProductType(partial.productType) ?? this.productType,
262
299
  metadata: {
263
300
  ...(partial.metadata ?? {}),
@@ -265,6 +302,24 @@ class AforoMqttBilling {
265
302
  productId: this.config.productId,
266
303
  },
267
304
  };
305
+ const checked = checkExecutionStatus(resolveExecutionStatus(executionStatus, event));
306
+ if (checked.problem)
307
+ this.reportError(new Error(`[aforo-mqtt] ${checked.problem}`));
308
+ if (checked.status)
309
+ event.executionStatus = checked.status;
310
+ // An event the ingestor would reject is not sent (one bad event fails its
311
+ // whole batch): it is dropped here with reason 'invalid', never thrown
312
+ // into the broker / client handlers. mqttTopic is required on every
313
+ // MQTT_BROKER event.
314
+ const invalid = tooLong('customerId', event.customerId, MAX_CUSTOMER_ID) ??
315
+ requiredText('mqttTopic', event.mqttTopic) ??
316
+ tooLong('mqttTopic', event.mqttTopic, MAX_MQTT_TOPIC) ??
317
+ tooLong('mqttClientId', event.mqttClientId, MAX_MQTT_CLIENT_ID) ??
318
+ tooLong('productType', event.productType, MAX_PRODUCT_TYPE);
319
+ if (invalid) {
320
+ this.recordInvalid(event, invalid);
321
+ return;
322
+ }
268
323
  this.buffer.push(event);
269
324
  if (this.buffer.length >= this.flushCount) {
270
325
  void this.flush();
@@ -284,9 +339,11 @@ class AforoMqttBilling {
284
339
  const body = JSON.stringify({ events: batch });
285
340
  const maxRetries = 3;
286
341
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
287
- let delayMs = Math.pow(2, attempt - 1) * 1000;
342
+ let delayMs = Math.pow(2, attempt - 1) * 1000; // 1s, 2s, 4s
343
+ let res;
344
+ let networkError;
288
345
  try {
289
- const res = await fetch(this.config.ingestorUrl.replace(/\/$/, '') + '/v1/ingest/batch', {
346
+ res = await fetch(this.config.ingestorUrl.replace(/\/$/, '') + '/v1/ingest/batch', {
290
347
  method: 'POST',
291
348
  headers: {
292
349
  'Content-Type': 'application/json',
@@ -295,33 +352,101 @@ class AforoMqttBilling {
295
352
  },
296
353
  body,
297
354
  });
355
+ }
356
+ catch (err) {
357
+ networkError = err;
358
+ }
359
+ if (res) {
298
360
  if (res.ok) {
299
- await this.reportPartialFailures(res);
361
+ await this.handlePartialFailures(res, batch);
300
362
  return;
301
363
  }
302
364
  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)`));
365
+ // 4xx other than 408/429: the same body would be refused again.
366
+ const { details } = await readBatchResult(res, batch.length);
367
+ this.recordDrop(batch, 'rejected');
368
+ this.reportError(new Error(`MQTT metering batch rejected with HTTP ${res.status}${details ? ` — ${details}` : ''} (dropped ${batch.length} events, not retried)`));
305
369
  return;
306
370
  }
307
371
  delayMs = parseRetryAfter(res) ?? delayMs;
308
372
  }
309
- catch (err) {
310
- if (attempt === maxRetries) {
311
- this.onError(err);
312
- return;
313
- }
373
+ else if (attempt === maxRetries) {
374
+ this.recordDrop(batch, 'retry_exhausted');
375
+ this.reportError(networkError ?? new Error('MQTT metering request failed'));
376
+ return;
314
377
  }
315
378
  if (attempt < maxRetries)
316
379
  await sleep(delayMs);
317
380
  }
318
- this.onError(new Error(`MQTT metering flush failed after ${maxRetries} attempts (dropped ${batch.length} events)`));
381
+ // Not re-queued: dropping avoids unbounded memory growth.
382
+ this.recordDrop(batch, 'retry_exhausted');
383
+ this.reportError(new Error(`MQTT metering flush failed after ${maxRetries} attempts (dropped ${batch.length} events)`));
384
+ }
385
+ /**
386
+ * A 2xx can still carry per-event rejections:
387
+ * `{accepted, duplicates, failed, errors:[{index, message}]}`. Events the
388
+ * response names by index are dropped with reason 'rejected'; a `failed`
389
+ * count the response does not attribute to an index is counted only.
390
+ */
391
+ async handlePartialFailures(res, batch) {
392
+ const { failed, details, indexes } = await readBatchResult(res, batch.length);
393
+ if (failed <= 0)
394
+ return;
395
+ if (indexes.length > 0)
396
+ this.recordDrop(indexes.map((i) => batch[i]), 'rejected');
397
+ const unattributed = Math.min(failed, batch.length) - indexes.length;
398
+ if (unattributed > 0) {
399
+ this.dropped += unattributed;
400
+ console.warn(`[aforo-mqtt] Dropped ${unattributed} event(s) — rejected, not identified by the ingestor (${this.dropped} total dropped).`);
401
+ }
402
+ this.reportError(new Error(`Aforo ingestor rejected ${failed} event(s)${details ? ` — ${details}` : ''}`));
403
+ }
404
+ /** Invoke onError; a hook bug must never break metering or flushing. */
405
+ reportError(err) {
406
+ try {
407
+ this.onError(err);
408
+ }
409
+ catch {
410
+ // ignore
411
+ }
412
+ }
413
+ /** Number of events permanently dropped since this instance was created. */
414
+ get droppedCount() {
415
+ return this.dropped;
416
+ }
417
+ /**
418
+ * Account for permanently lost events: bump the counter, WARN-log, and
419
+ * invoke the opt-in onDrop hook. The buffer is drained at flush start, so
420
+ * drops here are bounded by flush cadence — no log throttle needed.
421
+ */
422
+ recordDrop(events, reason) {
423
+ this.dropped += events.length;
424
+ console.warn(`[aforo-mqtt] Dropped ${events.length} event(s) — ${reason} (${this.dropped} total dropped).`);
425
+ this.notifyDrop(events, reason);
426
+ }
427
+ /**
428
+ * Account for an event that failed a client-side check: never buffered,
429
+ * never sent. Runs on the hot path, so the WARN is throttled per field
430
+ * (first occurrence, then every 1000th).
431
+ */
432
+ recordInvalid(event, invalid) {
433
+ this.dropped += 1;
434
+ const seen = (this.invalidSeen.get(invalid.field) ?? 0) + 1;
435
+ this.invalidSeen.set(invalid.field, seen);
436
+ if (seen === 1 || seen % 1000 === 0) {
437
+ console.warn(`[aforo-mqtt] Dropped 1 event — invalid: ${invalid.problem} ` +
438
+ `(${seen} for ${invalid.field}, ${this.dropped} total dropped).`);
439
+ }
440
+ this.notifyDrop([event], 'invalid');
319
441
  }
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}` : ''}`));
442
+ notifyDrop(events, reason) {
443
+ if (!this.onDrop)
444
+ return;
445
+ try {
446
+ this.onDrop(events, reason);
447
+ }
448
+ catch {
449
+ // A hook bug must never break metering or flushing.
325
450
  }
326
451
  }
327
452
  startTimer() {
@@ -329,12 +454,10 @@ class AforoMqttBilling {
329
454
  return;
330
455
  this.flushTimer = setInterval(() => { void this.flush(); }, this.flushIntervalMs);
331
456
  // 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();
334
- }
335
457
  if (typeof this.flushTimer.unref === 'function')
336
458
  this.flushTimer.unref();
337
459
  }
460
+ /** Flush any buffered events and stop the background timer. Call before process exit. */
338
461
  async shutdown() {
339
462
  if (this.flushTimer) {
340
463
  clearInterval(this.flushTimer);
@@ -355,6 +478,51 @@ function estimateBytes(data) {
355
478
  return data.byteLength;
356
479
  return 0;
357
480
  }
481
+ /** Statuses the Aforo ingestor accepts (contract/ingest-contract.json, max 20 chars). */
482
+ const CANONICAL_EXECUTION_STATUSES = new Set([
483
+ 'SUCCESS', 'PARTIAL', 'TIMEOUT', 'ERROR', 'VALIDATION_FAILED', 'FAILED',
484
+ 'FAILURE', 'CANCELLED', 'PENDING', 'BLOCKED', 'HITL_REQUIRED',
485
+ ]);
486
+ /**
487
+ * Check a caller-supplied status. `{ status }` when canonical; `{ problem }`
488
+ * when it has to be left off the event (an unknown value would make the
489
+ * ingestor reject the event; a Promise from an async resolver can't be
490
+ * awaited on this path); `{}` when blank or absent.
491
+ */
492
+ function checkExecutionStatus(value) {
493
+ if (value !== null && typeof value === 'object' && typeof value.then === 'function') {
494
+ // Swallow a later rejection so it doesn't surface as an unhandled rejection.
495
+ value.then(undefined, () => { });
496
+ return { problem: 'executionStatus resolver returned a Promise — resolvers must be synchronous; field omitted' };
497
+ }
498
+ const status = normalizeExecutionStatus(value);
499
+ if (!status)
500
+ return {};
501
+ if (!CANONICAL_EXECUTION_STATUSES.has(status)) {
502
+ return {
503
+ problem: `unknown executionStatus "${status.slice(0, 40)}" — field omitted. ` +
504
+ `Expected one of: ${[...CANONICAL_EXECUTION_STATUSES].join(', ')}`,
505
+ };
506
+ }
507
+ return { status };
508
+ }
509
+ /** Trim + upper-case; blank/absent → undefined (key omitted from the wire body). */
510
+ function normalizeExecutionStatus(value) {
511
+ if (typeof value !== 'string')
512
+ return undefined;
513
+ const trimmed = value.trim();
514
+ return trimmed ? trimmed.toUpperCase() : undefined;
515
+ }
516
+ function resolveExecutionStatus(option, event) {
517
+ if (typeof option !== 'function')
518
+ return option;
519
+ try {
520
+ return option(event);
521
+ }
522
+ catch {
523
+ return undefined; // a resolver bug must never break metering
524
+ }
525
+ }
358
526
  /** Trim + uppercase a product type; blank/non-string → undefined (unknown values pass through). */
359
527
  function normalizeProductType(value) {
360
528
  if (typeof value !== 'string')
@@ -362,6 +530,22 @@ function normalizeProductType(value) {
362
530
  const trimmed = value.trim();
363
531
  return trimmed ? trimmed.toUpperCase() : undefined;
364
532
  }
533
+ /** The offending value for a WARN line, cut to 80 chars. */
534
+ function preview(value) {
535
+ const text = typeof value === 'string' ? value : String(value);
536
+ return JSON.stringify(text.length > 80 ? `${text.slice(0, 80)}…` : text);
537
+ }
538
+ /** Limits mirror the ingestor's IngestUsageEventRequest; identity values are never truncated. */
539
+ function tooLong(field, value, max) {
540
+ if (typeof value !== 'string' || value.length <= max)
541
+ return undefined;
542
+ return { field, problem: `${field} is ${value.length} chars, limit ${max}: ${preview(value)}` };
543
+ }
544
+ function requiredText(field, value) {
545
+ if (typeof value === 'string' && value.trim())
546
+ return undefined;
547
+ return { field, problem: `${field} is required and must not be blank: ${preview(value)}` };
548
+ }
365
549
  /** Network errors, 408, 429 and 5xx are transient; every other 4xx (400/401/403/422...) is not. */
366
550
  function isRetryableStatus(status) {
367
551
  return status === 408 || status === 429 || status >= 500;
@@ -379,24 +563,78 @@ function parseRetryAfter(res) {
379
563
  return Math.min(Math.max(date - Date.now(), 0), MAX_RETRY_AFTER_MS);
380
564
  return undefined;
381
565
  }
382
- /** Reads the ingestor's `errors[].message` entries (first 5) from a response body, if any. */
383
- async function readErrorMessages(res) {
566
+ /**
567
+ * Reads the ingestor's batch response body, if any: the `failed` count, the
568
+ * first 5 `errors[].message` entries, and the batch indexes `errors[]` names.
569
+ */
570
+ async function readBatchResult(res, batchSize) {
571
+ const none = { failed: 0, details: '', indexes: [] };
384
572
  if (typeof res?.json !== 'function')
385
- return { details: '' };
573
+ return none;
386
574
  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('; ')
575
+ const body = unwrapEnvelope(await res.json());
576
+ const errors = Array.isArray(body?.errors) ? body.errors : undefined;
577
+ const details = errors
578
+ ? errors.slice(0, 5).map((e) => `#${e?.index}: ${e?.message}`).join('; ')
390
579
  : typeof body?.message === 'string' ? body.message : '';
391
- return { failed: typeof body?.failed === 'number' ? body.failed : undefined, details };
580
+ const indexes = [
581
+ ...new Set((errors ?? [])
582
+ .map((e) => e?.index)
583
+ .filter((i) => Number.isInteger(i) && i >= 0 && i < batchSize)),
584
+ ].sort((a, b) => a - b);
585
+ const failed = typeof body?.failed === 'number' && body.failed > 0 ? body.failed : 0;
586
+ return { failed, details, indexes };
392
587
  }
393
588
  catch {
394
- return { details: '' }; // Non-JSON or empty body — nothing to report.
589
+ return none; // Non-JSON or empty body — nothing to report.
395
590
  }
396
591
  }
592
+ /**
593
+ * Cut `value` to at most `max` UTF-16 code units — how the ingestor counts
594
+ * (`String.length()` in Java) — without leaving half a surrogate pair.
595
+ */
596
+ function truncateToLimit(value, max) {
597
+ if (value.length <= max)
598
+ return value;
599
+ let end = Math.max(0, max);
600
+ const last = end > 0 ? value.charCodeAt(end - 1) : 0;
601
+ // A high surrogate at the cut means its low half was cut off: drop it too.
602
+ if (last >= 0xd800 && last <= 0xdbff)
603
+ end -= 1;
604
+ return value.slice(0, end);
605
+ }
606
+ /**
607
+ * Build `head + component + tail`, where `component` is text taken from the
608
+ * incoming request and may be any length. Returned unchanged when it fits the
609
+ * ingestor's idempotencyKey limit; otherwise the component is replaced by the
610
+ * SHA-256 hex digest of its full, untruncated value (and, if the fixed parts
611
+ * alone are too long, `head + component` is). The key is never cut, so the
612
+ * unique tail always survives and two different components never share a key
613
+ * prefix by accident.
614
+ */
615
+ function boundedIdempotencyKey(head, component, tail) {
616
+ const key = `${head}${component}${tail}`;
617
+ if (key.length <= MAX_IDEMPOTENCY_KEY)
618
+ return key;
619
+ const hashed = `${head}${sha256Hex(component)}${tail}`;
620
+ if (hashed.length <= MAX_IDEMPOTENCY_KEY)
621
+ return hashed;
622
+ return `${sha256Hex(head + component)}${tail}`;
623
+ }
624
+ function sha256Hex(text) {
625
+ return (0, node_crypto_1.createHash)('sha256').update(text, 'utf8').digest('hex');
626
+ }
397
627
  function randomSuffix() {
398
628
  return Math.random().toString(36).slice(2, 10);
399
629
  }
400
630
  function sleep(ms) {
401
631
  return new Promise((r) => setTimeout(r, ms));
402
632
  }
633
+ /**
634
+ * The ingestor wraps every 2xx JSON body in `{success, data, meta}`. Returns
635
+ * the inner `data` object when present, else the body unchanged (bare shape).
636
+ */
637
+ function unwrapEnvelope(body) {
638
+ const data = body && typeof body === 'object' && !Array.isArray(body) ? body.data : undefined;
639
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : body;
640
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aforoai/mqtt-metering",
3
- "version": "1.0.0",
3
+ "version": "1.2.2",
4
4
  "description": "Aforo MQTT Metering SDK — aedes broker hooks + mqtt.js client wrapper that meters PUBLISH/DELIVER/SUBSCRIBE/CONNECT/DISCONNECT events to Aforo.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",