@aforoai/ws-metering 1.0.0 → 1.2.1

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 WebSocket connections into Aforo — open, close, bytes, frame counts, and duration — by wrapping a `ws` server, or by tracking any connection that exposes the standard WebSocket event surface (Fastify-WebSocket, Socket.io, Deno, Bun).
4
4
 
5
- **Version:** 1.0.0 · Apache-2.0 · [Changelog](CHANGELOG.md) · [User guide](USER_GUIDE.md)
5
+ **Version:** 1.2.1 · Apache-2.0 · [Changelog](CHANGELOG.md) · [User guide](USER_GUIDE.md)
6
6
 
7
7
  ## Install
8
8
 
@@ -12,7 +12,7 @@ Intended public install (once published):
12
12
  npm i @aforoai/ws-metering ws
13
13
  ```
14
14
 
15
- > **Not yet on the public npm registry — install from source for now.** `ws` (`^8`) is an **optional** peer dependency — needed only if you use `wrapServer`. `trackConnection` works with any compatible socket.
15
+ > **Install `1.2.1` 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.1` is not on npm yet, install from source with the steps below. `ws` (`^8`) is an **optional** peer dependency — needed only if you use `wrapServer`. `trackConnection` works with any compatible socket.
16
16
 
17
17
  ```bash
18
18
  # from the SDKs repo root
@@ -70,7 +70,8 @@ By default the SDK emits two events per connection — `CONNECTION_OPENED` on co
70
70
  | `perFrameEvents` | `boolean` | `false` | Emit one event per frame (each direction). Off → frames are aggregated into the close event only. |
71
71
  | `flushCount` | `number` | `100` | Buffered events that trigger an immediate flush. Higher default than the base SDK — WS is high-volume. |
72
72
  | `flushIntervalMs` | `number` | `3000` | Max ms before a partial batch is flushed. |
73
- | `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`). |
73
+ | `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. |
74
+ | `onDrop` | `(events, reason) => void` | none | Receives events that were permanently dropped, with reason `invalid`, `rejected` or `retry_exhausted`. See [Dropped events](#dropped-events). |
74
75
 
75
76
  `wrapServer(wss, options)` / `trackConnection(ws, opts)` take the customer resolver:
76
77
 
@@ -84,7 +85,45 @@ By default the SDK emits two events per connection — `CONNECTION_OPENED` on co
84
85
 
85
86
  Close codes map to labels via `WS_CLOSE_REASONS` — `1000 → NORMAL_CLOSURE`, `1006 → ABNORMAL_CLOSURE`, `1009 → MESSAGE_TOO_BIG`, `4000 → IDLE_TIMEOUT`, etc. A socket `error` emits a synthetic `CONNECTION_CLOSED` with `wsCloseReason: INTERNAL_ERROR` and `metadata.event: CONNECTION_ERROR`.
86
87
 
87
- Exported symbols: `AforoWsBilling` (with `wrapServer` / `trackConnection` / `shutdown`), the `WS_CLOSE_REASONS` map, and the `AforoWsConfig` / `WrapServerOptions` types.
88
+ Exported symbols: `AforoWsBilling` (with `wrapServer` / `trackConnection` / `shutdown` / `droppedCount`), the `WS_CLOSE_REASONS` map, `DEFAULT_PRODUCT_TYPE`, and the `AforoWsConfig` / `WrapServerOptions` / `TrackConnectionOptions` / `WsUsageEvent` / `DropReason` types.
89
+
90
+ ## Execution status
91
+
92
+ 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.
93
+
94
+ `executionStatus` is the outcome of the **connection**: it is set on the closing event only — `CONNECTION_CLOSED`, and the synthetic close emitted on a socket error. `CONNECTION_OPENED` and per-frame `MESSAGE` events never carry it, so a status such as `ERROR` for an abnormal close doesn't make every frame bill at the `ERROR` weight. (This matches the Python SDK; Go and Java take a status per call.)
95
+
96
+ WebSocket frames carry no success or failure signal, so the SDK never sets `executionStatus` on its own — not even for abnormal closes or socket errors. Pass it yourself, as a string or as a synchronous function called with the closing event:
97
+
98
+ ```typescript
99
+ billing.wrapServer(wss, {
100
+ extractCustomerId: (req) => req.headers['x-customer-id'] as string,
101
+ // (event, req) => status | undefined
102
+ executionStatus: (event, req) =>
103
+ event.wsFrameType === 'CLOSE' && event.wsCloseReason !== 'NORMAL_CLOSURE' ? 'ERROR' : undefined,
104
+ });
105
+
106
+ billing.trackConnection(socket, { customerId: 'cust_42', executionStatus: 'SUCCESS' });
107
+ ```
108
+
109
+ ## Dropped events
110
+
111
+ The SDK does not throw into your socket 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.
112
+
113
+ | Reason | When |
114
+ |---|---|
115
+ | `invalid` | The event failed a client-side check and was never buffered or sent: `customerId` longer than 64 characters or `productType` longer than 20 (every event of that connection is counted). Values are never truncated. 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. |
116
+ | `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. |
117
+ | `retry_exhausted` | Network errors, 408, 429 or 5xx persisted through 3 attempts (1s / 2s / 4s backoff; `Retry-After` honoured up to 30 s). |
118
+
119
+ A call with no resolvable customer id is not billable; it is skipped and is not counted as a drop.
120
+
121
+ ```ts
122
+ const billing = new AforoWsBilling({
123
+ // …
124
+ onDrop: (events, reason) => deadLetterQueue.push({ reason, events }),
125
+ });
126
+ ```
88
127
 
89
128
  ## Walk me through it
90
129
 
package/dist/index.d.ts CHANGED
@@ -45,15 +45,77 @@ export interface AforoWsConfig {
45
45
  perFrameEvents?: boolean;
46
46
  /** Callback for terminal flush failures. */
47
47
  onError?: (error: Error) => void;
48
+ /**
49
+ * Opt-in hook receiving events that were permanently dropped (retry
50
+ * exhaustion, a rejection by the ingestor, or a failed client-side check —
51
+ * see DropReason). Events keep their idempotency keys,
52
+ * so persisting and re-submitting them after recovery is dedup-safe.
53
+ * Exceptions thrown by the hook are swallowed. Default: none (drops are
54
+ * still counted in droppedCount and WARN-logged).
55
+ */
56
+ onDrop?: (events: WsUsageEvent[], reason: DropReason) => void;
48
57
  }
58
+ /**
59
+ * Why events were permanently dropped. `retry_exhausted`: the ingestor stayed
60
+ * unreachable / kept answering 408, 429 or 5xx. `rejected`: the ingestor
61
+ * refused the batch (non-retryable 4xx) or individual events of an accepted
62
+ * batch. `invalid`: the event failed a client-side check and was never sent.
63
+ * The buffer is unbounded (drained at flush start), so unlike the core SDK
64
+ * there is no 'overflow' reason here.
65
+ */
66
+ export type DropReason = 'retry_exhausted' | 'rejected' | 'invalid';
49
67
  export interface WrapServerOptions {
50
68
  /** Extract Aforo customer ID from the upgrade request. */
51
69
  extractCustomerId: (req: any) => string | undefined;
52
70
  /** Optional per-connection metadata (product-defined tags). */
53
71
  extractMetadata?: (req: any) => Record<string, unknown> | undefined;
72
+ /**
73
+ * Optional outcome of the request (used by OUTCOME_BASED pricing, which bills
74
+ * each event at the weight set for its status). Events without a status bill
75
+ * at full price. Trimmed and upper-cased by the SDK; blank is treated as absent.
76
+ * Accepted values: SUCCESS, PARTIAL, TIMEOUT, ERROR, VALIDATION_FAILED, FAILED,
77
+ * FAILURE, CANCELLED, PENDING, BLOCKED, HITL_REQUIRED (max 20 chars). Any other
78
+ * value — or a Promise from an async resolver — is reported through `onError`
79
+ * and left off the event (the event is still sent), because the server would
80
+ * reject the event.
81
+ *
82
+ * This is the outcome of the CONNECTION: it is set on the closing event only
83
+ * (CONNECTION_CLOSED, and the synthetic close emitted on a socket error).
84
+ * CONNECTION_OPENED and per-frame MESSAGE events never carry it, so a status
85
+ * meant for the connection (e.g. ERROR on an abnormal close) can't zero out
86
+ * every frame under OUTCOME_BASED pricing. WebSocket frames carry no
87
+ * success/failure signal, so the SDK never derives one: pass a fixed string,
88
+ * or a synchronous function called with the closing event (and the upgrade
89
+ * request) that returns the status or undefined.
90
+ */
91
+ executionStatus?: string | ((event: WsUsageEvent, req: any) => string | undefined);
54
92
  /** Product type for connections accepted by this server. Default: the client-level `productType`. */
55
93
  productType?: string;
56
94
  }
95
+ /** Options for trackConnection(). */
96
+ export interface TrackConnectionOptions {
97
+ customerId: string;
98
+ metadata?: Record<string, unknown>;
99
+ /**
100
+ * Optional outcome of the request (used by OUTCOME_BASED pricing, which bills
101
+ * each event at the weight set for its status). Events without a status bill
102
+ * at full price. Trimmed and upper-cased by the SDK; blank is treated as absent.
103
+ * Accepted values: SUCCESS, PARTIAL, TIMEOUT, ERROR, VALIDATION_FAILED, FAILED,
104
+ * FAILURE, CANCELLED, PENDING, BLOCKED, HITL_REQUIRED (max 20 chars). Any other
105
+ * value — or a Promise from an async resolver — is reported through `onError`
106
+ * and left off the event (the event is still sent), because the server would
107
+ * reject the event.
108
+ *
109
+ * The outcome of the connection, set on its closing event only
110
+ * (CONNECTION_CLOSED, or the synthetic close on a socket error) — never on
111
+ * CONNECTION_OPENED or per-frame events. Never derived by the SDK: pass a
112
+ * fixed string, or a synchronous function called with the closing event that
113
+ * returns the status or undefined.
114
+ */
115
+ executionStatus?: string | ((event: WsUsageEvent) => string | undefined);
116
+ /** Product type for this connection's events (trimmed + uppercased). Default: the client-level `productType`. */
117
+ productType?: string;
118
+ }
57
119
  /** Minimal WebSocket surface — matches `ws` WebSocket, Fastify socket, Deno, Bun. */
58
120
  interface MinimalWs {
59
121
  on(event: 'message', fn: (data: any, isBinary?: boolean) => void): void;
@@ -68,6 +130,24 @@ interface MinimalWss {
68
130
  }
69
131
  /** Default top-level `productType` for this SDK. */
70
132
  export declare const DEFAULT_PRODUCT_TYPE = "WEBSOCKET_API";
133
+ export interface WsUsageEvent {
134
+ customerId: string;
135
+ metricName: string;
136
+ quantity: number;
137
+ occurredAt: string;
138
+ idempotencyKey: string;
139
+ productType: string;
140
+ wsConnectionId: string;
141
+ wsDirection: 'CLIENT_TO_SERVER' | 'SERVER_TO_CLIENT';
142
+ wsFrameType: 'TEXT' | 'BINARY' | 'PING' | 'PONG' | 'CLOSE';
143
+ wsCloseReason?: string;
144
+ messageCount: number;
145
+ dataBytes: number;
146
+ executionDurationMs: number;
147
+ metadata?: Record<string, unknown>;
148
+ /** Normalized (trimmed, upper-cased) execution status; omitted when not set. */
149
+ executionStatus?: string;
150
+ }
71
151
  export declare class AforoWsBilling {
72
152
  private readonly config;
73
153
  private readonly productType;
@@ -75,23 +155,46 @@ export declare class AforoWsBilling {
75
155
  private readonly flushIntervalMs;
76
156
  private readonly perFrameEvents;
77
157
  private readonly onError;
158
+ private readonly onDrop?;
78
159
  private buffer;
79
160
  private flushTimer;
161
+ private dropped;
162
+ /** Invalid-event count per field, for WARN throttling. */
163
+ private readonly invalidSeen;
80
164
  constructor(config: AforoWsConfig);
81
165
  /** Wrap a `ws` WebSocketServer (or any object emitting 'connection' events). */
82
166
  wrapServer(wss: MinimalWss, options: WrapServerOptions): void;
83
167
  /** Track a single WebSocket connection. Returns an unsubscribe function. */
84
- trackConnection(ws: MinimalWs, opts: {
85
- customerId: string;
86
- metadata?: Record<string, unknown>;
87
- productType?: string;
88
- }): () => void;
168
+ trackConnection(ws: MinimalWs, opts: TrackConnectionOptions): () => void;
89
169
  private push;
90
170
  private flush;
91
171
  private send;
92
- /** A 202 can still carry per-event rejections: `{accepted, duplicates, failed, errors:[{index, message}]}`. */
93
- private reportPartialFailures;
172
+ /**
173
+ * A 2xx can still carry per-event rejections:
174
+ * `{accepted, duplicates, failed, errors:[{index, message}]}`. Events the
175
+ * response names by index are dropped with reason 'rejected'; a `failed`
176
+ * count the response does not attribute to an index is counted only.
177
+ */
178
+ private handlePartialFailures;
179
+ /** Invoke onError; a hook bug must never break metering or flushing. */
180
+ private reportError;
181
+ /** Number of events permanently dropped since this instance was created. */
182
+ get droppedCount(): number;
183
+ /**
184
+ * Account for permanently lost events: bump the counter, WARN-log, and
185
+ * invoke the opt-in onDrop hook. The buffer is drained at flush start, so
186
+ * drops here are bounded by flush cadence — no log throttle needed.
187
+ */
188
+ private recordDrop;
189
+ /**
190
+ * Account for an event that failed a client-side check: never buffered,
191
+ * never sent. Runs on the hot path, so the WARN is throttled per field
192
+ * (first occurrence, then every 1000th).
193
+ */
194
+ private recordInvalid;
195
+ private notifyDrop;
94
196
  private startTimer;
197
+ /** Flush any buffered events and stop the background timer. Call before process exit. */
95
198
  shutdown(): Promise<void>;
96
199
  }
97
200
  export declare const WS_CLOSE_REASONS: Record<number, string>;
package/dist/index.js CHANGED
@@ -31,7 +31,7 @@
31
31
  Object.defineProperty(exports, "__esModule", { value: true });
32
32
  exports.WS_CLOSE_REASONS = exports.AforoWsBilling = exports.DEFAULT_PRODUCT_TYPE = void 0;
33
33
  const node_crypto_1 = require("node:crypto");
34
- const SDK_VERSION = '1.0.0';
34
+ const SDK_VERSION = '1.2.1';
35
35
  /** Default top-level `productType` for this SDK. */
36
36
  exports.DEFAULT_PRODUCT_TYPE = 'WEBSOCKET_API';
37
37
  /** Upper bound on a server-requested Retry-After wait. */
@@ -41,6 +41,7 @@ const MAX_BATCH_EVENTS = 1000;
41
41
  /** usage-ingestor limits (IngestUsageEventRequest). */
42
42
  const MAX_CUSTOMER_ID = 64;
43
43
  const MAX_IDEMPOTENCY_KEY = 255;
44
+ const MAX_PRODUCT_TYPE = 20;
44
45
  // Close reason code → descriptor enum label
45
46
  const CLOSE_REASONS = {
46
47
  1000: 'NORMAL_CLOSURE',
@@ -63,8 +64,12 @@ class AforoWsBilling {
63
64
  flushIntervalMs;
64
65
  perFrameEvents;
65
66
  onError;
67
+ onDrop;
66
68
  buffer = [];
67
69
  flushTimer = null;
70
+ dropped = 0;
71
+ /** Invalid-event count per field, for WARN throttling. */
72
+ invalidSeen = new Map();
68
73
  constructor(config) {
69
74
  this.config = {
70
75
  tenantId: config.tenantId,
@@ -77,6 +82,7 @@ class AforoWsBilling {
77
82
  this.flushIntervalMs = config.flushIntervalMs ?? 3000;
78
83
  this.perFrameEvents = config.perFrameEvents ?? false;
79
84
  this.onError = config.onError ?? ((err) => console.error('[aforo-ws]', err.message));
85
+ this.onDrop = config.onDrop;
80
86
  this.startTimer();
81
87
  }
82
88
  /** Wrap a `ws` WebSocketServer (or any object emitting 'connection' events). */
@@ -86,17 +92,20 @@ class AforoWsBilling {
86
92
  if (!customerId || !customerId.trim())
87
93
  return; // no customer resolved → skip metering
88
94
  const metadata = options.extractMetadata?.(req);
89
- this.trackConnection(ws, { customerId, metadata, productType: options.productType });
95
+ const status = options.executionStatus;
96
+ this.trackConnection(ws, {
97
+ customerId,
98
+ metadata,
99
+ executionStatus: typeof status === 'function' ? (event) => status(event, req) : status,
100
+ productType: options.productType,
101
+ });
90
102
  });
91
103
  }
92
104
  /** Track a single WebSocket connection. Returns an unsubscribe function. */
93
105
  trackConnection(ws, opts) {
94
- if (!opts.customerId || !opts.customerId.trim())
95
- return () => { };
96
- if (opts.customerId.length > MAX_CUSTOMER_ID) {
97
- this.onError(new Error(`WebSocket metering: customerId longer than ${MAX_CUSTOMER_ID} chars; connection not metered`));
106
+ // No customer resolved — the connection is not billable, not a drop.
107
+ if (typeof opts.customerId !== 'string' || !opts.customerId.trim())
98
108
  return () => { };
99
- }
100
109
  const connectionId = (0, node_crypto_1.randomUUID)();
101
110
  const productType = normalizeProductType(opts.productType) ?? this.productType;
102
111
  const start = Date.now();
@@ -155,6 +164,8 @@ class AforoWsBilling {
155
164
  }
156
165
  return origSend(data, cb);
157
166
  };
167
+ // opts.executionStatus is the connection's outcome: only the closing events
168
+ // below carry it (same as the Python SDK), never OPENED or per-frame events.
158
169
  ws.on('close', (code) => {
159
170
  // Emit CONNECTION_CLOSED with aggregated counters — this is the billing anchor.
160
171
  this.push({
@@ -176,7 +187,7 @@ class AforoWsBilling {
176
187
  recvBytes,
177
188
  closeCode: code,
178
189
  },
179
- });
190
+ }, opts.executionStatus);
180
191
  });
181
192
  ws.on('error', (err) => {
182
193
  this.push({
@@ -190,13 +201,13 @@ class AforoWsBilling {
190
201
  dataBytes: sentBytes + recvBytes,
191
202
  executionDurationMs: Date.now() - start,
192
203
  metadata: { ...(opts.metadata ?? {}), event: 'CONNECTION_ERROR', error: err.message },
193
- });
204
+ }, opts.executionStatus);
194
205
  });
195
206
  return () => {
196
207
  // Tracking lifetime is managed by the close handler — no manual cleanup needed.
197
208
  };
198
209
  }
199
- push(partial) {
210
+ push(partial, executionStatus) {
200
211
  const now = new Date();
201
212
  const event = {
202
213
  ...partial,
@@ -205,7 +216,8 @@ class AforoWsBilling {
205
216
  : 'websocket_api.message',
206
217
  quantity: 1,
207
218
  occurredAt: now.toISOString(),
208
- // Tail-trimmed to the ingestor's limit, keeping the unique millis:random suffix.
219
+ // Minted once, here. SDK-generated; tail-trimmed to the ingestor's limit,
220
+ // keeping the unique millis:random suffix.
209
221
  idempotencyKey: `ws:${this.config.tenantId}:${partial.wsConnectionId}:${partial.wsFrameType}:${now.getTime()}:${randomSuffix()}`.slice(-MAX_IDEMPOTENCY_KEY),
210
222
  metadata: {
211
223
  ...(partial.metadata ?? {}),
@@ -213,6 +225,20 @@ class AforoWsBilling {
213
225
  productId: this.config.productId,
214
226
  },
215
227
  };
228
+ const checked = checkExecutionStatus(resolveExecutionStatus(executionStatus, event));
229
+ if (checked.problem)
230
+ this.reportError(new Error(`[aforo-ws] ${checked.problem}`));
231
+ if (checked.status)
232
+ event.executionStatus = checked.status;
233
+ // An event the ingestor would reject is not sent (one bad event fails its
234
+ // whole batch): it is dropped here with reason 'invalid', never thrown
235
+ // into the socket handlers.
236
+ const invalid = tooLong('customerId', event.customerId, MAX_CUSTOMER_ID) ??
237
+ tooLong('productType', event.productType, MAX_PRODUCT_TYPE);
238
+ if (invalid) {
239
+ this.recordInvalid(event, invalid);
240
+ return;
241
+ }
216
242
  this.buffer.push(event);
217
243
  if (this.buffer.length >= this.flushCount) {
218
244
  void this.flush();
@@ -232,9 +258,11 @@ class AforoWsBilling {
232
258
  const body = JSON.stringify({ events: batch });
233
259
  const maxRetries = 3;
234
260
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
235
- let delayMs = Math.pow(2, attempt - 1) * 1000;
261
+ let delayMs = Math.pow(2, attempt - 1) * 1000; // 1s, 2s, 4s
262
+ let res;
263
+ let networkError;
236
264
  try {
237
- const res = await fetch(this.config.ingestorUrl.replace(/\/$/, '') + '/v1/ingest/batch', {
265
+ res = await fetch(this.config.ingestorUrl.replace(/\/$/, '') + '/v1/ingest/batch', {
238
266
  method: 'POST',
239
267
  headers: {
240
268
  'Content-Type': 'application/json',
@@ -243,33 +271,101 @@ class AforoWsBilling {
243
271
  },
244
272
  body,
245
273
  });
274
+ }
275
+ catch (err) {
276
+ networkError = err;
277
+ }
278
+ if (res) {
246
279
  if (res.ok) {
247
- await this.reportPartialFailures(res);
280
+ await this.handlePartialFailures(res, batch);
248
281
  return;
249
282
  }
250
283
  if (!isRetryableStatus(res.status)) {
251
- const { details } = await readErrorMessages(res);
252
- this.onError(new Error(`WebSocket metering batch rejected with HTTP ${res.status}${details ? ` — ${details}` : ''} (dropped ${batch.length} events, not retried)`));
284
+ // 4xx other than 408/429: the same body would be refused again.
285
+ const { details } = await readBatchResult(res, batch.length);
286
+ this.recordDrop(batch, 'rejected');
287
+ this.reportError(new Error(`WebSocket metering batch rejected with HTTP ${res.status}${details ? ` — ${details}` : ''} (dropped ${batch.length} events, not retried)`));
253
288
  return;
254
289
  }
255
290
  delayMs = parseRetryAfter(res) ?? delayMs;
256
291
  }
257
- catch (err) {
258
- if (attempt === maxRetries) {
259
- this.onError(err);
260
- return;
261
- }
292
+ else if (attempt === maxRetries) {
293
+ this.recordDrop(batch, 'retry_exhausted');
294
+ this.reportError(networkError ?? new Error('WebSocket metering request failed'));
295
+ return;
262
296
  }
263
297
  if (attempt < maxRetries)
264
298
  await sleep(delayMs);
265
299
  }
266
- this.onError(new Error(`WebSocket metering flush failed after ${maxRetries} attempts (dropped ${batch.length} events)`));
300
+ // Not re-queued: dropping avoids unbounded memory growth.
301
+ this.recordDrop(batch, 'retry_exhausted');
302
+ this.reportError(new Error(`WebSocket metering flush failed after ${maxRetries} attempts (dropped ${batch.length} events)`));
267
303
  }
268
- /** A 202 can still carry per-event rejections: `{accepted, duplicates, failed, errors:[{index, message}]}`. */
269
- async reportPartialFailures(res) {
270
- const { failed, details } = await readErrorMessages(res);
271
- if (failed && failed > 0) {
272
- this.onError(new Error(`Aforo ingestor rejected ${failed} event(s)${details ? ` — ${details}` : ''}`));
304
+ /**
305
+ * A 2xx can still carry per-event rejections:
306
+ * `{accepted, duplicates, failed, errors:[{index, message}]}`. Events the
307
+ * response names by index are dropped with reason 'rejected'; a `failed`
308
+ * count the response does not attribute to an index is counted only.
309
+ */
310
+ async handlePartialFailures(res, batch) {
311
+ const { failed, details, indexes } = await readBatchResult(res, batch.length);
312
+ if (failed <= 0)
313
+ return;
314
+ if (indexes.length > 0)
315
+ this.recordDrop(indexes.map((i) => batch[i]), 'rejected');
316
+ const unattributed = Math.min(failed, batch.length) - indexes.length;
317
+ if (unattributed > 0) {
318
+ this.dropped += unattributed;
319
+ console.warn(`[aforo-ws] Dropped ${unattributed} event(s) — rejected, not identified by the ingestor (${this.dropped} total dropped).`);
320
+ }
321
+ this.reportError(new Error(`Aforo ingestor rejected ${failed} event(s)${details ? ` — ${details}` : ''}`));
322
+ }
323
+ /** Invoke onError; a hook bug must never break metering or flushing. */
324
+ reportError(err) {
325
+ try {
326
+ this.onError(err);
327
+ }
328
+ catch {
329
+ // ignore
330
+ }
331
+ }
332
+ /** Number of events permanently dropped since this instance was created. */
333
+ get droppedCount() {
334
+ return this.dropped;
335
+ }
336
+ /**
337
+ * Account for permanently lost events: bump the counter, WARN-log, and
338
+ * invoke the opt-in onDrop hook. The buffer is drained at flush start, so
339
+ * drops here are bounded by flush cadence — no log throttle needed.
340
+ */
341
+ recordDrop(events, reason) {
342
+ this.dropped += events.length;
343
+ console.warn(`[aforo-ws] Dropped ${events.length} event(s) — ${reason} (${this.dropped} total dropped).`);
344
+ this.notifyDrop(events, reason);
345
+ }
346
+ /**
347
+ * Account for an event that failed a client-side check: never buffered,
348
+ * never sent. Runs on the hot path, so the WARN is throttled per field
349
+ * (first occurrence, then every 1000th).
350
+ */
351
+ recordInvalid(event, invalid) {
352
+ this.dropped += 1;
353
+ const seen = (this.invalidSeen.get(invalid.field) ?? 0) + 1;
354
+ this.invalidSeen.set(invalid.field, seen);
355
+ if (seen === 1 || seen % 1000 === 0) {
356
+ console.warn(`[aforo-ws] Dropped 1 event — invalid: ${invalid.problem} ` +
357
+ `(${seen} for ${invalid.field}, ${this.dropped} total dropped).`);
358
+ }
359
+ this.notifyDrop([event], 'invalid');
360
+ }
361
+ notifyDrop(events, reason) {
362
+ if (!this.onDrop)
363
+ return;
364
+ try {
365
+ this.onDrop(events, reason);
366
+ }
367
+ catch {
368
+ // A hook bug must never break metering or flushing.
273
369
  }
274
370
  }
275
371
  startTimer() {
@@ -277,12 +373,10 @@ class AforoWsBilling {
277
373
  return;
278
374
  this.flushTimer = setInterval(() => { void this.flush(); }, this.flushIntervalMs);
279
375
  // Unref so the background timer never blocks host-process exit (final flush still needs shutdown()).
280
- if (this.flushTimer && typeof this.flushTimer === 'object' && 'unref' in this.flushTimer) {
281
- this.flushTimer.unref();
282
- }
283
376
  if (typeof this.flushTimer.unref === 'function')
284
377
  this.flushTimer.unref();
285
378
  }
379
+ /** Flush any buffered events and stop the background timer. Call before process exit. */
286
380
  async shutdown() {
287
381
  if (this.flushTimer) {
288
382
  clearInterval(this.flushTimer);
@@ -306,6 +400,51 @@ function estimateBytes(data) {
306
400
  return data.reduce((s, d) => s + estimateBytes(d), 0);
307
401
  return 0;
308
402
  }
403
+ /** Statuses the Aforo ingestor accepts (contract/ingest-contract.json, max 20 chars). */
404
+ const CANONICAL_EXECUTION_STATUSES = new Set([
405
+ 'SUCCESS', 'PARTIAL', 'TIMEOUT', 'ERROR', 'VALIDATION_FAILED', 'FAILED',
406
+ 'FAILURE', 'CANCELLED', 'PENDING', 'BLOCKED', 'HITL_REQUIRED',
407
+ ]);
408
+ /**
409
+ * Check a caller-supplied status. `{ status }` when canonical; `{ problem }`
410
+ * when it has to be left off the event (an unknown value would make the
411
+ * ingestor reject the event; a Promise from an async resolver can't be
412
+ * awaited on this path); `{}` when blank or absent.
413
+ */
414
+ function checkExecutionStatus(value) {
415
+ if (value !== null && typeof value === 'object' && typeof value.then === 'function') {
416
+ // Swallow a later rejection so it doesn't surface as an unhandled rejection.
417
+ value.then(undefined, () => { });
418
+ return { problem: 'executionStatus resolver returned a Promise — resolvers must be synchronous; field omitted' };
419
+ }
420
+ const status = normalizeExecutionStatus(value);
421
+ if (!status)
422
+ return {};
423
+ if (!CANONICAL_EXECUTION_STATUSES.has(status)) {
424
+ return {
425
+ problem: `unknown executionStatus "${status.slice(0, 40)}" — field omitted. ` +
426
+ `Expected one of: ${[...CANONICAL_EXECUTION_STATUSES].join(', ')}`,
427
+ };
428
+ }
429
+ return { status };
430
+ }
431
+ /** Trim + upper-case; blank/absent → undefined (key omitted from the wire body). */
432
+ function normalizeExecutionStatus(value) {
433
+ if (typeof value !== 'string')
434
+ return undefined;
435
+ const trimmed = value.trim();
436
+ return trimmed ? trimmed.toUpperCase() : undefined;
437
+ }
438
+ function resolveExecutionStatus(option, event) {
439
+ if (typeof option !== 'function')
440
+ return option;
441
+ try {
442
+ return option(event);
443
+ }
444
+ catch {
445
+ return undefined; // a resolver bug must never break metering
446
+ }
447
+ }
309
448
  /** Trim + uppercase a product type; blank/non-string → undefined (unknown values pass through). */
310
449
  function normalizeProductType(value) {
311
450
  if (typeof value !== 'string')
@@ -313,6 +452,17 @@ function normalizeProductType(value) {
313
452
  const trimmed = value.trim();
314
453
  return trimmed ? trimmed.toUpperCase() : undefined;
315
454
  }
455
+ /** The offending value for a WARN line, cut to 80 chars. */
456
+ function preview(value) {
457
+ const text = typeof value === 'string' ? value : String(value);
458
+ return JSON.stringify(text.length > 80 ? `${text.slice(0, 80)}…` : text);
459
+ }
460
+ /** Limits mirror the ingestor's IngestUsageEventRequest; over-limit values are never truncated. */
461
+ function tooLong(field, value, max) {
462
+ if (typeof value !== 'string' || value.length <= max)
463
+ return undefined;
464
+ return { field, problem: `${field} is ${value.length} chars, limit ${max}: ${preview(value)}` };
465
+ }
316
466
  /** Network errors, 408, 429 and 5xx are transient; every other 4xx (400/401/403/422...) is not. */
317
467
  function isRetryableStatus(status) {
318
468
  return status === 408 || status === 429 || status >= 500;
@@ -330,19 +480,30 @@ function parseRetryAfter(res) {
330
480
  return Math.min(Math.max(date - Date.now(), 0), MAX_RETRY_AFTER_MS);
331
481
  return undefined;
332
482
  }
333
- /** Reads the ingestor's `errors[].message` entries (first 5) from a response body, if any. */
334
- async function readErrorMessages(res) {
483
+ /**
484
+ * Reads the ingestor's batch response body, if any: the `failed` count, the
485
+ * first 5 `errors[].message` entries, and the batch indexes `errors[]` names.
486
+ */
487
+ async function readBatchResult(res, batchSize) {
488
+ const none = { failed: 0, details: '', indexes: [] };
335
489
  if (typeof res?.json !== 'function')
336
- return { details: '' };
490
+ return none;
337
491
  try {
338
- const body = await res.json();
339
- const details = Array.isArray(body?.errors)
340
- ? body.errors.slice(0, 5).map((e) => `#${e?.index}: ${e?.message}`).join('; ')
492
+ const body = unwrapEnvelope(await res.json());
493
+ const errors = Array.isArray(body?.errors) ? body.errors : undefined;
494
+ const details = errors
495
+ ? errors.slice(0, 5).map((e) => `#${e?.index}: ${e?.message}`).join('; ')
341
496
  : typeof body?.message === 'string' ? body.message : '';
342
- return { failed: typeof body?.failed === 'number' ? body.failed : undefined, details };
497
+ const indexes = [
498
+ ...new Set((errors ?? [])
499
+ .map((e) => e?.index)
500
+ .filter((i) => Number.isInteger(i) && i >= 0 && i < batchSize)),
501
+ ].sort((a, b) => a - b);
502
+ const failed = typeof body?.failed === 'number' && body.failed > 0 ? body.failed : 0;
503
+ return { failed, details, indexes };
343
504
  }
344
505
  catch {
345
- return { details: '' }; // Non-JSON or empty body — nothing to report.
506
+ return none; // Non-JSON or empty body — nothing to report.
346
507
  }
347
508
  }
348
509
  function randomSuffix() {
@@ -352,3 +513,11 @@ function sleep(ms) {
352
513
  return new Promise((r) => setTimeout(r, ms));
353
514
  }
354
515
  exports.WS_CLOSE_REASONS = CLOSE_REASONS;
516
+ /**
517
+ * The ingestor wraps every 2xx JSON body in `{success, data, meta}`. Returns
518
+ * the inner `data` object when present, else the body unchanged (bare shape).
519
+ */
520
+ function unwrapEnvelope(body) {
521
+ const data = body && typeof body === 'object' && !Array.isArray(body) ? body.data : undefined;
522
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : body;
523
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aforoai/ws-metering",
3
- "version": "1.0.0",
3
+ "version": "1.2.1",
4
4
  "description": "Aforo WebSocket Metering SDK — wraps ws/Fastify-WebSocket/Socket.io connections to meter frames, bytes, and connection duration.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",