@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/README.md +71 -13
- package/dist/index.d.ts +187 -16
- package/dist/index.js +488 -225
- package/dist/transport-core/core.d.ts +354 -0
- package/dist/transport-core/core.js +1077 -0
- 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:**
|
|
5
|
+
**Version:** 2.0.0 · 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 `2.0.0` 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 `2.0.0` 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
|
|
@@ -51,12 +51,18 @@ const billing = new AforoMqttBilling({
|
|
|
51
51
|
|
|
52
52
|
const broker = aedes();
|
|
53
53
|
billing.wrapAedesBroker(broker, {
|
|
54
|
-
|
|
54
|
+
// Who is billed: the customer for the client your broker authenticated.
|
|
55
|
+
customerResolver: async (clientId, username) => customerStore.byClientId(clientId),
|
|
55
56
|
resolveMetadata: (clientId) => ({ deviceClass: deviceRegistry.classOf(clientId) }),
|
|
56
57
|
});
|
|
57
58
|
|
|
58
59
|
createServer(broker.handle).listen(1883);
|
|
59
|
-
|
|
60
|
+
|
|
61
|
+
// The SDK installs no signal handlers. Flush from your own:
|
|
62
|
+
process.once('SIGTERM', async () => {
|
|
63
|
+
await billing.shutdown();
|
|
64
|
+
process.exit(0);
|
|
65
|
+
});
|
|
60
66
|
```
|
|
61
67
|
|
|
62
68
|
**Client mode (`mqtt.js`):**
|
|
@@ -83,19 +89,28 @@ Each event uses `metricName: "mqtt_broker.<event>"` (`mqtt_broker.publish`, `mqt
|
|
|
83
89
|
|---|---|---|---|
|
|
84
90
|
| `tenantId` | `string` | — (required) | Aforo tenant. Sent as `X-Tenant-Id`. Never read from a client header. |
|
|
85
91
|
| `productId` | `string` | — (required) | Aforo product id; into each event's `metadata.productId`. |
|
|
86
|
-
| `apiKey` | `string` | — (required) | Sent as `X-API-Key: <apiKey
|
|
87
|
-
| `ingestorUrl` | `string` | — (required) | Ingestion base URL. SDK appends `/v1/ingest/batch`. Use `https://api.aforo.ai`. |
|
|
92
|
+
| `apiKey` | `string` | — (required) | Sent only as `X-API-Key: <apiKey>`; never logged or shown by `inspect()` / `JSON.stringify`. |
|
|
93
|
+
| `ingestorUrl` | `string` | — (required) | Ingestion base URL, `https://` (see `allowInsecureHttp`). SDK appends `/v1/ingest/batch`. Use `https://api.aforo.ai`. |
|
|
88
94
|
| `productType` | `string` | `'MQTT_BROKER'` | Aforo product type sent as top-level `productType` on every event (trimmed + uppercased; unknown values pass through). Override via `wrapAedesBroker(broker, { productType })` or `wrapMqttClient(client, { customerId, productType })`. |
|
|
89
95
|
| `emitDeliverEvents` | `boolean` | `false` | Emit a `DELIVER` event per fan-out delivery. Off → `DELIVER` events are dropped (both modes). |
|
|
90
|
-
| `
|
|
91
|
-
| `
|
|
92
|
-
| `
|
|
96
|
+
| `allowInsecureHttp` | `boolean` | `false` | Accept a plain `http://` `ingestorUrl` on a non-local host. `https://` is required otherwise (plain http is fine for `localhost`, `127.0.0.1`, `::1`). |
|
|
97
|
+
| `flushCount` | `number` | `500` | Events per request (max 1000). Reaching it wakes the background sender; recording an event never sends by itself. |
|
|
98
|
+
| `flushIntervalMs` | `number` | `5000` | The background sender also runs this often. |
|
|
99
|
+
| `maxQueueSize` | `number` | `10000` | Queue capacity. When it is full — usually during a long outage — the oldest event is dropped with reason `overflow`. |
|
|
100
|
+
| `maxRetries` | `number` | `3` | Retries per batch on network errors, timeouts, 408, 429 and 5xx, with full-jitter backoff (`Retry-After` in seconds or as a date is honoured, capped at 60 s). |
|
|
101
|
+
| `timeoutMs` | `number` | `10000` | Total deadline per request. |
|
|
102
|
+
| `shutdownTimeoutMs` | `number` | `5000` | How long `shutdown()` keeps sending. Events still queued then are dropped with reason `shutdown`. |
|
|
103
|
+
| `flushOnExit` | `boolean` | `true` | Send queued events on the process `beforeExit` event, within `shutdownTimeoutMs`. The SDK installs no signal handlers. |
|
|
104
|
+
| `onError` | `(error: Error) => void` | logs to `console.error` | Called for delivery problems: a batch the ingestor refused (the message includes its `errors[].message`), an ingestor outage (once per outage), a wrong or revoked API key, events lost at the shutdown deadline, and an unusable `executionStatus`. Delivery reports are a `CoreTransportError`: branch on `err.kind` (`outage`, `auth`, `rejected`, `shutdown`, `closed`) and read `err.events`, not the message. Exceptions it throws are caught. |
|
|
105
|
+
| `reconnectDebounceMs` | `number` | `30000` | A connection that drops and comes back within this window is one session: no new CONNECT/DISCONNECT pair is billed for the flap. A drop that does not come back is billed as DISCONNECT, stamped with the time it dropped, once the window passes; a clean disconnect is billed at once. `0` turns the window off. Overridable per `wrapAedesBroker` / `wrapMqttClient` call. |
|
|
106
|
+
| `onDrop` | `(events, reason) => void` | none | Receives events lost for good. See [Dropped events](#dropped-events). |
|
|
93
107
|
|
|
94
108
|
Mode-specific options:
|
|
95
109
|
|
|
96
110
|
| Option | Mode | Type | What it does |
|
|
97
111
|
|---|---|---|---|
|
|
98
|
-
| `
|
|
112
|
+
| `customerResolver` | broker | `(clientId, username?) => string \| undefined \| Promise<…>` | Required (or `trustCustomerHeader`). Map the client your broker authenticated to an Aforo customer. Returning nothing: that event is not metered and is counted as an `invalid` drop. A throw or rejection is reported through `onError` and treated the same way — never an unhandled rejection. `resolveCustomerId` is a deprecated alias. |
|
|
113
|
+
| `trustCustomerHeader` | broker | `boolean` | Bill the MQTT username sent at CONNECT. A client chooses its own username, so only turn this on when your broker authenticates it. Default `false`; `wrapAedesBroker` throws when neither this nor `customerResolver` is given. |
|
|
99
114
|
| `resolveMetadata` | broker | `(clientId) => Record<string, unknown> \| undefined` | Optional per-client tags. |
|
|
100
115
|
| `customerId` | client | `string` | Customer to attribute all traffic on this client to. |
|
|
101
116
|
| `clientId` | client | `string` | Fixed client id (defaults to `client.options.clientId`, then `'mqtt-client'`). |
|
|
@@ -103,7 +118,49 @@ Mode-specific options:
|
|
|
103
118
|
|
|
104
119
|
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
120
|
|
|
106
|
-
Exported symbols: `AforoMqttBilling` (with `wrapAedesBroker` / `wrapMqttClient` / `shutdown`) and the `AforoMqttConfig` / `AedesBrokerOptions` / `MqttClientOptions` types.
|
|
121
|
+
Exported symbols: `AforoMqttBilling` (with `wrapAedesBroker` / `wrapMqttClient` / `shutdown` / `droppedCount`), `mqttConnectTopic` / `mqttDisconnectTopic`, `DEFAULT_PRODUCT_TYPE`, `DEFAULT_RECONNECT_DEBOUNCE_MS`, `CoreTransportError` (with the `CoreErrorKind` type), and the `AforoMqttConfig` / `AedesBrokerOptions` / `MqttClientOptions` / `MqttUsageEvent` / `DropReason` types.
|
|
122
|
+
|
|
123
|
+
## Delivery
|
|
124
|
+
|
|
125
|
+
One background sender per client sends one request at a time; recording an event only queues it. If the ingestor is unreachable, keeps answering 408/429/5xx after the retries, or answers 401/403 (a wrong or revoked key — logged as an error), the batch goes back to the front of the queue with its idempotency keys and sending pauses (5 s, doubling to 5 min, randomized). New events keep queueing up to `maxQueueSize`; the first successful request resumes sending, and the ingestor deduplicates the re-sent keys. A 413 splits the batch. Request bodies are plain JSON (the ingestor does not decode gzip). `flush()` sends what is queued now; `shutdown()` sends for at most `shutdownTimeoutMs` — call it from your own SIGTERM handler.
|
|
126
|
+
|
|
127
|
+
## Execution status
|
|
128
|
+
|
|
129
|
+
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.
|
|
130
|
+
|
|
131
|
+
The SDK never sets `executionStatus` on its own: the broker hook sees no success or failure signal, and a client publish is metered only after it succeeded, so there is no failure for the SDK to read. Pass it yourself, as a string or as a function called for each event:
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
billing.wrapAedesBroker(broker, {
|
|
135
|
+
customerResolver: (clientId) => customerIdLookup(clientId),
|
|
136
|
+
// (event, clientId) => status | undefined
|
|
137
|
+
executionStatus: (event) => (event.mqttTopic.startsWith('alerts/') ? 'SUCCESS' : undefined),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
billing.wrapMqttClient(client, { customerId: 'cust_42', executionStatus: 'SUCCESS' });
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Dropped events
|
|
144
|
+
|
|
145
|
+
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.
|
|
146
|
+
|
|
147
|
+
| Reason | When |
|
|
148
|
+
|---|---|
|
|
149
|
+
| `invalid` | The customer resolver returned nothing or failed, or the event failed a client-side check and was never queued 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 invalid event and for every 1000th after that. |
|
|
150
|
+
| `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. |
|
|
151
|
+
| `overflow` | The queue (`maxQueueSize`) was full and the oldest event was evicted. |
|
|
152
|
+
| `shutdown` | Still queued when `shutdownTimeoutMs` ran out (also reported through `onError`). |
|
|
153
|
+
| `closed` | Recorded after `shutdown()`. |
|
|
154
|
+
| `retry_exhausted` | Not produced by 2.x (kept in the type): an outage keeps events queued instead of dropping them. |
|
|
155
|
+
|
|
156
|
+
An event with no resolvable customer is not metered and is counted as an `invalid` drop.
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
const billing = new AforoMqttBilling({
|
|
160
|
+
// …
|
|
161
|
+
onDrop: (events, reason) => deadLetterQueue.push({ reason, events }),
|
|
162
|
+
});
|
|
163
|
+
```
|
|
107
164
|
|
|
108
165
|
## Walk me through it
|
|
109
166
|
|
|
@@ -113,6 +170,7 @@ Step-by-step from install to a verified event in Aforo: [USER_GUIDE.md](USER_GUI
|
|
|
113
170
|
|
|
114
171
|
- **Fan-out billing is opt-in.** `DELIVER` events are dropped unless `emitDeliverEvents: true`. Default metering counts the PUBLISH, not the per-subscriber fan-out.
|
|
115
172
|
- **CONNECT/DISCONNECT have no MQTT topic.** Because the ingestor requires `mqttTopic`, they are sent with `$SYS/clients/<clientId>/connected` and `$SYS/clients/<clientId>/disconnected`. `$SYS/` is reserved for brokers, so these topics can't collide with a real one.
|
|
116
|
-
- **Client-mode
|
|
117
|
-
- **
|
|
173
|
+
- **Client-mode PUBLISH is billed after success.** `wrapMqttClient` meters a publish when mqtt.js reports it succeeded: on the broker's PUBACK/PUBCOMP for QoS 1/2, after the socket write for QoS 0. A publish that throws or whose callback gets an error is not billed. `publishAsync()` is covered too.
|
|
174
|
+
- **Reconnects are one session.** mqtt.js reconnects on its own; a connection that comes back within `reconnectDebounceMs` (default 30 s) bills no new CONNECT and no DISCONNECT, and failed reconnect attempts bill nothing. A clean `client.end()` bills DISCONNECT at once. On the broker, the same rule applies per client id: a client that drops without a DISCONNECT packet and reconnects (or takes over its own session) inside the window is one session. `shutdown()` bills any DISCONNECT still waiting.
|
|
175
|
+
- **No persistent buffer.** Events are in memory until sent; a hard crash loses what is queued. `shutdown()` covers graceful exit only.
|
|
118
176
|
- **The SDK does not enforce or read pricing.** It emits usage; rating/billing happens in Aforo.
|
package/dist/index.d.ts
CHANGED
|
@@ -26,9 +26,15 @@
|
|
|
26
26
|
*
|
|
27
27
|
* const broker = aedes();
|
|
28
28
|
* billing.wrapAedesBroker(broker, {
|
|
29
|
-
*
|
|
29
|
+
* // Who is billed: map the client your broker authenticated to an Aforo customer.
|
|
30
|
+
* customerResolver: (clientId, username) => deviceRegistry.customerFor(clientId),
|
|
30
31
|
* });
|
|
32
|
+
*
|
|
33
|
+
* // The SDK installs no signal handlers. Flush from your own:
|
|
34
|
+
* process.once('SIGTERM', async () => { await billing.shutdown(); process.exit(0); });
|
|
31
35
|
*/
|
|
36
|
+
/** What onError receives for delivery problems: branch on `kind`, read `events`. */
|
|
37
|
+
export { CoreTransportError, type CoreErrorKind } from './transport-core/core';
|
|
32
38
|
export interface AforoMqttConfig {
|
|
33
39
|
tenantId: string;
|
|
34
40
|
productId: string;
|
|
@@ -39,31 +45,136 @@ export interface AforoMqttConfig {
|
|
|
39
45
|
* Default: `MQTT_BROKER`. Override per integration via `wrapAedesBroker(broker, { productType })` / `wrapMqttClient(client, { productType })`.
|
|
40
46
|
*/
|
|
41
47
|
productType?: string;
|
|
42
|
-
/**
|
|
48
|
+
/** Allow a plain http:// ingestorUrl on a non-local host. Default false (https required). */
|
|
49
|
+
allowInsecureHttp?: boolean;
|
|
50
|
+
/** Events per request (default 500, max 1000). Reaching it wakes the background sender. */
|
|
43
51
|
flushCount?: number;
|
|
44
|
-
/**
|
|
52
|
+
/** The background sender also runs this often, in ms (default 5000). */
|
|
45
53
|
flushIntervalMs?: number;
|
|
54
|
+
/** Queue capacity (default 10 000). The oldest event is dropped on overflow. */
|
|
55
|
+
maxQueueSize?: number;
|
|
56
|
+
/** Retries per batch before the breaker opens (default 3). */
|
|
57
|
+
maxRetries?: number;
|
|
58
|
+
/** Total deadline per request, in ms (default 10 000). */
|
|
59
|
+
timeoutMs?: number;
|
|
60
|
+
/** How long shutdown() keeps sending, in ms (default 5000). */
|
|
61
|
+
shutdownTimeoutMs?: number;
|
|
62
|
+
/** Send queued events on the process 'beforeExit' event (default true). */
|
|
63
|
+
flushOnExit?: boolean;
|
|
46
64
|
/** Emit one event per fanout delivery in addition to the publish (default false — costly). */
|
|
47
65
|
emitDeliverEvents?: boolean;
|
|
48
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* A connection that drops and comes back within this many ms is one
|
|
68
|
+
* session: no new CONNECT/DISCONNECT pair is billed for the flap. A
|
|
69
|
+
* connection that does not come back is billed as DISCONNECT (at the time
|
|
70
|
+
* it dropped) once the window passes; a clean disconnect is billed at once.
|
|
71
|
+
* Default 30 000. 0 turns the window off. Overridable per wrap call.
|
|
72
|
+
*/
|
|
73
|
+
reconnectDebounceMs?: number;
|
|
74
|
+
/**
|
|
75
|
+
* Callback for delivery problems (rejected batches, outages, a wrong API
|
|
76
|
+
* key, shutdown leftovers), resolver errors and bad executionStatus values.
|
|
77
|
+
* Delivery reports are a `CoreTransportError`: branch on `err.kind`.
|
|
78
|
+
*/
|
|
49
79
|
onError?: (error: Error) => void;
|
|
80
|
+
/**
|
|
81
|
+
* Opt-in hook receiving events that were permanently dropped (see
|
|
82
|
+
* DropReason). Events keep their idempotency keys,
|
|
83
|
+
* so persisting and re-submitting them after recovery is dedup-safe.
|
|
84
|
+
* Exceptions thrown by the hook are swallowed. Default: none (drops are
|
|
85
|
+
* still counted in droppedCount and WARN-logged).
|
|
86
|
+
*/
|
|
87
|
+
onDrop?: (events: MqttUsageEvent[], reason: DropReason) => void;
|
|
50
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Why events were permanently dropped. `overflow`: the queue was full and the
|
|
91
|
+
* oldest event was evicted. `rejected`: the ingestor refused the batch
|
|
92
|
+
* (non-retryable 4xx) or individual events of an accepted batch. `invalid`:
|
|
93
|
+
* the event failed a client-side check, or the customer resolver returned no
|
|
94
|
+
* customer. `shutdown`: still queued when the shutdown deadline passed.
|
|
95
|
+
* `closed`: recorded after shutdown(). `retry_exhausted`: kept for
|
|
96
|
+
* compatibility, not produced by 2.x — an outage keeps events queued.
|
|
97
|
+
*/
|
|
98
|
+
export type DropReason = 'overflow' | 'retry_exhausted' | 'rejected' | 'invalid' | 'shutdown' | 'closed';
|
|
99
|
+
/** Maps a broker client to the Aforo customer billed for it. */
|
|
100
|
+
export type MqttCustomerResolver = (clientId: string, username?: string) => string | undefined | null | Promise<string | undefined | null>;
|
|
51
101
|
export interface AedesBrokerOptions {
|
|
52
|
-
/**
|
|
53
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Who is billed for a client's traffic: map the client your broker
|
|
104
|
+
* authenticated (its client id / username after your `authenticate` hook)
|
|
105
|
+
* to an Aforo customer. Required unless `trustCustomerHeader` is true. May
|
|
106
|
+
* be async. Returning nothing drops that event as 'invalid' (counted); a
|
|
107
|
+
* throw or rejection is reported through onError and the event is dropped
|
|
108
|
+
* as 'invalid'. MQTT traffic is never blocked.
|
|
109
|
+
*/
|
|
110
|
+
customerResolver?: MqttCustomerResolver;
|
|
111
|
+
/** @deprecated Use `customerResolver` (same signature). */
|
|
112
|
+
resolveCustomerId?: MqttCustomerResolver;
|
|
113
|
+
/**
|
|
114
|
+
* Bill the MQTT username the client sent at CONNECT. A client picks its own
|
|
115
|
+
* username, so only turn this on when your broker authenticates the
|
|
116
|
+
* username (password / certificate check in `authenticate`). Default false.
|
|
117
|
+
*/
|
|
118
|
+
trustCustomerHeader?: boolean;
|
|
54
119
|
/** Optional per-client metadata (tags, device type). */
|
|
55
120
|
resolveMetadata?: (clientId: string) => Record<string, unknown> | undefined;
|
|
121
|
+
/**
|
|
122
|
+
* Optional outcome of the request (used by OUTCOME_BASED pricing, which bills
|
|
123
|
+
* each event at the weight set for its status). Events without a status bill
|
|
124
|
+
* at full price. Trimmed and upper-cased by the SDK; blank is treated as absent.
|
|
125
|
+
* Accepted values: SUCCESS, PARTIAL, TIMEOUT, ERROR, VALIDATION_FAILED, FAILED,
|
|
126
|
+
* FAILURE, CANCELLED, PENDING, BLOCKED, HITL_REQUIRED (max 20 chars). Any other
|
|
127
|
+
* value — or a Promise from an async resolver — is reported through `onError`
|
|
128
|
+
* and left off the event (the event is still sent), because the server would
|
|
129
|
+
* reject the event. Resolvers must be synchronous.
|
|
130
|
+
*
|
|
131
|
+
* MQTT packets carry no success/failure signal the broker hook sees, so the
|
|
132
|
+
* SDK never derives one: pass a fixed string, or a function called for each
|
|
133
|
+
* event (with the MQTT client ID) that returns the status or undefined.
|
|
134
|
+
*/
|
|
135
|
+
executionStatus?: string | ((event: MqttUsageEvent, clientId: string) => string | undefined);
|
|
56
136
|
/** Product type for events from this broker. Default: the client-level `productType`. */
|
|
57
137
|
productType?: string;
|
|
138
|
+
/**
|
|
139
|
+
* Reconnect window per client id, in ms (default: the client-level
|
|
140
|
+
* `reconnectDebounceMs`, 30 000). A client that disconnects without a
|
|
141
|
+
* DISCONNECT packet and reconnects with the same client id inside the
|
|
142
|
+
* window — or takes over its own session — is one session. 0 = off.
|
|
143
|
+
*/
|
|
144
|
+
reconnectDebounceMs?: number;
|
|
58
145
|
}
|
|
59
146
|
export interface MqttClientOptions {
|
|
60
147
|
/** Customer ID to attribute all traffic on this client to. */
|
|
61
148
|
customerId: string;
|
|
62
149
|
/** Fixed client identifier (defaults to mqtt.js connection options.clientId). */
|
|
63
150
|
clientId?: string;
|
|
151
|
+
/**
|
|
152
|
+
* Optional outcome of the request (used by OUTCOME_BASED pricing, which bills
|
|
153
|
+
* each event at the weight set for its status). Events without a status bill
|
|
154
|
+
* at full price. Trimmed and upper-cased by the SDK; blank is treated as absent.
|
|
155
|
+
* Accepted values: SUCCESS, PARTIAL, TIMEOUT, ERROR, VALIDATION_FAILED, FAILED,
|
|
156
|
+
* FAILURE, CANCELLED, PENDING, BLOCKED, HITL_REQUIRED (max 20 chars). Any other
|
|
157
|
+
* value — or a Promise from an async resolver — is reported through `onError`
|
|
158
|
+
* and left off the event (the event is still sent), because the server would
|
|
159
|
+
* reject the event. Resolvers must be synchronous.
|
|
160
|
+
*
|
|
161
|
+
* Never derived by the SDK: a publish is metered only once mqtt.js
|
|
162
|
+
* reports it succeeded, so there is no failure to derive a status from.
|
|
163
|
+
* Pass a fixed string, or a function called for each event that returns
|
|
164
|
+
* the status or undefined.
|
|
165
|
+
*/
|
|
166
|
+
executionStatus?: string | ((event: MqttUsageEvent) => string | undefined);
|
|
64
167
|
/** Product type for events from this client. Default: the client-level `productType`. */
|
|
65
168
|
productType?: string;
|
|
169
|
+
/**
|
|
170
|
+
* Reconnect window in ms (default: the client-level `reconnectDebounceMs`,
|
|
171
|
+
* 30 000). mqtt.js reconnects on its own after a drop; a reconnect inside
|
|
172
|
+
* the window bills no new CONNECT and no DISCONNECT. 0 = off.
|
|
173
|
+
*/
|
|
174
|
+
reconnectDebounceMs?: number;
|
|
66
175
|
}
|
|
176
|
+
/** Default reconnect window, the same as python-mqtt's `reconnect_debounce_sec`. */
|
|
177
|
+
export declare const DEFAULT_RECONNECT_DEBOUNCE_MS = 30000;
|
|
67
178
|
/** Default top-level `productType` for this SDK. */
|
|
68
179
|
export declare const DEFAULT_PRODUCT_TYPE = "MQTT_BROKER";
|
|
69
180
|
/**
|
|
@@ -75,6 +186,24 @@ export declare const DEFAULT_PRODUCT_TYPE = "MQTT_BROKER";
|
|
|
75
186
|
*/
|
|
76
187
|
export declare const mqttConnectTopic: (clientId: string) => string;
|
|
77
188
|
export declare const mqttDisconnectTopic: (clientId: string) => string;
|
|
189
|
+
type MqttEventType = 'PUBLISH' | 'DELIVER' | 'SUBSCRIBE' | 'UNSUBSCRIBE' | 'CONNECT' | 'DISCONNECT';
|
|
190
|
+
export interface MqttUsageEvent {
|
|
191
|
+
customerId: string;
|
|
192
|
+
metricName: string;
|
|
193
|
+
quantity: number;
|
|
194
|
+
occurredAt: string;
|
|
195
|
+
idempotencyKey: string;
|
|
196
|
+
productType: string;
|
|
197
|
+
mqttTopic: string;
|
|
198
|
+
mqttQos: number;
|
|
199
|
+
mqttRetained: boolean;
|
|
200
|
+
mqttEventType: MqttEventType;
|
|
201
|
+
mqttClientId: string;
|
|
202
|
+
dataBytes: number;
|
|
203
|
+
metadata?: Record<string, unknown>;
|
|
204
|
+
/** Normalized (trimmed, upper-cased) execution status; omitted when not set. */
|
|
205
|
+
executionStatus?: string;
|
|
206
|
+
}
|
|
78
207
|
/** Minimal Aedes broker surface — matches the `aedes` package. */
|
|
79
208
|
interface MinimalAedes {
|
|
80
209
|
on(event: 'publish', fn: (packet: any, client: any) => void): void;
|
|
@@ -88,29 +217,71 @@ interface MinimalMqttClient {
|
|
|
88
217
|
on(event: 'message', fn: (topic: string, payload: Buffer, packet: any) => void): void;
|
|
89
218
|
on(event: 'connect', fn: () => void): void;
|
|
90
219
|
on(event: 'close', fn: () => void): void;
|
|
220
|
+
on(event: 'end', fn: () => void): void;
|
|
91
221
|
publish: (topic: string, message: any, opts?: any, cb?: any) => any;
|
|
92
222
|
options?: {
|
|
93
223
|
clientId?: string;
|
|
94
224
|
};
|
|
225
|
+
/** mqtt.js sets this while end() runs, so a 'close' then is a clean disconnect. */
|
|
226
|
+
disconnecting?: boolean;
|
|
95
227
|
}
|
|
96
228
|
export declare class AforoMqttBilling {
|
|
97
229
|
private readonly config;
|
|
98
230
|
private readonly productType;
|
|
99
|
-
private readonly flushCount;
|
|
100
|
-
private readonly flushIntervalMs;
|
|
101
231
|
private readonly emitDeliverEvents;
|
|
102
232
|
private readonly onError;
|
|
103
|
-
private
|
|
104
|
-
|
|
233
|
+
private readonly core;
|
|
234
|
+
/** Request-derived labels already reported as truncated (one WARN per label). */
|
|
235
|
+
private readonly truncationWarned;
|
|
236
|
+
private readonly reconnectDebounceMs;
|
|
237
|
+
/** DISCONNECTs inside their reconnect window; billed at shutdown at the latest. */
|
|
238
|
+
private readonly pendingDisconnects;
|
|
105
239
|
constructor(config: AforoMqttConfig);
|
|
240
|
+
/**
|
|
241
|
+
* Attach to an Aedes broker. Throws when neither `customerResolver` nor
|
|
242
|
+
* `trustCustomerHeader: true` is given. Every listener catches its own
|
|
243
|
+
* errors (a resolver rejection is reported through onError), so metering can
|
|
244
|
+
* never produce an unhandled rejection in the broker process.
|
|
245
|
+
*/
|
|
106
246
|
wrapAedesBroker(broker: MinimalAedes, options: AedesBrokerOptions): void;
|
|
107
247
|
wrapMqttClient(client: MinimalMqttClient, options: MqttClientOptions): void;
|
|
248
|
+
/**
|
|
249
|
+
* Bound an MQTT label (topic, client id) to the ingestor's limit. The event
|
|
250
|
+
* is still sent; one WARN per label name per client. Identity fields
|
|
251
|
+
* (customerId, productType) never go through here — an over-long one drops
|
|
252
|
+
* the event as 'invalid'.
|
|
253
|
+
*/
|
|
254
|
+
private requestLabel;
|
|
255
|
+
/** Topic off an incoming packet, bound to the ingestor's mqttTopic limit. */
|
|
256
|
+
private topicLabel;
|
|
257
|
+
/** Client id a connecting client chose, bound to the ingestor's mqttClientId limit. */
|
|
258
|
+
private clientIdLabel;
|
|
108
259
|
private push;
|
|
109
|
-
|
|
110
|
-
private
|
|
111
|
-
/**
|
|
112
|
-
private
|
|
113
|
-
|
|
260
|
+
/** Hold a DISCONNECT for `ms`; it is billed when the window passes unless cancelled. */
|
|
261
|
+
private scheduleDisconnect;
|
|
262
|
+
/** Invoke onError; a hook bug must never break metering or flushing. */
|
|
263
|
+
private reportError;
|
|
264
|
+
/** Number of events permanently dropped since this instance was created (every DropReason). */
|
|
265
|
+
get droppedCount(): number;
|
|
266
|
+
/** Number of events waiting to be sent. */
|
|
267
|
+
get queuedCount(): number;
|
|
268
|
+
/** Send what is queued now and wait for it (one request at a time; never concurrent). */
|
|
269
|
+
flush(): Promise<{
|
|
270
|
+
sent: number;
|
|
271
|
+
failed: number;
|
|
272
|
+
requeued: number;
|
|
273
|
+
queued: number;
|
|
274
|
+
}>;
|
|
275
|
+
/**
|
|
276
|
+
* Stop accepting events and send what is queued, for at most
|
|
277
|
+
* `shutdownTimeoutMs`. Events still queued then are dropped with reason
|
|
278
|
+
* 'shutdown' (counted, passed to onDrop). Call it from your SIGTERM handler.
|
|
279
|
+
*/
|
|
114
280
|
shutdown(): Promise<void>;
|
|
281
|
+
toJSON(): Record<string, unknown>;
|
|
115
282
|
}
|
|
116
|
-
|
|
283
|
+
/**
|
|
284
|
+
* Cut `value` to at most `max` UTF-16 code units — how the ingestor counts
|
|
285
|
+
* (`String.length()` in Java) — without leaving half a surrogate pair.
|
|
286
|
+
*/
|
|
287
|
+
export declare function truncateToLimit(value: string, max: number): string;
|