@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 +40 -4
- package/dist/index.d.ts +113 -2
- package/dist/index.js +306 -68
- package/package.json +1 -1
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.
|
|
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
|
-
> **
|
|
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
|
|
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
|
-
/**
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
260
|
-
|
|
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
|
-
|
|
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.
|
|
361
|
+
await this.handlePartialFailures(res, batch);
|
|
300
362
|
return;
|
|
301
363
|
}
|
|
302
364
|
if (!isRetryableStatus(res.status)) {
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
this.
|
|
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
|
-
/**
|
|
383
|
-
|
|
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
|
|
573
|
+
return none;
|
|
386
574
|
try {
|
|
387
|
-
const body = await res.json();
|
|
388
|
-
const
|
|
389
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|