@aforoai/mqtt-metering 1.2.2 → 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.2.2 · 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
- > **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.
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,20 +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 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). |
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). |
94
107
 
95
108
  Mode-specific options:
96
109
 
97
110
  | Option | Mode | Type | What it does |
98
111
  |---|---|---|---|
99
- | `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. |
100
114
  | `resolveMetadata` | broker | `(clientId) => Record<string, unknown> \| undefined` | Optional per-client tags. |
101
115
  | `customerId` | client | `string` | Customer to attribute all traffic on this client to. |
102
116
  | `clientId` | client | `string` | Fixed client id (defaults to `client.options.clientId`, then `'mqtt-client'`). |
@@ -104,17 +118,21 @@ Mode-specific options:
104
118
 
105
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.
106
120
 
107
- Exported symbols: `AforoMqttBilling` (with `wrapAedesBroker` / `wrapMqttClient` / `shutdown` / `droppedCount`), `mqttConnectTopic` / `mqttDisconnectTopic`, `DEFAULT_PRODUCT_TYPE`, and the `AforoMqttConfig` / `AedesBrokerOptions` / `MqttClientOptions` / `MqttUsageEvent` / `DropReason` 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.
108
126
 
109
127
  ## Execution status
110
128
 
111
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.
112
130
 
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:
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:
114
132
 
115
133
  ```typescript
116
134
  billing.wrapAedesBroker(broker, {
117
- resolveCustomerId: (clientId) => customerIdLookup(clientId),
135
+ customerResolver: (clientId) => customerIdLookup(clientId),
118
136
  // (event, clientId) => status | undefined
119
137
  executionStatus: (event) => (event.mqttTopic.startsWith('alerts/') ? 'SUCCESS' : undefined),
120
138
  });
@@ -128,11 +146,14 @@ The SDK does not throw into your broker or client handlers for event content. An
128
146
 
129
147
  | Reason | When |
130
148
  |---|---|
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. |
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. |
132
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. |
133
- | `retry_exhausted` | Network errors, 408, 429 or 5xx persisted through 3 attempts (1s / 2s / 4s backoff; `Retry-After` honoured up to 30 s). |
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. |
134
155
 
135
- A call with no resolvable customer id is not billable; it is skipped and is not counted as a drop.
156
+ An event with no resolvable customer is not metered and is counted as an `invalid` drop.
136
157
 
137
158
  ```ts
138
159
  const billing = new AforoMqttBilling({
@@ -149,6 +170,7 @@ Step-by-step from install to a verified event in Aforo: [USER_GUIDE.md](USER_GUI
149
170
 
150
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.
151
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.
152
- - **Client-mode CONNECT/DISCONNECT use socket lifecycle.** `wrapMqttClient` maps `connect`/`close`; a client that reconnects emits a CONNECT each time.
153
- - **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.
154
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,18 +45,41 @@ 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;
50
80
  /**
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,
81
+ * Opt-in hook receiving events that were permanently dropped (see
82
+ * DropReason). Events keep their idempotency keys,
54
83
  * so persisting and re-submitting them after recovery is dedup-safe.
55
84
  * Exceptions thrown by the hook are swallowed. Default: none (drops are
56
85
  * still counted in droppedCount and WARN-logged).
@@ -58,17 +87,35 @@ export interface AforoMqttConfig {
58
87
  onDrop?: (events: MqttUsageEvent[], reason: DropReason) => void;
59
88
  }
60
89
  /**
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.
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.
67
97
  */
68
- export type DropReason = 'retry_exhausted' | 'rejected' | 'invalid';
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>;
69
101
  export interface AedesBrokerOptions {
70
- /** Map an MQTT client ID to an Aforo customer ID. Required. */
71
- 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;
72
119
  /** Optional per-client metadata (tags, device type). */
73
120
  resolveMetadata?: (clientId: string) => Record<string, unknown> | undefined;
74
121
  /**
@@ -88,6 +135,13 @@ export interface AedesBrokerOptions {
88
135
  executionStatus?: string | ((event: MqttUsageEvent, clientId: string) => string | undefined);
89
136
  /** Product type for events from this broker. Default: the client-level `productType`. */
90
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;
91
145
  }
92
146
  export interface MqttClientOptions {
93
147
  /** Customer ID to attribute all traffic on this client to. */
@@ -104,14 +158,23 @@ export interface MqttClientOptions {
104
158
  * and left off the event (the event is still sent), because the server would
105
159
  * reject the event. Resolvers must be synchronous.
106
160
  *
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.
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.
110
165
  */
111
166
  executionStatus?: string | ((event: MqttUsageEvent) => string | undefined);
112
167
  /** Product type for events from this client. Default: the client-level `productType`. */
113
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;
114
175
  }
176
+ /** Default reconnect window, the same as python-mqtt's `reconnect_debounce_sec`. */
177
+ export declare const DEFAULT_RECONNECT_DEBOUNCE_MS = 30000;
115
178
  /** Default top-level `productType` for this SDK. */
116
179
  export declare const DEFAULT_PRODUCT_TYPE = "MQTT_BROKER";
117
180
  /**
@@ -154,27 +217,32 @@ interface MinimalMqttClient {
154
217
  on(event: 'message', fn: (topic: string, payload: Buffer, packet: any) => void): void;
155
218
  on(event: 'connect', fn: () => void): void;
156
219
  on(event: 'close', fn: () => void): void;
220
+ on(event: 'end', fn: () => void): void;
157
221
  publish: (topic: string, message: any, opts?: any, cb?: any) => any;
158
222
  options?: {
159
223
  clientId?: string;
160
224
  };
225
+ /** mqtt.js sets this while end() runs, so a 'close' then is a clean disconnect. */
226
+ disconnecting?: boolean;
161
227
  }
162
228
  export declare class AforoMqttBilling {
163
229
  private readonly config;
164
230
  private readonly productType;
165
- private readonly flushCount;
166
- private readonly flushIntervalMs;
167
231
  private readonly emitDeliverEvents;
168
232
  private readonly onError;
169
- private readonly onDrop?;
170
- private buffer;
171
- private flushTimer;
172
- private dropped;
173
- /** Invalid-event count per field, for WARN throttling. */
174
- private readonly invalidSeen;
233
+ private readonly core;
175
234
  /** Request-derived labels already reported as truncated (one WARN per label). */
176
235
  private readonly truncationWarned;
236
+ private readonly reconnectDebounceMs;
237
+ /** DISCONNECTs inside their reconnect window; billed at shutdown at the latest. */
238
+ private readonly pendingDisconnects;
177
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
+ */
178
246
  wrapAedesBroker(broker: MinimalAedes, options: AedesBrokerOptions): void;
179
247
  wrapMqttClient(client: MinimalMqttClient, options: MqttClientOptions): void;
180
248
  /**
@@ -189,39 +257,31 @@ export declare class AforoMqttBilling {
189
257
  /** Client id a connecting client chose, bound to the ingestor's mqttClientId limit. */
190
258
  private clientIdLabel;
191
259
  private push;
192
- private flush;
193
- private send;
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;
260
+ /** Hold a DISCONNECT for `ms`; it is billed when the window passes unless cancelled. */
261
+ private scheduleDisconnect;
201
262
  /** Invoke onError; a hook bug must never break metering or flushing. */
202
263
  private reportError;
203
- /** Number of events permanently dropped since this instance was created. */
264
+ /** Number of events permanently dropped since this instance was created (every DropReason). */
204
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
+ }>;
205
275
  /**
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).
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.
215
279
  */
216
- private recordInvalid;
217
- private notifyDrop;
218
- private startTimer;
219
- /** Flush any buffered events and stop the background timer. Call before process exit. */
220
280
  shutdown(): Promise<void>;
281
+ toJSON(): Record<string, unknown>;
221
282
  }
222
283
  /**
223
284
  * Cut `value` to at most `max` UTF-16 code units — how the ingestor counts
224
285
  * (`String.length()` in Java) — without leaving half a surrogate pair.
225
286
  */
226
287
  export declare function truncateToLimit(value: string, max: number): string;
227
- export {};