@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 +43 -4
- package/dist/index.d.ts +110 -7
- package/dist/index.js +207 -38
- package/package.json +1 -1
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.
|
|
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
|
-
> **
|
|
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
|
|
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
|
-
/**
|
|
93
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
280
|
+
await this.handlePartialFailures(res, batch);
|
|
248
281
|
return;
|
|
249
282
|
}
|
|
250
283
|
if (!isRetryableStatus(res.status)) {
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
/**
|
|
334
|
-
|
|
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
|
|
490
|
+
return none;
|
|
337
491
|
try {
|
|
338
|
-
const body = await res.json();
|
|
339
|
-
const
|
|
340
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|