@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 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:** 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
- > **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 `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
- resolveCustomerId: async (clientId, username) => customerStore.byClientId(clientId),
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
- process.on('SIGTERM', async () => { await billing.shutdown(); });
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
- | `flushCount` | `number` | `200` | Buffered events that trigger an immediate flush. Highest default of the SDKs — MQTT is very high-volume. |
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`). |
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
- | `resolveCustomerId` | broker | `(clientId, username?) => string \| undefined \| Promise<…>` | Required. Map an MQTT client id to a customer. `undefined` → that client's events are not metered. |
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 CONNECT/DISCONNECT use socket lifecycle.** `wrapMqttClient` maps `connect`/`close`; a client that reconnects emits a CONNECT each time.
117
- - **No persistent buffer.** Events are in memory until flushed; a hard crash before flush drops the buffered batch. `shutdown()` covers graceful exit only.
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
- * resolveCustomerId: (clientId) => customerIdLookup(clientId),
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
- /** How many events to buffer before flushing (default 200 — MQTT is very high-volume). */
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
- /** Max interval in ms before a partial batch is flushed (default 2000). */
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
- /** Callback for terminal flush failures. */
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
- /** Map an MQTT client ID to an Aforo customer ID. Required. */
53
- resolveCustomerId: (clientId: string, username?: string) => string | undefined | Promise<string | undefined>;
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 buffer;
104
- private flushTimer;
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
- private flush;
110
- private send;
111
- /** A 202 can still carry per-event rejections: `{accepted, duplicates, failed, errors:[{index, message}]}`. */
112
- private reportPartialFailures;
113
- private startTimer;
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
- export {};
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;