@aforoai/metering 1.0.0 → 1.1.2
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 +37 -9
- package/dist/cjs/buffer.d.ts +7 -1
- package/dist/cjs/buffer.d.ts.map +1 -1
- package/dist/cjs/buffer.js +11 -3
- package/dist/cjs/buffer.js.map +1 -1
- package/dist/cjs/client.d.ts +27 -11
- package/dist/cjs/client.d.ts.map +1 -1
- package/dist/cjs/client.js +212 -55
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/idempotency.d.ts +9 -9
- package/dist/cjs/idempotency.js +9 -9
- package/dist/cjs/index.d.ts +8 -8
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +20 -19
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/limits.d.ts +47 -7
- package/dist/cjs/limits.d.ts.map +1 -1
- package/dist/cjs/limits.js +65 -10
- package/dist/cjs/limits.js.map +1 -1
- package/dist/cjs/middleware/common.d.ts +8 -2
- package/dist/cjs/middleware/common.d.ts.map +1 -1
- package/dist/cjs/middleware/common.js +12 -4
- package/dist/cjs/middleware/common.js.map +1 -1
- package/dist/cjs/middleware/express.d.ts +1 -1
- package/dist/cjs/middleware/express.d.ts.map +1 -1
- package/dist/cjs/middleware/express.js +8 -8
- package/dist/cjs/middleware/express.js.map +1 -1
- package/dist/cjs/middleware/fastify.d.ts +1 -1
- package/dist/cjs/middleware/fastify.d.ts.map +1 -1
- package/dist/cjs/middleware/fastify.js +8 -8
- package/dist/cjs/middleware/fastify.js.map +1 -1
- package/dist/cjs/middleware/koa.d.ts +1 -1
- package/dist/cjs/middleware/koa.d.ts.map +1 -1
- package/dist/cjs/middleware/koa.js +8 -8
- package/dist/cjs/middleware/koa.js.map +1 -1
- package/dist/cjs/transport.d.ts +4 -2
- package/dist/cjs/transport.d.ts.map +1 -1
- package/dist/cjs/transport.js +75 -5
- package/dist/cjs/transport.js.map +1 -1
- package/dist/cjs/types.d.ts +53 -1
- package/dist/cjs/types.d.ts.map +1 -1
- package/dist/esm/buffer.js +11 -3
- package/dist/esm/buffer.js.map +1 -1
- package/dist/esm/client.js +209 -52
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/idempotency.js +9 -9
- package/dist/esm/index.js +7 -7
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/limits.js +62 -10
- package/dist/esm/limits.js.map +1 -1
- package/dist/esm/middleware/common.js +11 -4
- package/dist/esm/middleware/common.js.map +1 -1
- package/dist/esm/middleware/express.js +3 -3
- package/dist/esm/middleware/express.js.map +1 -1
- package/dist/esm/middleware/fastify.js +3 -3
- package/dist/esm/middleware/fastify.js.map +1 -1
- package/dist/esm/middleware/koa.js +3 -3
- package/dist/esm/middleware/koa.js.map +1 -1
- package/dist/esm/package.json +1 -0
- package/dist/esm/transport.js +75 -5
- package/dist/esm/transport.js.map +1 -1
- package/dist/types/buffer.d.ts +7 -1
- package/dist/types/buffer.d.ts.map +1 -1
- package/dist/types/client.d.ts +27 -11
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/idempotency.d.ts +9 -9
- package/dist/types/index.d.ts +8 -8
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/limits.d.ts +47 -7
- package/dist/types/limits.d.ts.map +1 -1
- package/dist/types/middleware/common.d.ts +8 -2
- package/dist/types/middleware/common.d.ts.map +1 -1
- package/dist/types/middleware/express.d.ts +1 -1
- package/dist/types/middleware/express.d.ts.map +1 -1
- package/dist/types/middleware/fastify.d.ts +1 -1
- package/dist/types/middleware/fastify.d.ts.map +1 -1
- package/dist/types/middleware/koa.d.ts +1 -1
- package/dist/types/middleware/koa.d.ts.map +1 -1
- package/dist/types/transport.d.ts +4 -2
- package/dist/types/transport.d.ts.map +1 -1
- package/dist/types/types.d.ts +53 -1
- package/dist/types/types.d.ts.map +1 -1
- package/package.json +22 -6
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @aforoai/metering
|
|
2
2
|
|
|
3
|
-
Track API usage from your Node service and send it to Aforo for billing. A buffered, batched, retrying client plus
|
|
3
|
+
Track API usage from your Node service and send it to Aforo for billing. A buffered, batched, retrying client plus Express / Fastify / Koa middleware — `track()` returns immediately and events flush in the background, so metering never sits in your request path.
|
|
4
4
|
|
|
5
|
-
**Version:** 1.
|
|
5
|
+
**Version:** 1.1.2 · Apache-2.0 · [Changelog](CHANGELOG.md) · [User guide](USER_GUIDE.md)
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -10,13 +10,13 @@ Track API usage from your Node service and send it to Aforo for billing. A buffe
|
|
|
10
10
|
npm i @aforoai/metering
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
> **
|
|
13
|
+
> **Install `1.1.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.1.2` is not on npm yet, install from source:
|
|
14
14
|
> ```bash
|
|
15
15
|
> git clone https://github.com/aforoai/SDKs.git
|
|
16
16
|
> cd SDKs/aforo-metering-sdks/node
|
|
17
17
|
> npm install && npm run build
|
|
18
|
-
> npm pack # produces aforo-metering-1.
|
|
19
|
-
> # then in your app: npm i /path/to/aforo-metering-1.
|
|
18
|
+
> npm pack # produces aforo-metering-1.1.2.tgz to install in your app
|
|
19
|
+
> # then in your app: npm i /path/to/aforo-metering-1.1.2.tgz
|
|
20
20
|
> ```
|
|
21
21
|
|
|
22
22
|
Requires Node >= 18 (uses the built-in `fetch`).
|
|
@@ -51,7 +51,7 @@ app.use(expressMiddleware({
|
|
|
51
51
|
|
|
52
52
|
The middleware hooks `res.on('finish')`, so it runs after the response is sent — zero added latency.
|
|
53
53
|
|
|
54
|
-
- **Metric:** `metricName` is a fixed name or a `(req, res) => string` resolver; the default is `"api_calls"` (exported as `DEFAULT_METRIC_NAME`). The metric must exist in your tenant's Aforo catalog: the ingestor rejects an unknown `metricName
|
|
54
|
+
- **Metric:** `metricName` is a fixed name or a `(req, res) => string` resolver; the default is `"api_calls"` (exported as `DEFAULT_METRIC_NAME`). The metric must exist in your tenant's Aforo catalog: the ingestor rejects an event with an unknown `metricName` (the other events in the batch are still ingested), and the SDK reports it as a drop with reason `rejected`.
|
|
55
55
|
- **Customer:** `customerId` is a fixed id or a `(req) => string | null` resolver; the default is `req.user.id` / `req.user.sub`, then the `X-Customer-Id` header. The caller's `X-Api-Key` header is never used — it is the end user's secret, not a customer id. Requests with no customer are not metered.
|
|
56
56
|
- **CORS preflights** (`OPTIONS`) are never metered, nor are requests whose quantity is `<= 0`.
|
|
57
57
|
- **HTTP context:** each event carries top-level `endpointPath` (path without query string, max 512 chars), `httpMethod`, `statusCode` and `responseTimeMs`, plus `productType`.
|
|
@@ -68,12 +68,40 @@ The middleware hooks `res.on('finish')`, so it runs after the response is sent
|
|
|
68
68
|
| `flushCount` | `number` | `50` | Buffered events that trigger a flush (and events per request; capped at 1000). |
|
|
69
69
|
| `flushInterval` | `number` (ms) | `5000` | Background flush cadence. |
|
|
70
70
|
| `maxQueueSize` | `number` | `10000` | Ring-buffer cap; oldest events drop on overflow. |
|
|
71
|
-
| `
|
|
71
|
+
| `onDrop` | `(events, reason) => void` | none | Called with events the SDK is about to lose. See [Dropped events](#dropped-events). |
|
|
72
|
+
| `maxRetries` | `number` | `3` | Retries on 5xx, 408, 429 and network errors (exponential backoff; `Retry-After` honoured on 429). Other 4xx are not retried. |
|
|
72
73
|
| `retryBaseMs` | `number` (ms) | `1000` | Base backoff delay. |
|
|
73
74
|
| `timeout` | `number` (ms) | `10000` | Per-request timeout. |
|
|
74
75
|
| `shutdownTimeoutMs` | `number` (ms) | `5000` | Max time `shutdown()` waits for a final flush. |
|
|
75
76
|
|
|
76
|
-
`track(event)` — `event: TrackEvent`: `customerId` (required, non-blank), `metricName` (required, non-blank), `quantity` (default 1, must be > 0
|
|
77
|
+
`track(event)` — `event: TrackEvent`: `customerId` (required, non-blank), `metricName` (required, non-blank), `quantity` (default 1, must be > 0), `productType` (overrides the client default for this event), `idempotencyKey` (a fresh random UUID v4 per event if omitted — pass your own key if you want the ingestor to deduplicate retries), `occurredAt` (ISO string or epoch ms; defaults to now), `executionStatus` (optional, see below), `endpointPath` / `httpMethod` / `statusCode` / `responseTimeMs` (optional HTTP context), `metadata` (string/number/boolean map).
|
|
78
|
+
|
|
79
|
+
`track()` throws only when the client is shut down. It never throws for the content of an event — see [Dropped events](#dropped-events).
|
|
80
|
+
|
|
81
|
+
### Execution status
|
|
82
|
+
|
|
83
|
+
`executionStatus` feeds outcome-based pricing: an OUTCOME_BASED rate plan bills each event at the weight set for its status. Events without a status bill at full price.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
await aforo.track({ customerId: 'cust_123', metricName: 'api_calls', executionStatus: 'TIMEOUT' });
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The value is trimmed and upper-cased; blank is treated as absent. Accepted: `SUCCESS`, `PARTIAL`, `TIMEOUT`, `ERROR`, `VALIDATION_FAILED`, `FAILED`, `FAILURE`, `CANCELLED`, `PENDING`, `BLOCKED`, `HITL_REQUIRED`. Any other value (or one longer than 20 characters) is WARN-logged and left off the event; the event is still sent.
|
|
90
|
+
|
|
91
|
+
### Dropped events
|
|
92
|
+
|
|
93
|
+
Every event the SDK loses is counted in `client.droppedCount`, WARN-logged, and passed to the opt-in `onDrop(events, reason)` hook. Events keep their idempotency keys, so re-submitting them with `track()` is dedup-safe.
|
|
94
|
+
|
|
95
|
+
| `reason` | When |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `overflow` | The ring buffer was full; the oldest event was evicted. |
|
|
98
|
+
| `retry_exhausted` | A batch failed after all retries. |
|
|
99
|
+
| `rejected` | The ingestor refused the batch with a non-retryable 4xx, or refused these events individually inside a batch it otherwise accepted. |
|
|
100
|
+
| `invalid` | The event failed a client-side check at `track()` and was never buffered or sent. |
|
|
101
|
+
|
|
102
|
+
`invalid` covers what the ingestor would reject for any deployment: blank `customerId` / `metricName`, `quantity <= 0`, a `quantity` with more than 14 integer digits or 6 decimal places, a malformed `occurredAt`, and fields over the server's size limit (`customerId` 64, `metricName` 255, `idempotencyKey` 255, `productType` 20, `endpointPath` 512, `httpMethod` 16). Nothing you pass to `track()` or set in the middleware options is truncated or rounded. The one exception is what the middlewares read off the request: a path over 512 characters or a method over 16 is cut to the limit and the event is still sent (one WARN per label). The WARN names the field, the limit and the value; it is logged for the first invalid event and then every 1000th. Limits the server makes configurable (event age, clock skew, metadata size) are not checked client-side.
|
|
103
|
+
|
|
104
|
+
When a 2xx response reports failures without saying which events, the count is added to `droppedCount` and logged, and `onDrop` is not called. A failed session heartbeat is not a drop.
|
|
77
105
|
|
|
78
106
|
### Sessions
|
|
79
107
|
|
|
@@ -85,4 +113,4 @@ Step-by-step from install to a verified event in Aforo: see the **[User guide](U
|
|
|
85
113
|
|
|
86
114
|
## What this doesn't cover
|
|
87
115
|
|
|
88
|
-
This SDK only *emits* usage. Pricing, rate plans, and which `metricName`s are billable are configured in the Aforo console — this package does not create or validate them.
|
|
116
|
+
This SDK only *emits* usage. Pricing, rate plans, and which `metricName`s are billable are configured in the Aforo console — this package does not create or validate them. An event whose `metricName` isn't defined in your tenant is **rejected** by the ingestor, so define the metric before sending it.
|
package/dist/cjs/buffer.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ResolvedEvent } from './types';
|
|
1
|
+
import { ResolvedEvent } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Bounded ring buffer for usage events.
|
|
4
4
|
* Thread-safe for Node.js single-threaded event loop.
|
|
@@ -13,6 +13,12 @@ export declare class RingBuffer {
|
|
|
13
13
|
constructor(capacity?: number);
|
|
14
14
|
/** Add an event to the buffer. Returns true if added, false if overflow (oldest dropped). */
|
|
15
15
|
push(event: ResolvedEvent): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Add an event to the buffer. On overflow, returns the evicted OLDEST
|
|
18
|
+
* event so the caller can surface the drop; returns null when no
|
|
19
|
+
* eviction occurred.
|
|
20
|
+
*/
|
|
21
|
+
pushEvict(event: ResolvedEvent): ResolvedEvent | null;
|
|
16
22
|
/** Drain all events from the buffer. Returns a new array and clears the buffer. */
|
|
17
23
|
drain(): ResolvedEvent[];
|
|
18
24
|
/** Drain up to `max` events from the front of the buffer. */
|
package/dist/cjs/buffer.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"buffer.d.ts","sourceRoot":"","sources":["../../src/buffer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"buffer.d.ts","sourceRoot":"","sources":["../../src/buffer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE3C;;;;GAIG;AACH,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAkB;IACxC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,IAAI,CAAK;IACjB,OAAO,CAAC,IAAI,CAAK;IACjB,OAAO,CAAC,KAAK,CAAK;gBAEN,QAAQ,GAAE,MAAe;IAMrC,6FAA6F;IAC7F,IAAI,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO;IAInC;;;;OAIG;IACH,SAAS,CAAC,KAAK,EAAE,aAAa,GAAG,aAAa,GAAG,IAAI;IAiBrD,mFAAmF;IACnF,KAAK,IAAI,aAAa,EAAE;IAgBxB,6DAA6D;IAC7D,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,aAAa,EAAE;IAgBvC,8CAA8C;IAC9C,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,mCAAmC;IACnC,IAAI,OAAO,IAAI,OAAO,CAErB;IAED,yCAAyC;IACzC,IAAI,MAAM,IAAI,OAAO,CAEpB;CACF"}
|
package/dist/cjs/buffer.js
CHANGED
|
@@ -18,17 +18,25 @@ class RingBuffer {
|
|
|
18
18
|
}
|
|
19
19
|
/** Add an event to the buffer. Returns true if added, false if overflow (oldest dropped). */
|
|
20
20
|
push(event) {
|
|
21
|
-
|
|
21
|
+
return this.pushEvict(event) === null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Add an event to the buffer. On overflow, returns the evicted OLDEST
|
|
25
|
+
* event so the caller can surface the drop; returns null when no
|
|
26
|
+
* eviction occurred.
|
|
27
|
+
*/
|
|
28
|
+
pushEvict(event) {
|
|
29
|
+
let evicted = null;
|
|
22
30
|
if (this.count === this.capacity) {
|
|
23
31
|
// Buffer full — drop oldest (advance head)
|
|
32
|
+
evicted = this.items[this.head];
|
|
24
33
|
this.head = (this.head + 1) % this.capacity;
|
|
25
34
|
this.count--;
|
|
26
|
-
overflow = true;
|
|
27
35
|
}
|
|
28
36
|
this.items[this.tail] = event;
|
|
29
37
|
this.tail = (this.tail + 1) % this.capacity;
|
|
30
38
|
this.count++;
|
|
31
|
-
return
|
|
39
|
+
return evicted;
|
|
32
40
|
}
|
|
33
41
|
/** Drain all events from the buffer. Returns a new array and clears the buffer. */
|
|
34
42
|
drain() {
|
package/dist/cjs/buffer.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"buffer.js","sourceRoot":"","sources":["../../src/buffer.ts"],"names":[],"mappings":";;;AAEA;;;;GAIG;AACH,MAAa,UAAU;IAOrB,YAAY,WAAmB,KAAM;QAJ7B,SAAI,GAAG,CAAC,CAAC;QACT,SAAI,GAAG,CAAC,CAAC;QACT,UAAK,GAAG,CAAC,CAAC;QAGhB,IAAI,QAAQ,GAAG,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC;QAClE,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACzB,IAAI,CAAC,KAAK,GAAG,IAAI,KAAK,CAAC,QAAQ,CAAC,CAAC;IACnC,CAAC;IAED,6FAA6F;IAC7F,IAAI,CAAC,KAAoB;QACvB,IAAI,
|
|
1
|
+
{"version":3,"file":"buffer.js","sourceRoot":"","sources":["../../src/buffer.ts"],"names":[],"mappings":";;;AAEA;;;;GAIG;AACH,MAAa,UAAU;IAOrB,YAAY,WAAmB,KAAM;QAJ7B,SAAI,GAAG,CAAC,CAAC;QACT,SAAI,GAAG,CAAC,CAAC;QACT,UAAK,GAAG,CAAC,CAAC;QAGhB,IAAI,QAAQ,GAAG,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC;QAClE,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACzB,IAAI,CAAC,KAAK,GAAG,IAAI,KAAK,CAAC,QAAQ,CAAC,CAAC;IACnC,CAAC;IAED,6FAA6F;IAC7F,IAAI,CAAC,KAAoB;QACvB,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,KAAK,IAAI,CAAC;IACxC,CAAC;IAED;;;;OAIG;IACH,SAAS,CAAC,KAAoB;QAC5B,IAAI,OAAO,GAAyB,IAAI,CAAC;QAEzC,IAAI,IAAI,CAAC,KAAK,KAAK,IAAI,CAAC,QAAQ,EAAE,CAAC;YACjC,2CAA2C;YAC3C,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YAChC,IAAI,CAAC,IAAI,GAAG,CAAC,IAAI,CAAC,IAAI,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC;YAC5C,IAAI,CAAC,KAAK,EAAE,CAAC;QACf,CAAC;QAED,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC;QAC9B,IAAI,CAAC,IAAI,GAAG,CAAC,IAAI,CAAC,IAAI,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC;QAC5C,IAAI,CAAC,KAAK,EAAE,CAAC;QAEb,OAAO,OAAO,CAAC;IACjB,CAAC;IAED,mFAAmF;IACnF,KAAK;QACH,IAAI,IAAI,CAAC,KAAK,KAAK,CAAC;YAAE,OAAO,EAAE,CAAC;QAEhC,MAAM,MAAM,GAAoB,EAAE,CAAC;QACnC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,CAAC;YACpC,MAAM,GAAG,GAAG,CAAC,IAAI,CAAC,IAAI,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC;YAC5C,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC;QAC/B,CAAC;QAED,IAAI,CAAC,IAAI,GAAG,CAAC,CAAC;QACd,IAAI,CAAC,IAAI,GAAG,CAAC,CAAC;QACd,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC;QAEf,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,6DAA6D;IAC7D,SAAS,CAAC,GAAW;QACnB,IAAI,IAAI,CAAC,KAAK,KAAK,CAAC,IAAI,GAAG,IAAI,CAAC;YAAE,OAAO,EAAE,CAAC;QAE5C,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;QACvC,MAAM,MAAM,GAAoB,EAAE,CAAC;QACnC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC;YAC9B,MAAM,GAAG,GAAG,CAAC,IAAI,CAAC,IAAI,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC;YAC5C,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC;QAC/B,CAAC;QAED,IAAI,CAAC,IAAI,GAAG,CAAC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC;QAC/C,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC;QAEnB,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,8CAA8C;IAC9C,IAAI,IAAI;QACN,OAAO,IAAI,CAAC,KAAK,CAAC;IACpB,CAAC;IAED,mCAAmC;IACnC,IAAI,OAAO;QACT,OAAO,IAAI,CAAC,KAAK,KAAK,CAAC,CAAC;IAC1B,CAAC;IAED,yCAAyC;IACzC,IAAI,MAAM;QACR,OAAO,IAAI,CAAC,KAAK,KAAK,IAAI,CAAC,QAAQ,CAAC;IACtC,CAAC;CACF;AAxFD,gCAwFC"}
|
package/dist/cjs/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AforoOptions, TrackEvent, FlushResult } from './types';
|
|
1
|
+
import { AforoOptions, TrackEvent, FlushResult } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Aforo metering client.
|
|
4
4
|
*
|
|
@@ -20,6 +20,10 @@ export declare class AforoClient {
|
|
|
20
20
|
private readonly flushInterval;
|
|
21
21
|
private readonly shutdownTimeoutMs;
|
|
22
22
|
private readonly productType;
|
|
23
|
+
private readonly onDrop?;
|
|
24
|
+
private dropped;
|
|
25
|
+
private overflowDrops;
|
|
26
|
+
private invalidDrops;
|
|
23
27
|
private flushTimer;
|
|
24
28
|
private flushing;
|
|
25
29
|
private closed;
|
|
@@ -28,6 +32,7 @@ export declare class AforoClient {
|
|
|
28
32
|
private activeSessionId;
|
|
29
33
|
private sessionStartedAt;
|
|
30
34
|
private sessionProductType;
|
|
35
|
+
private readonly signalHandler;
|
|
31
36
|
constructor(options: AforoOptions);
|
|
32
37
|
/**
|
|
33
38
|
* Start a session and emit `system.session.heartbeat` events: one now, then
|
|
@@ -55,18 +60,27 @@ export declare class AforoClient {
|
|
|
55
60
|
* Returns immediately — does not await HTTP.
|
|
56
61
|
* Triggers a flush if the buffer reaches flushCount.
|
|
57
62
|
*
|
|
58
|
-
* Throws
|
|
59
|
-
* or over-long `customerId` / `metricName` /
|
|
60
|
-
* that is not positive or carries more than
|
|
61
|
-
* places, an `occurredAt` that is not an
|
|
62
|
-
* capped field that is too long
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* See `limits.ts` for the limits and where each one comes from.
|
|
63
|
+
* Throws only when the client is shut down. An event the ingestor would
|
|
64
|
+
* reject — a blank or over-long `customerId` / `metricName` /
|
|
65
|
+
* `idempotencyKey`, a `quantity` that is not positive or carries more than
|
|
66
|
+
* 14 integer digits / 6 decimal places, an `occurredAt` that is not an
|
|
67
|
+
* ISO-8601 instant, or any other capped field that is too long — is NOT
|
|
68
|
+
* buffered and NOT sent. It is counted in `droppedCount`, WARN-logged with
|
|
69
|
+
* the field, the limit and the offending value, and passed to the opt-in
|
|
70
|
+
* `onDrop` hook with reason `'invalid'`. Limits the server makes
|
|
71
|
+
* configurable (event age, clock skew, metadata size) are left to the
|
|
72
|
+
* server. See `limits.ts` for the limits and where each one comes from.
|
|
68
73
|
*/
|
|
69
74
|
track(event: TrackEvent): Promise<void>;
|
|
75
|
+
/** Buffer an event; surface the evicted-oldest event if the push overflowed. */
|
|
76
|
+
private enqueue;
|
|
77
|
+
/**
|
|
78
|
+
* Account for permanently lost events: bump the counter, WARN-log, and
|
|
79
|
+
* invoke the opt-in onDrop hook. Overflow logs are throttled (first, then
|
|
80
|
+
* every 1000th eviction) so sustained overflow can't storm the log;
|
|
81
|
+
* failed-batch drops log every time (bounded by flush cadence).
|
|
82
|
+
*/
|
|
83
|
+
private recordDrop;
|
|
70
84
|
/**
|
|
71
85
|
* Force-flush all buffered events to the ingestor.
|
|
72
86
|
* Safe to call concurrently — only one flush runs at a time.
|
|
@@ -80,6 +94,8 @@ export declare class AforoClient {
|
|
|
80
94
|
shutdown(): Promise<void>;
|
|
81
95
|
/** Number of events currently buffered. */
|
|
82
96
|
get bufferedCount(): number;
|
|
97
|
+
/** Total events permanently dropped (overflow, failed batches, server-rejected and invalid events) since creation. */
|
|
98
|
+
get droppedCount(): number;
|
|
83
99
|
/** Whether the client has been shut down. */
|
|
84
100
|
get isShutdown(): boolean;
|
|
85
101
|
}
|
package/dist/cjs/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,UAAU,EAAiB,WAAW,
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,UAAU,EAAiB,WAAW,EAAc,MAAM,YAAY,CAAC;AAuB9F;;;;;;;;;;;;;GAaG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAa;IACpC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAwD;IAIhF,OAAO,CAAC,OAAO,CAAK;IACpB,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,YAAY,CAAK;IAEzB,OAAO,CAAC,UAAU,CAA+C;IACjE,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,YAAY,CAAqC;IAGzD,OAAO,CAAC,cAAc,CAA+C;IACrE,OAAO,CAAC,eAAe,CAAuB;IAC9C,OAAO,CAAC,gBAAgB,CAAuB;IAC/C,OAAO,CAAC,kBAAkB,CAAwC;IAIlE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAa;gBAE/B,OAAO,EAAE,YAAY;IAuCjC;;;;;;;;;;;;OAYG;IACH,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,GAAE,MAAqC,GAAG,IAAI;IAezF;;;OAGG;IACG,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAQjC,OAAO,CAAC,kBAAkB;IAO1B,OAAO,CAAC,oBAAoB;IAkC5B;;;;;;;;;;;;;;;OAeG;IACG,KAAK,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IA8D7C,gFAAgF;IAChF,OAAO,CAAC,OAAO;IAOf;;;;;OAKG;IACH,OAAO,CAAC,UAAU;IAqClB;;;OAGG;IACG,KAAK,IAAI,OAAO,CAAC,WAAW,CAAC;YAgBrB,OAAO;IAsCrB;;;OAGG;IACG,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAmC/B,2CAA2C;IAC3C,IAAI,aAAa,IAAI,MAAM,CAE1B;IAED,sHAAsH;IACtH,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED,6CAA6C;IAC7C,IAAI,UAAU,IAAI,OAAO,CAExB;CACF"}
|
package/dist/cjs/client.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.AforoClient = void 0;
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
4
|
+
const buffer_js_1 = require("./buffer.js");
|
|
5
|
+
const transport_js_1 = require("./transport.js");
|
|
6
|
+
const idempotency_js_1 = require("./idempotency.js");
|
|
7
|
+
const limits_js_1 = require("./limits.js");
|
|
8
8
|
const DEFAULT_BASE_URL = 'https://api.aforo.ai';
|
|
9
9
|
const DEFAULT_PRODUCT_TYPE = 'API';
|
|
10
10
|
const DEFAULT_SESSION_PRODUCT_TYPE = 'AI_AGENT';
|
|
@@ -37,6 +37,11 @@ const DEFAULT_SHUTDOWN_TIMEOUT = 5000;
|
|
|
37
37
|
*/
|
|
38
38
|
class AforoClient {
|
|
39
39
|
constructor(options) {
|
|
40
|
+
// Drop accounting — events permanently lost (overflow eviction, failed
|
|
41
|
+
// batch, or an event that failed client-side validation)
|
|
42
|
+
this.dropped = 0;
|
|
43
|
+
this.overflowDrops = 0;
|
|
44
|
+
this.invalidDrops = 0;
|
|
40
45
|
this.flushTimer = null;
|
|
41
46
|
this.flushing = false;
|
|
42
47
|
this.closed = false;
|
|
@@ -52,8 +57,9 @@ class AforoClient {
|
|
|
52
57
|
this.productType = normalizeProductType(options.productType) ?? DEFAULT_PRODUCT_TYPE;
|
|
53
58
|
this.flushInterval = options.flushInterval ?? DEFAULT_FLUSH_INTERVAL;
|
|
54
59
|
this.shutdownTimeoutMs = options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT;
|
|
55
|
-
this.
|
|
56
|
-
this.
|
|
60
|
+
this.onDrop = options.onDrop;
|
|
61
|
+
this.buffer = new buffer_js_1.RingBuffer(options.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE);
|
|
62
|
+
this.transport = new transport_js_1.Transport({
|
|
57
63
|
baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,
|
|
58
64
|
apiKey: options.apiKey,
|
|
59
65
|
timeout: options.timeout ?? DEFAULT_TIMEOUT,
|
|
@@ -68,12 +74,12 @@ class AforoClient {
|
|
|
68
74
|
if (this.flushTimer && typeof this.flushTimer === 'object' && 'unref' in this.flushTimer) {
|
|
69
75
|
this.flushTimer.unref();
|
|
70
76
|
}
|
|
71
|
-
// Register graceful shutdown handlers
|
|
72
|
-
|
|
77
|
+
// Register graceful shutdown handlers (deregistered in shutdown())
|
|
78
|
+
this.signalHandler = () => {
|
|
73
79
|
this.shutdown().catch(() => { });
|
|
74
80
|
};
|
|
75
|
-
process.once('SIGTERM',
|
|
76
|
-
process.once('SIGINT',
|
|
81
|
+
process.once('SIGTERM', this.signalHandler);
|
|
82
|
+
process.once('SIGINT', this.signalHandler);
|
|
77
83
|
}
|
|
78
84
|
// ─── Session lifecycle with heartbeat ─────────────────────────────
|
|
79
85
|
/**
|
|
@@ -145,6 +151,8 @@ class AforoClient {
|
|
|
145
151
|
sdkLanguage: 'node',
|
|
146
152
|
},
|
|
147
153
|
};
|
|
154
|
+
// Sent alone and outside the usage buffer; a failed heartbeat is not a
|
|
155
|
+
// usage drop, so it is never counted in droppedCount or handed to onDrop.
|
|
148
156
|
return this.transport.sendSingleBestEffort(heartbeat).then(() => undefined, () => undefined);
|
|
149
157
|
}
|
|
150
158
|
// ─── Event tracking ──────────────────────────────────────────────
|
|
@@ -153,54 +161,35 @@ class AforoClient {
|
|
|
153
161
|
* Returns immediately — does not await HTTP.
|
|
154
162
|
* Triggers a flush if the buffer reaches flushCount.
|
|
155
163
|
*
|
|
156
|
-
* Throws
|
|
157
|
-
* or over-long `customerId` / `metricName` /
|
|
158
|
-
* that is not positive or carries more than
|
|
159
|
-
* places, an `occurredAt` that is not an
|
|
160
|
-
* capped field that is too long
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* See `limits.ts` for the limits and where each one comes from.
|
|
164
|
+
* Throws only when the client is shut down. An event the ingestor would
|
|
165
|
+
* reject — a blank or over-long `customerId` / `metricName` /
|
|
166
|
+
* `idempotencyKey`, a `quantity` that is not positive or carries more than
|
|
167
|
+
* 14 integer digits / 6 decimal places, an `occurredAt` that is not an
|
|
168
|
+
* ISO-8601 instant, or any other capped field that is too long — is NOT
|
|
169
|
+
* buffered and NOT sent. It is counted in `droppedCount`, WARN-logged with
|
|
170
|
+
* the field, the limit and the offending value, and passed to the opt-in
|
|
171
|
+
* `onDrop` hook with reason `'invalid'`. Limits the server makes
|
|
172
|
+
* configurable (event age, clock skew, metadata size) are left to the
|
|
173
|
+
* server. See `limits.ts` for the limits and where each one comes from.
|
|
166
174
|
*/
|
|
167
175
|
async track(event) {
|
|
168
176
|
if (this.closed) {
|
|
169
177
|
throw new Error('AforoClient is shut down — cannot track new events');
|
|
170
178
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
174
|
-
if (event.metricName === undefined || event.metricName === null || !String(event.metricName).trim()) {
|
|
175
|
-
throw new Error('metricName is required');
|
|
176
|
-
}
|
|
179
|
+
// A missing event object is reported as an invalid event, not a TypeError.
|
|
180
|
+
event = (event ?? {});
|
|
177
181
|
const occurredAt = resolveOccurredAt(event.occurredAt);
|
|
178
182
|
const quantity = event.quantity ?? 1;
|
|
179
|
-
if (typeof quantity !== 'number' || !Number.isFinite(quantity) || quantity <= 0) {
|
|
180
|
-
throw new Error('quantity must be a positive number (> 0)');
|
|
181
|
-
}
|
|
182
183
|
const productType = normalizeProductType(event.productType) ?? this.productType;
|
|
183
184
|
// Minted once, here, when the event is enqueued — never at flush/retry
|
|
184
185
|
// time, so a retried batch carries the same keys and the ingestor
|
|
185
186
|
// deduplicates it. A caller-supplied key is passed through verbatim;
|
|
186
|
-
// otherwise each event gets its own random UUID
|
|
187
|
-
// the event fields would make two
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
const idempotencyKey = event.idempotencyKey ?? (0,
|
|
192
|
-
const violation = (0, limits_1.describeLimitViolation)({
|
|
193
|
-
customerId: event.customerId,
|
|
194
|
-
metricName: event.metricName,
|
|
195
|
-
quantity,
|
|
196
|
-
idempotencyKey,
|
|
197
|
-
occurredAt,
|
|
198
|
-
productType,
|
|
199
|
-
endpointPath: event.endpointPath,
|
|
200
|
-
httpMethod: event.httpMethod,
|
|
201
|
-
});
|
|
202
|
-
if (violation)
|
|
203
|
-
throw new Error(violation);
|
|
187
|
+
// otherwise each event gets its own random UUID (no caller key = dedup
|
|
188
|
+
// opt-out). A deterministic hash of the event fields would make two
|
|
189
|
+
// genuinely distinct events in the same millisecond collide, and the
|
|
190
|
+
// ingestor would silently drop the second. Minted before the validity
|
|
191
|
+
// check so a dropped event reaches onDrop with its key.
|
|
192
|
+
const idempotencyKey = event.idempotencyKey ?? (0, idempotency_js_1.generateRandomKey)();
|
|
204
193
|
const resolved = {
|
|
205
194
|
customerId: event.customerId,
|
|
206
195
|
metricName: event.metricName,
|
|
@@ -214,12 +203,76 @@ class AforoClient {
|
|
|
214
203
|
...(event.statusCode !== undefined ? { statusCode: event.statusCode } : {}),
|
|
215
204
|
...(event.responseTimeMs !== undefined ? { responseTimeMs: event.responseTimeMs } : {}),
|
|
216
205
|
};
|
|
217
|
-
|
|
206
|
+
const executionStatus = normalizeExecutionStatus(event.executionStatus);
|
|
207
|
+
if (executionStatus) {
|
|
208
|
+
if (CANONICAL_EXECUTION_STATUSES.has(executionStatus)) {
|
|
209
|
+
resolved.executionStatus = executionStatus;
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
// The ingestor rejects an event carrying an unknown status, so an
|
|
213
|
+
// unknown value is reported and the field omitted (the event still
|
|
214
|
+
// bills, at full weight) instead of losing the event.
|
|
215
|
+
warnUnknownExecutionStatus(executionStatus);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const violation = describeInvalidEvent(event, resolved);
|
|
219
|
+
if (violation) {
|
|
220
|
+
// Not buffered, not sent, never thrown: an un-awaited rejected track()
|
|
221
|
+
// would crash the process on the hot path. Same shape as every other drop.
|
|
222
|
+
this.recordDrop([resolved], 'invalid', violation);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
this.enqueue(resolved);
|
|
218
226
|
// Trigger flush if buffer threshold reached
|
|
219
227
|
if (this.buffer.size >= this.flushCount) {
|
|
220
228
|
this.flush().catch(() => { });
|
|
221
229
|
}
|
|
222
230
|
}
|
|
231
|
+
/** Buffer an event; surface the evicted-oldest event if the push overflowed. */
|
|
232
|
+
enqueue(resolved) {
|
|
233
|
+
const evicted = this.buffer.pushEvict(resolved);
|
|
234
|
+
if (evicted) {
|
|
235
|
+
this.recordDrop([evicted], 'overflow');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Account for permanently lost events: bump the counter, WARN-log, and
|
|
240
|
+
* invoke the opt-in onDrop hook. Overflow logs are throttled (first, then
|
|
241
|
+
* every 1000th eviction) so sustained overflow can't storm the log;
|
|
242
|
+
* failed-batch drops log every time (bounded by flush cadence).
|
|
243
|
+
*/
|
|
244
|
+
recordDrop(events, reason, detail) {
|
|
245
|
+
if (events.length === 0)
|
|
246
|
+
return;
|
|
247
|
+
this.dropped += events.length;
|
|
248
|
+
if (reason === 'invalid') {
|
|
249
|
+
// Throttled like overflow: a tight loop of bad events can't storm the log.
|
|
250
|
+
this.invalidDrops += events.length;
|
|
251
|
+
if (this.invalidDrops === 1 || this.invalidDrops % 1000 === 0) {
|
|
252
|
+
console.warn(`[aforo] Invalid event dropped — ${detail ?? 'failed validation'} ` +
|
|
253
|
+
`(${this.invalidDrops} invalid, ${this.dropped} total dropped). It was not sent.`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
else if (reason === 'overflow') {
|
|
257
|
+
this.overflowDrops += events.length;
|
|
258
|
+
if (this.overflowDrops === 1 || this.overflowDrops % 1000 === 0) {
|
|
259
|
+
console.warn(`[aforo] Buffer overflow: oldest event dropped (${this.dropped} total dropped). ` +
|
|
260
|
+
`Consider raising maxQueueSize or checking ingest connectivity.`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
console.warn(`[aforo] Dropped ${events.length} event(s) — ${reason}` +
|
|
265
|
+
`${detail ? `: ${detail}` : ''} (${this.dropped} total dropped).`);
|
|
266
|
+
}
|
|
267
|
+
if (this.onDrop) {
|
|
268
|
+
try {
|
|
269
|
+
this.onDrop(events, reason);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// A hook bug must never break tracking/flushing.
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
223
276
|
/**
|
|
224
277
|
* Force-flush all buffered events to the ingestor.
|
|
225
278
|
* Safe to call concurrently — only one flush runs at a time.
|
|
@@ -248,6 +301,27 @@ class AforoClient {
|
|
|
248
301
|
const result = await this.transport.send(batch);
|
|
249
302
|
totalSent += result.sent;
|
|
250
303
|
totalFailed += result.failed;
|
|
304
|
+
if (result.failed > 0) {
|
|
305
|
+
// The batch was already drained from the buffer — without this it
|
|
306
|
+
// vanishes silently. Surface it (counter + WARN + opt-in hook).
|
|
307
|
+
// A partially accepted batch drops only the events the server
|
|
308
|
+
// rejected: by index when the response names them; otherwise they are
|
|
309
|
+
// counted and logged but not handed to onDrop (which ones is unknown).
|
|
310
|
+
if (result.sent > 0 || result.rejected) {
|
|
311
|
+
if (result.rejected) {
|
|
312
|
+
const lost = result.rejected.map((r) => batch[r.index]).filter(Boolean);
|
|
313
|
+
this.recordDrop(lost, 'rejected', result.message);
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
this.dropped += result.failed;
|
|
317
|
+
console.warn(`[aforo] Ingestor rejected ${result.failed} of ${batch.length} event(s) in a batch` +
|
|
318
|
+
`${result.message ? `: ${result.message}` : ''} (${this.dropped} total dropped).`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
this.recordDrop(batch, result.reason ?? 'retry_exhausted', result.message);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
251
325
|
}
|
|
252
326
|
return { sent: totalSent, failed: totalFailed };
|
|
253
327
|
}
|
|
@@ -259,6 +333,10 @@ class AforoClient {
|
|
|
259
333
|
if (this.closed)
|
|
260
334
|
return;
|
|
261
335
|
this.closed = true;
|
|
336
|
+
// Deregister signal handlers so repeated create/shutdown cycles don't
|
|
337
|
+
// accumulate process listeners (safe if already fired — once() removed it).
|
|
338
|
+
process.removeListener('SIGTERM', this.signalHandler);
|
|
339
|
+
process.removeListener('SIGINT', this.signalHandler);
|
|
262
340
|
// Stop session heartbeats
|
|
263
341
|
this.stopHeartbeatTimer();
|
|
264
342
|
// Clear periodic flush timer
|
|
@@ -266,16 +344,32 @@ class AforoClient {
|
|
|
266
344
|
clearInterval(this.flushTimer);
|
|
267
345
|
this.flushTimer = null;
|
|
268
346
|
}
|
|
269
|
-
// Flush with timeout
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
347
|
+
// Flush with timeout. The escape-hatch timer MUST be cleared once the
|
|
348
|
+
// race settles — left dangling it holds the event loop open for up to
|
|
349
|
+
// shutdownTimeoutMs after a clean shutdown, delaying process exit in
|
|
350
|
+
// short-lived producers (CLIs, jobs, serverless handlers).
|
|
351
|
+
let escapeTimer;
|
|
352
|
+
try {
|
|
353
|
+
await Promise.race([
|
|
354
|
+
this.flush(),
|
|
355
|
+
new Promise((resolve) => {
|
|
356
|
+
escapeTimer = setTimeout(resolve, this.shutdownTimeoutMs);
|
|
357
|
+
}),
|
|
358
|
+
]);
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
if (escapeTimer)
|
|
362
|
+
clearTimeout(escapeTimer);
|
|
363
|
+
}
|
|
274
364
|
}
|
|
275
365
|
/** Number of events currently buffered. */
|
|
276
366
|
get bufferedCount() {
|
|
277
367
|
return this.buffer.size;
|
|
278
368
|
}
|
|
369
|
+
/** Total events permanently dropped (overflow, failed batches, server-rejected and invalid events) since creation. */
|
|
370
|
+
get droppedCount() {
|
|
371
|
+
return this.dropped;
|
|
372
|
+
}
|
|
279
373
|
/** Whether the client has been shut down. */
|
|
280
374
|
get isShutdown() {
|
|
281
375
|
return this.closed;
|
|
@@ -288,14 +382,77 @@ function normalizeProductType(value) {
|
|
|
288
382
|
const trimmed = String(value).trim();
|
|
289
383
|
return trimmed ? trimmed.toUpperCase() : undefined;
|
|
290
384
|
}
|
|
385
|
+
/** Shorten an offending value for a log line. */
|
|
386
|
+
function clip(value) {
|
|
387
|
+
return String(value).slice(0, 80);
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* First reason the ingestor would reject this event, or null when it is
|
|
391
|
+
* acceptable. Required fields and quantity sign first, then the size limits.
|
|
392
|
+
*/
|
|
393
|
+
function describeInvalidEvent(event, resolved) {
|
|
394
|
+
for (const field of ['customerId', 'metricName']) {
|
|
395
|
+
const value = event?.[field];
|
|
396
|
+
if (value === undefined || value === null || !String(value).trim()) {
|
|
397
|
+
return `${field} is required (got "${clip(value)}")`;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const quantity = resolved.quantity;
|
|
401
|
+
if (typeof quantity !== 'number' || !Number.isFinite(quantity) || quantity <= 0) {
|
|
402
|
+
return `quantity must be a positive number (> 0), got "${clip(quantity)}"`;
|
|
403
|
+
}
|
|
404
|
+
if (typeof resolved.occurredAt !== 'string') {
|
|
405
|
+
return `occurredAt must be an ISO-8601 string or epoch milliseconds, got "${clip(resolved.occurredAt)}"`;
|
|
406
|
+
}
|
|
407
|
+
return (0, limits_js_1.describeLimitViolation)({
|
|
408
|
+
customerId: resolved.customerId,
|
|
409
|
+
metricName: resolved.metricName,
|
|
410
|
+
quantity,
|
|
411
|
+
idempotencyKey: resolved.idempotencyKey,
|
|
412
|
+
occurredAt: resolved.occurredAt,
|
|
413
|
+
productType: resolved.productType,
|
|
414
|
+
endpointPath: resolved.endpointPath,
|
|
415
|
+
httpMethod: resolved.httpMethod,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
291
418
|
function randomSuffix() {
|
|
292
419
|
return Math.random().toString(36).slice(2, 10);
|
|
293
420
|
}
|
|
294
421
|
function resolveOccurredAt(value) {
|
|
295
422
|
if (!value)
|
|
296
423
|
return new Date().toISOString();
|
|
297
|
-
if (typeof value === 'number')
|
|
298
|
-
|
|
424
|
+
if (typeof value === 'number') {
|
|
425
|
+
const date = new Date(value);
|
|
426
|
+
// An out-of-range epoch must not throw from track(); it is reported as invalid.
|
|
427
|
+
return Number.isNaN(date.getTime()) ? String(value) : date.toISOString();
|
|
428
|
+
}
|
|
299
429
|
return value;
|
|
300
430
|
}
|
|
431
|
+
/**
|
|
432
|
+
* Statuses the ingestor accepts (contract/ingest-contract.json, max 20 chars).
|
|
433
|
+
* Anything else would make it reject the event.
|
|
434
|
+
*/
|
|
435
|
+
const CANONICAL_EXECUTION_STATUSES = new Set([
|
|
436
|
+
'SUCCESS', 'PARTIAL', 'TIMEOUT', 'ERROR', 'VALIDATION_FAILED', 'FAILED',
|
|
437
|
+
'FAILURE', 'CANCELLED', 'PENDING', 'BLOCKED', 'HITL_REQUIRED',
|
|
438
|
+
]);
|
|
439
|
+
// Warn once per distinct bad value (bounded) so a misconfigured static status
|
|
440
|
+
// can't storm the log on every request.
|
|
441
|
+
const MAX_WARNED_STATUSES = 100;
|
|
442
|
+
const warnedStatuses = new Set();
|
|
443
|
+
function warnUnknownExecutionStatus(value) {
|
|
444
|
+
if (warnedStatuses.has(value))
|
|
445
|
+
return;
|
|
446
|
+
if (warnedStatuses.size < MAX_WARNED_STATUSES)
|
|
447
|
+
warnedStatuses.add(value);
|
|
448
|
+
console.warn(`[aforo] Unknown executionStatus "${value.slice(0, 40)}" — field omitted from the event. ` +
|
|
449
|
+
`Expected one of: ${[...CANONICAL_EXECUTION_STATUSES].join(', ')}.`);
|
|
450
|
+
}
|
|
451
|
+
/** Trim + upper-case; blank/absent → undefined (key omitted from the wire body). */
|
|
452
|
+
function normalizeExecutionStatus(value) {
|
|
453
|
+
if (typeof value !== 'string')
|
|
454
|
+
return undefined;
|
|
455
|
+
const trimmed = value.trim();
|
|
456
|
+
return trimmed ? trimmed.toUpperCase() : undefined;
|
|
457
|
+
}
|
|
301
458
|
//# sourceMappingURL=client.js.map
|