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 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://margin-ai-rho.vercel.app"
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"}
@@ -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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"}