margin-meter 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +292 -2
- package/dist/batching.d.ts +94 -0
- package/dist/batching.d.ts.map +1 -0
- package/dist/batching.js +181 -0
- package/dist/batching.js.map +1 -0
- package/dist/braintrust.d.ts +153 -0
- package/dist/braintrust.d.ts.map +1 -0
- package/dist/braintrust.js +424 -0
- package/dist/braintrust.js.map +1 -0
- package/dist/capture.d.ts +28 -0
- package/dist/capture.d.ts.map +1 -0
- package/dist/capture.js +177 -0
- package/dist/capture.js.map +1 -0
- package/dist/index.d.ts +334 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +697 -23
- package/dist/index.js.map +1 -1
- package/dist/instrument.d.ts +8 -7
- package/dist/instrument.d.ts.map +1 -1
- package/dist/instrument.js +8 -7
- package/dist/instrument.js.map +1 -1
- package/dist/langsmith.d.ts +150 -0
- package/dist/langsmith.d.ts.map +1 -0
- package/dist/langsmith.js +415 -0
- package/dist/langsmith.js.map +1 -0
- package/dist/promptfoo.d.ts +161 -0
- package/dist/promptfoo.d.ts.map +1 -0
- package/dist/promptfoo.js +446 -0
- package/dist/promptfoo.js.map +1 -0
- package/dist/providers.d.ts +36 -0
- package/dist/providers.d.ts.map +1 -1
- package/dist/providers.js +229 -3
- package/dist/providers.js.map +1 -1
- package/dist/vitest.d.ts +232 -0
- package/dist/vitest.d.ts.map +1 -0
- package/dist/vitest.js +502 -0
- package/dist/vitest.js.map +1 -0
- package/package.json +24 -6
package/README.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# margin-meter (TypeScript SDK)
|
|
2
2
|
|
|
3
|
+
> **You probably do not need this to start.** Margin's first step installs nothing: sign in at
|
|
4
|
+
> [trymargin.io/console](https://trymargin.io/console), connect the repository your agents live
|
|
5
|
+
> in (or drop the folder), and press Execute. Each agent runs on the evals beside it and every
|
|
6
|
+
> call is metered. This SDK is the **optional** route for metering your live production traffic,
|
|
7
|
+
> once you want that as well ([`/start#production`](https://trymargin.io/start#production)).
|
|
8
|
+
|
|
3
9
|
The tiny client a **TypeScript/JavaScript** project imports to connect to
|
|
4
10
|
Margin — the TS twin of the Python [`margin-meter`](../python) package. It wraps
|
|
5
11
|
your LLM calls and records their outcomes, emitting each one **over HTTP** to a
|
|
@@ -7,6 +13,10 @@ Margin **ingest API** (`POST /api/ingest/calls` / `/api/ingest/outcomes`),
|
|
|
7
13
|
authenticated with a per-project ingest key.
|
|
8
14
|
|
|
9
15
|
- **Dependency-free.** Uses the global `fetch` — nothing to install at runtime.
|
|
16
|
+
- **Off your hot path.** Calls are buffered and flushed in batches, so your
|
|
17
|
+
agent's latency never depends on Margin being up. See
|
|
18
|
+
[Batching](#batching-what-it-does-to-your-process) — read it before you
|
|
19
|
+
integrate, because the guarantees and their costs are both stated there.
|
|
10
20
|
- **Fail-safe.** A failed emit resolves to `{ ok: false, … }` instead of throwing.
|
|
11
21
|
Pass `raiseOnError: true` for strict/CI behaviour.
|
|
12
22
|
- **Provenance-honest.** `isSimulated` is carried through untouched; the written
|
|
@@ -39,7 +49,7 @@ build via `prepublishOnly`).
|
|
|
39
49
|
Two environment variables — the deployed API base and your project's key:
|
|
40
50
|
|
|
41
51
|
```bash
|
|
42
|
-
export MARGIN_INGEST_URL="https://
|
|
52
|
+
export MARGIN_INGEST_URL="https://trymargin.io"
|
|
43
53
|
export MARGIN_INGEST_KEY="mgk_…" # issued by the Margin owner
|
|
44
54
|
```
|
|
45
55
|
|
|
@@ -86,6 +96,115 @@ const res = await meter.recordCall({
|
|
|
86
96
|
if (!res.ok) console.warn(`margin ingest failed: ${res.error} (${res.statusCode})`);
|
|
87
97
|
```
|
|
88
98
|
|
|
99
|
+
## Say what each call was FOR (`taskKey`)
|
|
100
|
+
|
|
101
|
+
`workflowId` names the pipeline and `operation` names the step inside it. Neither
|
|
102
|
+
says what the work actually WAS — a billing question and a password reset look
|
|
103
|
+
identical on both. `taskKey` is that third axis, in your own vocabulary:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
await meter.recordCall({
|
|
107
|
+
workflowId: "support-triage",
|
|
108
|
+
provider: "openai",
|
|
109
|
+
model: "gpt-4o-mini",
|
|
110
|
+
inputTokens: 812,
|
|
111
|
+
outputTokens: 96,
|
|
112
|
+
taskKey: ticket.topic, // "billing" | "password-reset" | whatever you already use
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Why it earns a field. Margin measures a cheaper route's parity on a sample and
|
|
117
|
+
records what that sample was made of. If the distribution of arriving work moves —
|
|
118
|
+
new ticket topics, a mix that shifts as you grow — **parity decays with no model
|
|
119
|
+
change at all**, and nothing that watches for a route getting worse will fire,
|
|
120
|
+
because nothing got worse. With `taskKey` flowing, Margin compares the measured mix
|
|
121
|
+
against the arriving one and says `steady` or `moved`. Without it the answer is
|
|
122
|
+
`unknown`, which is what the console shows and is the honest state, not a bug.
|
|
123
|
+
|
|
124
|
+
It is never priced and never rolled into cost. Send it on more than 60% of a
|
|
125
|
+
workflow's calls and the comparison becomes answerable; below that Margin declines
|
|
126
|
+
to rule rather than describing your instrumented slice as if it were your traffic.
|
|
127
|
+
|
|
128
|
+
## Say where each call RAN (`environment`)
|
|
129
|
+
|
|
130
|
+
`environment` splits the work you sell from the work you did to decide what to
|
|
131
|
+
sell. A spike, an eval pass and your production traffic are three different kinds
|
|
132
|
+
of spend, and only one of them belongs in the number you report upward.
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
await meter.recordCall({
|
|
136
|
+
workflowId: "support-triage",
|
|
137
|
+
provider: "openai",
|
|
138
|
+
model: "gpt-4o-mini",
|
|
139
|
+
inputTokens: 812,
|
|
140
|
+
outputTokens: 96,
|
|
141
|
+
environment: "prod", // "prod" | "dev" | "eval"
|
|
142
|
+
});
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
| value | send it for |
|
|
146
|
+
| --- | --- |
|
|
147
|
+
| `prod` | traffic your customers cause |
|
|
148
|
+
| `dev` | a spike, a local run, a branch deploy |
|
|
149
|
+
| `eval` | a graded pass, in CI or from an eval harness |
|
|
150
|
+
|
|
151
|
+
Why it earns a field. `costPerOutcome` is total call spend divided by passed
|
|
152
|
+
outcomes. With `environment` unset, your R&D and your operations land in that one
|
|
153
|
+
number, so the headline describes neither. Margin already keeps its own face-off
|
|
154
|
+
spend out of your number; this is how you get the same split on your side.
|
|
155
|
+
|
|
156
|
+
It does not default. Leave it off and the row is `unknown`, which is honest and
|
|
157
|
+
costs you nothing, where guessing `prod` would book your spike as production. It
|
|
158
|
+
is never priced and never rolled into cost. It is a label, like `team`.
|
|
159
|
+
|
|
160
|
+
## Multi-agent pipelines (name each stage)
|
|
161
|
+
|
|
162
|
+
A crew of agents — a planner→coder hand-off, a LangChain pipeline — runs under one
|
|
163
|
+
workflow. Auto-instrumentation binds a single `operation` at wire-up, so every
|
|
164
|
+
agent would meter under the same label and the run reads as one blob. `stage()`
|
|
165
|
+
splits it: wrap the current step and every metered call inside — however many
|
|
166
|
+
`await`s deep — is attributed to that stage, under a shared run id. It is the twin
|
|
167
|
+
of the Python SDK's `stage()`; because TS has no `with`, it takes the block as a
|
|
168
|
+
callback and returns whatever the callback returns.
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
import { MarginMeter, stage } from "margin-meter";
|
|
172
|
+
|
|
173
|
+
const meter = new MarginMeter();
|
|
174
|
+
const runId = "run-42";
|
|
175
|
+
|
|
176
|
+
await stage("classify", { taskClass: "signal-check", sessionId: runId }, () =>
|
|
177
|
+
classifier.run(ticket), // its calls meter as operation="classify"
|
|
178
|
+
);
|
|
179
|
+
await stage("implement", { taskClass: "algorithm", sessionId: runId }, () =>
|
|
180
|
+
implementer.run(plan), // ... and these as operation="implement"
|
|
181
|
+
);
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The two stages share `sessionId` but carry distinct operations, so the run is a
|
|
185
|
+
real multi-stage pipeline the Money Map decomposes — and `taskClass`, declared per
|
|
186
|
+
step, is what lets `margin_stage_plan` recommend a model and effort for each. It
|
|
187
|
+
uses `AsyncLocalStorage` (the direct analogue of Python's contextvars), so it stays
|
|
188
|
+
correct across `await` and concurrent runs; it re-labels and links only — tokens,
|
|
189
|
+
cost and the outcome are untouched.
|
|
190
|
+
|
|
191
|
+
Nest `stage()`/`span()` to build the tree, and reach for `span()` when a block fans
|
|
192
|
+
out and you want to record the orchestration shape:
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
import { span } from "margin-meter";
|
|
196
|
+
|
|
197
|
+
await span({ pattern: "panel", branchWidth: 3, stoppingRule: "majority of 3" }, () =>
|
|
198
|
+
Promise.all(candidates.map((c) => judge(c))), // each judge nests its own node
|
|
199
|
+
);
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
One caveat carried over from the Python side: `AsyncLocalStorage` does not cross a
|
|
203
|
+
worker boundary (as a contextvar does not cross a bare thread). A branch you run on
|
|
204
|
+
a worker must pass an explicit `parentSpanId` on the call or in the options, or it
|
|
205
|
+
records as a root — which reads as "no chain happened" rather than the missing edge
|
|
206
|
+
it is.
|
|
207
|
+
|
|
89
208
|
## Auto-instrument (a few lines for a whole repo)
|
|
90
209
|
|
|
91
210
|
To instrument an existing agent you didn't write, don't edit every call site.
|
|
@@ -112,6 +231,17 @@ undo(); // restore the original method
|
|
|
112
231
|
(recovering the model id off the instance). Each returns an `undo()` and throws
|
|
113
232
|
`MarginInstrumentError` if the client shape has moved.
|
|
114
233
|
|
|
234
|
+
To flow `taskKey` (above) through the auto-instrument path, pass a **resolver, not
|
|
235
|
+
a value** — one wrapper meters a whole run, so the unit of work is read at call
|
|
236
|
+
time, never baked as a constant (a constant would collapse the run into one class):
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
const undo = instrumentOpenAI(client, meter, {
|
|
240
|
+
workflowId: "support-triage",
|
|
241
|
+
resolveTaskKey: () => currentTicket()?.topic, // read per call; undefined ⇒ unknown
|
|
242
|
+
});
|
|
243
|
+
```
|
|
244
|
+
|
|
115
245
|
Two lower-level primitives when you want tighter control:
|
|
116
246
|
|
|
117
247
|
```ts
|
|
@@ -131,6 +261,97 @@ cache reads are billed separately, never double-counted as fresh input — and a
|
|
|
131
261
|
metering failure is fail-safe (your call still returns / still throws). A failed
|
|
132
262
|
provider call is still recorded with `status:"error"` so it appears in the chain.
|
|
133
263
|
|
|
264
|
+
## Report your evals (promptfoo)
|
|
265
|
+
|
|
266
|
+
Your eval suite already grades pass/fail — that is the outcome signal Margin's
|
|
267
|
+
cost-/value-per-outcome math divides by. If you run [promptfoo](https://promptfoo.dev),
|
|
268
|
+
point this adapter at the file it already writes; no hand-written POST:
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
promptfoo eval -o results.json
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
import { readFile } from "node:fs/promises";
|
|
276
|
+
import { MarginMeter, promptfoo } from "margin-meter";
|
|
277
|
+
|
|
278
|
+
const meter = new MarginMeter(); // MARGIN_INGEST_URL/KEY from env
|
|
279
|
+
const data = JSON.parse(await readFile("results.json", "utf8"));
|
|
280
|
+
const { recorded, skipped, errors } = await promptfoo.recordResults(meter, data, "my-eval-suite");
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
One promptfoo result becomes one outcome. `success` is the pass/fail; `score` rides
|
|
284
|
+
through when it is in `[0, 1]`. The grade's provenance is **derived from the assert
|
|
285
|
+
types, never assumed** — a code assert (`contains`, `equals`, `regex`, …) records
|
|
286
|
+
`ground_truth`/`code`, any model-graded assert (`llm-rubric`, `factuality`,
|
|
287
|
+
`similar`, …) records `llm_judge`/`judge`, and the weaker honest claim always wins,
|
|
288
|
+
so a rubric grade is never reported as ground truth. Tag a case with
|
|
289
|
+
`vars.margin_value: <usd>` (or `testCase.metadata.margin_value`) to report the
|
|
290
|
+
outcome's worth — the value-per-outcome numerator; absent is the honest
|
|
291
|
+
cost-per-outcome default, never `0`. A row with no boolean `success` is not a
|
|
292
|
+
verdict and is skipped rather than invented. Same contract, field for field, as the
|
|
293
|
+
Python SDK's `margin_meter.promptfoo`.
|
|
294
|
+
|
|
295
|
+
Run [Braintrust](https://braintrust.dev) instead? Same shape, one difference — Braintrust
|
|
296
|
+
scores are continuous, so a pass is derived rather than intrinsic:
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
import { MarginMeter, braintrust } from "margin-meter";
|
|
300
|
+
|
|
301
|
+
const meter = new MarginMeter();
|
|
302
|
+
const data = JSON.parse(await readFile("results.json", "utf8"));
|
|
303
|
+
// a case passes when its mean score clears the threshold …
|
|
304
|
+
await braintrust.recordResults(meter, data, "my-suite", { threshold: 0.7 });
|
|
305
|
+
// … or, with no threshold, only cases whose scorers are all binary (0/1) are
|
|
306
|
+
// recorded; a continuous-score case is skipped, never given a fabricated verdict.
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
`qualityScore` is the mean of the case's per-scorer scores; the grade's provenance is
|
|
310
|
+
derived from the scorer names (a model autoeval like `Factuality` → `llm_judge`/`judge`,
|
|
311
|
+
a code scorer like `ExactMatch` → `ground_truth`/`code`). Tag `metadata.margin_value`
|
|
312
|
+
for rung 8, and `metadata.margin_judge_model` to record which model judged (a bare
|
|
313
|
+
`model` field is the generation model under test, not the judge, so it is never used).
|
|
314
|
+
|
|
315
|
+
Your unit tests are already an eval harness — a passing test is a code-graded, ground-truth
|
|
316
|
+
pass/fail. Point the adapter at the JSON your test runner already writes and every test
|
|
317
|
+
becomes an outcome, no hand-written POST. One adapter covers both **vitest** and **jest**
|
|
318
|
+
(they emit the same jest-compatible JSON):
|
|
319
|
+
|
|
320
|
+
```ts
|
|
321
|
+
import { MarginMeter, vitest } from "margin-meter";
|
|
322
|
+
// vitest run --reporter=json --outputFile=results.json (or: jest --json --outputFile=results.json)
|
|
323
|
+
|
|
324
|
+
const meter = new MarginMeter();
|
|
325
|
+
const data = JSON.parse(await readFile("results.json", "utf8"));
|
|
326
|
+
const { recorded, skipped } = await vitest.recordResults(meter, data, "my-test-suite");
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
A `passed` assertion records as `ground_truth`/`code` with `qualityScore` 1.0, a `failed`
|
|
330
|
+
one 0.0; a `skipped`/`todo`/`pending` test is **not a verdict** and is skipped, never
|
|
331
|
+
fabricated. The `link` traces back to the test's `fullName`.
|
|
332
|
+
|
|
333
|
+
The JSON file carries no per-test value, so that path is cost-per-outcome only. To attach a
|
|
334
|
+
**value** (value-per-outcome, the way pytest uses `@pytest.mark.margin_value`), use the runtime
|
|
335
|
+
reporter instead — it runs inside vitest and reads `task.meta.marginValue`:
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
// vitest.config.ts
|
|
339
|
+
import { vitest as m } from "margin-meter";
|
|
340
|
+
export default {
|
|
341
|
+
test: { reporters: ["default", new m.MarginVitestReporter({ workflowId: "my-test-suite" })] },
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// in a test — tag its business value (optional; absent = cost-per-outcome)
|
|
345
|
+
import { getCurrentTest } from "vitest";
|
|
346
|
+
test("resolves the ticket", () => {
|
|
347
|
+
getCurrentTest().meta.marginValue = 120; // this outcome is worth $120
|
|
348
|
+
// ...
|
|
349
|
+
});
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
The reporter is a no-op unless a `workflowId` (or `MARGIN_VITEST_WORKFLOW`) is set, and it never
|
|
353
|
+
throws into your test run — a delivery fault warns and is counted.
|
|
354
|
+
|
|
134
355
|
## API
|
|
135
356
|
|
|
136
357
|
| Method | Emits to | Notes |
|
|
@@ -141,9 +362,78 @@ provider call is still recorded with `status:"error"` so it appears in the chain
|
|
|
141
362
|
| `recordResponse(input)` | `recordCall` | meters straight off a provider response — no manual tokens |
|
|
142
363
|
| `wrap(fn, opts)` | `recordCall` per call | returns an auto-metered drop-in for any LLM call |
|
|
143
364
|
| `instrumentOpenAI` / `instrumentAnthropic` / `instrumentGemini` | — | monkeypatch a client instance's create method |
|
|
365
|
+
| `stage(op, opts?, fn)` | — | attributes every call inside `fn` to one pipeline step under a shared run id; opens a chain node; returns `fn`'s value |
|
|
366
|
+
| `span(opts, fn)` | — | marks the calls inside `fn` as one node of an agent tree, with the orchestration shape; nest to build the tree |
|
|
367
|
+
| `currentStage()` / `currentSpan()` | — | the active step / tree node, or `undefined` outside any |
|
|
368
|
+
|
|
369
|
+
| `deliveryStats()` | — | what this meter delivered against what it tried to (see below) |
|
|
144
370
|
|
|
145
|
-
Every method resolves to an `IngestResult { ok, statusCode, body?, error? }`.
|
|
371
|
+
Every method resolves to an `IngestResult { ok, statusCode, body?, error?, reason? }`.
|
|
146
372
|
`ok` is true only on HTTP 200; `body` holds `call_id`/`outcome_id` + `source`.
|
|
373
|
+
`reason` is the failure as a stable bucket (`no_key`, `transport`,
|
|
374
|
+
`http_<status>`) — read that rather than parsing `error`, whose wording is prose
|
|
375
|
+
and will move.
|
|
376
|
+
|
|
377
|
+
## Batching (what it does to your process)
|
|
378
|
+
|
|
379
|
+
Calls are **buffered and flushed in batches** — 100 records or 10 seconds,
|
|
380
|
+
whichever comes first. `recordCall()` pushes onto a bounded queue and resolves;
|
|
381
|
+
the POST happens off your await chain, driven by an `unref`'d timer that never
|
|
382
|
+
holds your process open.
|
|
383
|
+
|
|
384
|
+
Why it works this way: at 1M metered calls/day, one call per HTTP request is 30M
|
|
385
|
+
serverless invocations a month and the same traffic batched at 100 is 300K. That
|
|
386
|
+
100x is Margin's cost, not yours, which is exactly why it belongs in the SDK
|
|
387
|
+
rather than the server — only the client can decide not to make the request.
|
|
388
|
+
|
|
389
|
+
| knob | default | env override |
|
|
390
|
+
| --- | ---: | --- |
|
|
391
|
+
| records per flush | 100 | `MARGIN_BATCH_MAX_RECORDS` |
|
|
392
|
+
| seconds between flushes | 10 | `MARGIN_BATCH_FLUSH_SECONDS` |
|
|
393
|
+
| queued records (memory ceiling) | 10,000 | `MARGIN_BATCH_MAX_QUEUE` |
|
|
394
|
+
|
|
395
|
+
**What you are guaranteed:** metering never blocks your call path, never throws
|
|
396
|
+
into it, and cannot grow without bound in your process.
|
|
397
|
+
|
|
398
|
+
**What it costs you, stated plainly:**
|
|
399
|
+
|
|
400
|
+
- `recordCall()` resolves to `{ ok: true, body: { queued: true } }` — accepted
|
|
401
|
+
into the buffer, *not* written to the ledger. `deliveryStats()` is where you
|
|
402
|
+
learn what landed.
|
|
403
|
+
- A hard kill loses at most 100 records or 10 seconds of them. `beforeExit`
|
|
404
|
+
covers a normal exit; no in-memory buffer survives `SIGKILL`.
|
|
405
|
+
- If Margin is unreachable long enough to fill the queue, the **newest** records
|
|
406
|
+
are dropped, and every drop is counted under `queue_full`.
|
|
407
|
+
- Delivery is at-least-once. Every record carries an `event_id` and the server is
|
|
408
|
+
idempotent on it, so a redelivery never double-counts your spend.
|
|
409
|
+
|
|
410
|
+
`await meter.flush()` resolves once everything queued has been delivered or
|
|
411
|
+
counted. `await meter.close()` drains and stops the timer. `raiseOnError: true`
|
|
412
|
+
skips the buffer entirely and posts at the call site, because strict mode's whole
|
|
413
|
+
meaning is "throw where I called you".
|
|
414
|
+
|
|
415
|
+
Full contract, including the arithmetic behind every default:
|
|
416
|
+
[`docs/reference/SDK_BATCHING.md`](../../docs/reference/SDK_BATCHING.md).
|
|
417
|
+
|
|
418
|
+
## Knowing what you dropped
|
|
419
|
+
|
|
420
|
+
Metering is fail-safe: a failed emit resolves to `ok: false` and your call carries
|
|
421
|
+
on. That is the right trade — telemetry should never take your agent down — but it
|
|
422
|
+
means a lost row looks, from the ledger, exactly like a call that never happened.
|
|
423
|
+
Cost per outcome then reads cheaper than your bill.
|
|
424
|
+
|
|
425
|
+
So the meter counts its own losses:
|
|
426
|
+
|
|
427
|
+
```ts
|
|
428
|
+
const s = meter.deliveryStats();
|
|
429
|
+
console.log(s.attempted, s.delivered, s.dropped, s.dropRate);
|
|
430
|
+
console.log(s.droppedByReason); // { http_502: 3, transport: 1 }
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
The Python SDK's `delivery_stats()` reports two more fields, `deduped` and
|
|
434
|
+
`unmeasured`. Neither applies here: this client has no file sink to lose buffered
|
|
435
|
+
rows in and no cross-hook de-dup to suppress a second hook, so every emit it makes
|
|
436
|
+
goes through one door and is counted there.
|
|
147
437
|
|
|
148
438
|
## Testing against the ingest contract (no network)
|
|
149
439
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The buffer that decides WHEN a metered row goes over the wire.
|
|
3
|
+
*
|
|
4
|
+
* The full contract, with the arithmetic behind every default, is
|
|
5
|
+
* `docs/reference/SDK_BATCHING.md`, and this file is the TS twin of
|
|
6
|
+
* `margin_meter/batching.py` — the two must agree, because they are one contract
|
|
7
|
+
* a customer integrates against, not two implementations.
|
|
8
|
+
*
|
|
9
|
+
* The short version: at 1M metered calls/day, one call per HTTP request is 30M
|
|
10
|
+
* Vercel invocations a month and the same traffic batched at 100 is 300K. 100x on
|
|
11
|
+
* a line item Margin pays. It lives in the SDK rather than the server because
|
|
12
|
+
* only the client can decide not to make the request.
|
|
13
|
+
*
|
|
14
|
+
* ⛔ Two properties are not negotiable, and both are about the customer's process
|
|
15
|
+
* rather than our data:
|
|
16
|
+
*
|
|
17
|
+
* 1. **Nothing blocks the caller.** `submit` pushes onto an array and returns.
|
|
18
|
+
* There is no thread here and none is needed — the flush runs off the caller's
|
|
19
|
+
* await chain, driven by an `unref`'d timer so it never holds a process open.
|
|
20
|
+
* 2. **The queue is BOUNDED and drops the NEWEST.** An unbounded buffer behind a
|
|
21
|
+
* dead endpoint is an OOM with our name on it, and taking down a customer's
|
|
22
|
+
* production agent because our meter was unavailable is the worst thing this
|
|
23
|
+
* SDK could do. Drop-newest leaves a contiguous prefix and a gap at the tail,
|
|
24
|
+
* which reads as "we stopped recording at 14:20"; drop-oldest leaves a hole in
|
|
25
|
+
* the middle of history, which reads as "they spent less that hour" — an
|
|
26
|
+
* under-count in the flattering direction wearing the costume of a measurement.
|
|
27
|
+
*/
|
|
28
|
+
export declare const BATCH_PATH = "/api/ingest/batch";
|
|
29
|
+
/** Flush on 100 records OR 10 seconds, whichever comes first. The crossover is
|
|
30
|
+
* 100 ÷ 10 = 10 calls/sec: above it size fires first and invocations scale as
|
|
31
|
+
* volume ÷ 100; below it the timer governs and invocations cap at 8,640/day
|
|
32
|
+
* however quiet the customer is. Each trigger covers the other's failure — size
|
|
33
|
+
* alone stalls forever on a low-volume customer, time alone spends a fixed
|
|
34
|
+
* ~518K invocations/month whatever the traffic. */
|
|
35
|
+
export declare const DEFAULT_MAX_RECORDS = 100;
|
|
36
|
+
export declare const DEFAULT_FLUSH_MS = 10000;
|
|
37
|
+
/** ~14 minutes of buffered traffic at 11.6 calls/sec — enough to ride out a
|
|
38
|
+
* deploy or a short outage of ours. A row is 400–1500 bytes, so the ceiling on
|
|
39
|
+
* the host's memory is 4–15 MB, which is the number to quote a customer. */
|
|
40
|
+
export declare const DEFAULT_MAX_QUEUE = 10000;
|
|
41
|
+
/** Bounded so a shutdown flush can never hang the host's exit. */
|
|
42
|
+
export declare const SHUTDOWN_TIMEOUT_MS = 5000;
|
|
43
|
+
/** One buffered emit: which ingest leg it belongs to, and its built payload. */
|
|
44
|
+
export interface BatchRecord {
|
|
45
|
+
path: string;
|
|
46
|
+
payload: Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
export interface BatcherOptions {
|
|
49
|
+
maxRecords?: number;
|
|
50
|
+
flushMs?: number;
|
|
51
|
+
maxQueue?: number;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A bounded queue plus a timer that flushes it on size or time.
|
|
55
|
+
*
|
|
56
|
+
* `send` is awaited off the caller's path and is expected never to reject — it
|
|
57
|
+
* owns the POST, the retries and the accounting.
|
|
58
|
+
*/
|
|
59
|
+
export declare class Batcher {
|
|
60
|
+
readonly maxRecords: number;
|
|
61
|
+
readonly flushMs: number;
|
|
62
|
+
readonly maxQueue: number;
|
|
63
|
+
private queue;
|
|
64
|
+
private timer;
|
|
65
|
+
private inFlight;
|
|
66
|
+
/** True once `close()` has run. Public so a caller can report a refused emit
|
|
67
|
+
* under the reason that actually caused it: "the meter was shut down" and "the
|
|
68
|
+
* buffer is full" have opposite fixes, and filing one as the other sends the
|
|
69
|
+
* reader hunting for a network problem that is not there. */
|
|
70
|
+
closed: boolean;
|
|
71
|
+
private readonly send;
|
|
72
|
+
constructor(send: (batch: BatchRecord[]) => Promise<void>, options?: BatcherOptions);
|
|
73
|
+
/** Queue one record. `false` when the queue was full and it was DROPPED.
|
|
74
|
+
*
|
|
75
|
+
* ⛔ A bounded push and a size check are the entire cost on the caller's path.
|
|
76
|
+
* Nothing here is awaited. */
|
|
77
|
+
submit(record: BatchRecord): boolean;
|
|
78
|
+
/** Start the interval timer if it is not already running.
|
|
79
|
+
*
|
|
80
|
+
* ⭐ `unref`'d, which is what lets a short-lived script exit on its own
|
|
81
|
+
* schedule instead of waiting out our timer — and what makes `beforeExit`
|
|
82
|
+
* fire at all, since a ref'd timer would keep the event loop alive forever. */
|
|
83
|
+
private arm;
|
|
84
|
+
/** Send everything queued when this was called, and resolve when it has been
|
|
85
|
+
* delivered or counted as dropped. Never rejects. */
|
|
86
|
+
flush(): Promise<void>;
|
|
87
|
+
/** Stop accepting, drain what is queued, and return anything left behind.
|
|
88
|
+
*
|
|
89
|
+
* The stranded records are handed BACK rather than swallowed so the caller can
|
|
90
|
+
* count them: an abandoned buffer nobody counts is exactly the invisible loss
|
|
91
|
+
* the delivery accounting exists to make visible. */
|
|
92
|
+
close(timeoutMs?: number): Promise<BatchRecord[]>;
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=batching.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"batching.d.ts","sourceRoot":"","sources":["../src/batching.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,eAAO,MAAM,UAAU,sBAAsB,CAAC;AAE9C;;;;;oDAKoD;AACpD,eAAO,MAAM,mBAAmB,MAAM,CAAC;AACvC,eAAO,MAAM,gBAAgB,QAAS,CAAC;AAEvC;;6EAE6E;AAC7E,eAAO,MAAM,iBAAiB,QAAS,CAAC;AAExC,kEAAkE;AAClE,eAAO,MAAM,mBAAmB,OAAQ,CAAC;AAEzC,gFAAgF;AAChF,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED,MAAM,WAAW,cAAc;IAC7B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAgBD;;;;;GAKG;AACH,qBAAa,OAAO;IAClB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,OAAO,CAAC,KAAK,CAAqB;IAClC,OAAO,CAAC,KAAK,CAA8C;IAC3D,OAAO,CAAC,QAAQ,CAAoC;IACpD;;;kEAG8D;IAC9D,MAAM,UAAS;IAKf,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA0C;gBAG7D,IAAI,EAAE,CAAC,KAAK,EAAE,WAAW,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,EAC7C,OAAO,GAAE,cAAmB;IAgB9B;;;mCAG+B;IAC/B,MAAM,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO;IAiBpC;;;;oFAIgF;IAChF,OAAO,CAAC,GAAG;IAYX;0DACsD;IAChD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAsB5B;;;;0DAIsD;IAChD,KAAK,CAAC,SAAS,GAAE,MAA4B,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;CAqB7E"}
|
package/dist/batching.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The buffer that decides WHEN a metered row goes over the wire.
|
|
3
|
+
*
|
|
4
|
+
* The full contract, with the arithmetic behind every default, is
|
|
5
|
+
* `docs/reference/SDK_BATCHING.md`, and this file is the TS twin of
|
|
6
|
+
* `margin_meter/batching.py` — the two must agree, because they are one contract
|
|
7
|
+
* a customer integrates against, not two implementations.
|
|
8
|
+
*
|
|
9
|
+
* The short version: at 1M metered calls/day, one call per HTTP request is 30M
|
|
10
|
+
* Vercel invocations a month and the same traffic batched at 100 is 300K. 100x on
|
|
11
|
+
* a line item Margin pays. It lives in the SDK rather than the server because
|
|
12
|
+
* only the client can decide not to make the request.
|
|
13
|
+
*
|
|
14
|
+
* ⛔ Two properties are not negotiable, and both are about the customer's process
|
|
15
|
+
* rather than our data:
|
|
16
|
+
*
|
|
17
|
+
* 1. **Nothing blocks the caller.** `submit` pushes onto an array and returns.
|
|
18
|
+
* There is no thread here and none is needed — the flush runs off the caller's
|
|
19
|
+
* await chain, driven by an `unref`'d timer so it never holds a process open.
|
|
20
|
+
* 2. **The queue is BOUNDED and drops the NEWEST.** An unbounded buffer behind a
|
|
21
|
+
* dead endpoint is an OOM with our name on it, and taking down a customer's
|
|
22
|
+
* production agent because our meter was unavailable is the worst thing this
|
|
23
|
+
* SDK could do. Drop-newest leaves a contiguous prefix and a gap at the tail,
|
|
24
|
+
* which reads as "we stopped recording at 14:20"; drop-oldest leaves a hole in
|
|
25
|
+
* the middle of history, which reads as "they spent less that hour" — an
|
|
26
|
+
* under-count in the flattering direction wearing the costume of a measurement.
|
|
27
|
+
*/
|
|
28
|
+
export const BATCH_PATH = "/api/ingest/batch";
|
|
29
|
+
/** Flush on 100 records OR 10 seconds, whichever comes first. The crossover is
|
|
30
|
+
* 100 ÷ 10 = 10 calls/sec: above it size fires first and invocations scale as
|
|
31
|
+
* volume ÷ 100; below it the timer governs and invocations cap at 8,640/day
|
|
32
|
+
* however quiet the customer is. Each trigger covers the other's failure — size
|
|
33
|
+
* alone stalls forever on a low-volume customer, time alone spends a fixed
|
|
34
|
+
* ~518K invocations/month whatever the traffic. */
|
|
35
|
+
export const DEFAULT_MAX_RECORDS = 100;
|
|
36
|
+
export const DEFAULT_FLUSH_MS = 10_000;
|
|
37
|
+
/** ~14 minutes of buffered traffic at 11.6 calls/sec — enough to ride out a
|
|
38
|
+
* deploy or a short outage of ours. A row is 400–1500 bytes, so the ceiling on
|
|
39
|
+
* the host's memory is 4–15 MB, which is the number to quote a customer. */
|
|
40
|
+
export const DEFAULT_MAX_QUEUE = 10_000;
|
|
41
|
+
/** Bounded so a shutdown flush can never hang the host's exit. */
|
|
42
|
+
export const SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
43
|
+
/** Read a positive tunable from the environment, ignoring nonsense.
|
|
44
|
+
*
|
|
45
|
+
* A malformed or non-positive value falls back to the default rather than
|
|
46
|
+
* throwing: a typo in a deployment variable must never stop a customer's process
|
|
47
|
+
* from starting, and a zero flush interval would be a busy loop. */
|
|
48
|
+
function envNumber(name, fallback, minimum) {
|
|
49
|
+
const env = (typeof process !== "undefined" && process.env) || {};
|
|
50
|
+
const raw = env[name];
|
|
51
|
+
if (raw === undefined)
|
|
52
|
+
return fallback;
|
|
53
|
+
const value = Number(raw);
|
|
54
|
+
if (!Number.isFinite(value) || value < minimum)
|
|
55
|
+
return fallback;
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A bounded queue plus a timer that flushes it on size or time.
|
|
60
|
+
*
|
|
61
|
+
* `send` is awaited off the caller's path and is expected never to reject — it
|
|
62
|
+
* owns the POST, the retries and the accounting.
|
|
63
|
+
*/
|
|
64
|
+
export class Batcher {
|
|
65
|
+
maxRecords;
|
|
66
|
+
flushMs;
|
|
67
|
+
maxQueue;
|
|
68
|
+
queue = [];
|
|
69
|
+
timer = null;
|
|
70
|
+
inFlight = Promise.resolve();
|
|
71
|
+
/** True once `close()` has run. Public so a caller can report a refused emit
|
|
72
|
+
* under the reason that actually caused it: "the meter was shut down" and "the
|
|
73
|
+
* buffer is full" have opposite fixes, and filing one as the other sends the
|
|
74
|
+
* reader hunting for a network problem that is not there. */
|
|
75
|
+
closed = false;
|
|
76
|
+
// ⚠️ Declared rather than written as a constructor parameter property: Node's
|
|
77
|
+
// strip-only TypeScript mode (which this SDK's own test runner uses) rejects
|
|
78
|
+
// parameter properties outright, and a package that cannot be loaded by the
|
|
79
|
+
// runtime its tests run on is not testable.
|
|
80
|
+
send;
|
|
81
|
+
constructor(send, options = {}) {
|
|
82
|
+
this.send = send;
|
|
83
|
+
this.maxRecords = Math.floor(options.maxRecords ?? envNumber("MARGIN_BATCH_MAX_RECORDS", DEFAULT_MAX_RECORDS, 1));
|
|
84
|
+
// The env var is in SECONDS so the two SDKs read the same variable the same
|
|
85
|
+
// way; a deployment should not have to remember which one wants milliseconds.
|
|
86
|
+
this.flushMs =
|
|
87
|
+
options.flushMs ??
|
|
88
|
+
envNumber("MARGIN_BATCH_FLUSH_SECONDS", DEFAULT_FLUSH_MS / 1000, 0.001) * 1000;
|
|
89
|
+
this.maxQueue = Math.floor(options.maxQueue ?? envNumber("MARGIN_BATCH_MAX_QUEUE", DEFAULT_MAX_QUEUE, 1));
|
|
90
|
+
}
|
|
91
|
+
/** Queue one record. `false` when the queue was full and it was DROPPED.
|
|
92
|
+
*
|
|
93
|
+
* ⛔ A bounded push and a size check are the entire cost on the caller's path.
|
|
94
|
+
* Nothing here is awaited. */
|
|
95
|
+
submit(record) {
|
|
96
|
+
if (this.closed)
|
|
97
|
+
return false;
|
|
98
|
+
if (this.queue.length >= this.maxQueue) {
|
|
99
|
+
// Drop the NEWEST — see the module comment. The rows already queued have
|
|
100
|
+
// waited longer and, under a sustained outage, are the ones whose loss
|
|
101
|
+
// would put a hole in the middle of the ledger rather than at its end.
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
this.queue.push(record);
|
|
105
|
+
if (this.queue.length >= this.maxRecords) {
|
|
106
|
+
void this.flush();
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
this.arm();
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
/** Start the interval timer if it is not already running.
|
|
113
|
+
*
|
|
114
|
+
* ⭐ `unref`'d, which is what lets a short-lived script exit on its own
|
|
115
|
+
* schedule instead of waiting out our timer — and what makes `beforeExit`
|
|
116
|
+
* fire at all, since a ref'd timer would keep the event loop alive forever. */
|
|
117
|
+
arm() {
|
|
118
|
+
if (this.timer !== null || this.closed)
|
|
119
|
+
return;
|
|
120
|
+
const timer = setTimeout(() => {
|
|
121
|
+
this.timer = null;
|
|
122
|
+
void this.flush();
|
|
123
|
+
}, this.flushMs);
|
|
124
|
+
if (typeof timer.unref === "function") {
|
|
125
|
+
timer.unref();
|
|
126
|
+
}
|
|
127
|
+
this.timer = timer;
|
|
128
|
+
}
|
|
129
|
+
/** Send everything queued when this was called, and resolve when it has been
|
|
130
|
+
* delivered or counted as dropped. Never rejects. */
|
|
131
|
+
async flush() {
|
|
132
|
+
if (this.timer !== null) {
|
|
133
|
+
clearTimeout(this.timer);
|
|
134
|
+
this.timer = null;
|
|
135
|
+
}
|
|
136
|
+
// Chained rather than parallel: two overlapping flushes would interleave
|
|
137
|
+
// batches and make "everything queued when I called" unanswerable.
|
|
138
|
+
const previous = this.inFlight;
|
|
139
|
+
this.inFlight = (async () => {
|
|
140
|
+
await previous;
|
|
141
|
+
while (this.queue.length > 0) {
|
|
142
|
+
const batch = this.queue.splice(0, this.maxRecords);
|
|
143
|
+
try {
|
|
144
|
+
await this.send(batch);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
/* `send` owns its own accounting; a throw here must not stop the drain */
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
})();
|
|
151
|
+
await this.inFlight;
|
|
152
|
+
}
|
|
153
|
+
/** Stop accepting, drain what is queued, and return anything left behind.
|
|
154
|
+
*
|
|
155
|
+
* The stranded records are handed BACK rather than swallowed so the caller can
|
|
156
|
+
* count them: an abandoned buffer nobody counts is exactly the invisible loss
|
|
157
|
+
* the delivery accounting exists to make visible. */
|
|
158
|
+
async close(timeoutMs = SHUTDOWN_TIMEOUT_MS) {
|
|
159
|
+
this.closed = true;
|
|
160
|
+
if (this.timer !== null) {
|
|
161
|
+
clearTimeout(this.timer);
|
|
162
|
+
this.timer = null;
|
|
163
|
+
}
|
|
164
|
+
let timer;
|
|
165
|
+
const bounded = new Promise((resolve) => {
|
|
166
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
167
|
+
if (typeof timer.unref === "function") {
|
|
168
|
+
timer.unref();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
// ⛔ Raced against a timeout so a wedged endpoint can never hang the host's
|
|
172
|
+
// exit. Whatever is still queued when the race ends is returned, not lost.
|
|
173
|
+
await Promise.race([this.flush(), bounded]);
|
|
174
|
+
if (timer !== undefined)
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
const stranded = this.queue;
|
|
177
|
+
this.queue = [];
|
|
178
|
+
return stranded;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
//# sourceMappingURL=batching.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"batching.js","sourceRoot":"","sources":["../src/batching.ts"],"names":[],"mappings":"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"}
|