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/dist/index.js CHANGED
@@ -22,18 +22,65 @@
22
22
  * client cannot spoof another project's economics.
23
23
  */
24
24
  import { extractUsage, StreamUsageAccumulator, } from "./providers.js";
25
+ import { RETRIES, checkVerdict, requestFields, servingHost } from "./capture.js";
26
+ import { BATCH_PATH, Batcher, SHUTDOWN_TIMEOUT_MS, } from "./batching.js";
27
+ // The Node analogue of Python's `contextvars`: it carries a value across the
28
+ // async/await chain of the callback it runs, so a call metered several awaits
29
+ // deep inside `stage(...)` still sees the enclosing step. `node:async_hooks` is
30
+ // a Node builtin (stable since 16; this package requires node>=18) and is
31
+ // present in Bun and the edge runtimes too. It is the ONE thing `stage`/`span`
32
+ // need that a browser bundle would not have — and those primitives are the whole
33
+ // reason to reach for it — so a caller instrumenting server-side agent code (the
34
+ // only place an LLM key belongs) always has it.
35
+ import { AsyncLocalStorage } from "node:async_hooks";
25
36
  export * from "./providers.js";
26
37
  export * from "./instrument.js";
38
+ export * from "./batching.js";
27
39
  // Namespaced, not flattened: `BEDROCK` means two different things here — the
28
40
  // provider that bills for the model and the path the request took — and merging
29
41
  // them into one barrel would collide the names and blur the distinction the
30
42
  // substrate dimension exists to draw. `substrate.BEDROCK` reads as the path.
31
43
  export * as substrate from "./substrate.js";
44
+ // Namespaced: an eval adapter reuses common verb names (`recordResults`,
45
+ // `outcomeInput`), so `promptfoo.recordResults(...)` keeps the framework it
46
+ // bridges legible at the call site — the Python SDK's `margin_meter.promptfoo`
47
+ // twin (`docs/INTEGRATION_LADDER.md` rung 2).
48
+ export * as promptfoo from "./promptfoo.js";
49
+ // The second eval adapter (rung 2), same namespaced shape — `braintrust.recordResults(...)`.
50
+ export * as braintrust from "./braintrust.js";
51
+ // The third eval adapter (rung 2), same namespaced shape — `langsmith.recordResults(...)`.
52
+ export * as langsmith from "./langsmith.js";
53
+ // The test-runner adapter (rung 2/6): one vitest/jest JSON file, every test an
54
+ // outcome — `vitest.recordResults(...)`. The pytest plugin's TS twin.
55
+ export * as vitest from "./vitest.js";
32
56
  export const CALLS_PATH = "/api/ingest/calls";
33
57
  export const OUTCOMES_PATH = "/api/ingest/outcomes";
34
58
  export const KEY_HEADER = "X-Margin-Key";
35
- export const DEFAULT_INGEST_URL = "http://127.0.0.1:8000";
59
+ // ⛔ No default ingest URL, on purpose. It used to be `http://127.0.0.1:8000`, so a
60
+ // meter given a key and no URL POSTed to the caller's own laptop and dropped every row
61
+ // (a real authorized run on 2026-09-15 spent money and landed nothing that way). An
62
+ // unset URL is refused as `no_url` at once, like `no_key`, never guessed.
36
63
  export const DEFAULT_TIMEOUT_MS = 10_000;
64
+ /** One batched request's byte ceiling, and the retry policy behind it. Full
65
+ * reasoning in `docs/reference/SDK_BATCHING.md`; the short version:
66
+ *
67
+ * - 1 MB is ~7x headroom over a worst-case 1500-byte row at the default 100
68
+ * records, and far under any gateway limit. Vercel accepts 100 MB bodies;
69
+ * that is a ceiling, not a target.
70
+ * - 3 attempts with FULL jitter rather than plain exponential, because every
71
+ * buffered client backs off on the same schedule and un-jittered retries
72
+ * re-synchronise into a herd aimed at the endpoint that just failed.
73
+ * - Duplicates from an at-least-once retry are free: every record carries an
74
+ * `event_id` and the server is idempotent on `(source, event_id)`.
75
+ */
76
+ export const MAX_BATCH_BYTES = 1_000_000;
77
+ export const RETRY_ATTEMPTS = 3;
78
+ export const RETRY_BASE_MS = 1_000;
79
+ export const RETRY_CAP_MS = 30_000;
80
+ /** Statuses worth resending. Everything else (401, 422, 400) fails identically
81
+ * next time, so retrying is a hot loop that never drains — and against a bad key
82
+ * it would hammer us forever. */
83
+ const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
37
84
  /** A fresh idempotency key per emit. Uses the platform crypto UUID (Node 16.7+,
38
85
  * modern browsers); falls back to a random-hex id where crypto is absent, which
39
86
  * is still unique enough to key a single call's dedup. */
@@ -45,6 +92,88 @@ function newEventId() {
45
92
  Math.random().toString(16).slice(2) +
46
93
  Math.random().toString(16).slice(2));
47
94
  }
95
+ // --------------------------------------------------------------------------- //
96
+ // The chain — `stage()` and `span()` (MAR-899, the TS twin of the Python SDK).
97
+ // --------------------------------------------------------------------------- //
98
+ // The wire fields (`spanId` / `parentSpanId` / `taskClass`) have always been on
99
+ // `RecordCallInput`, but a TS user had to thread the ids through their own code by
100
+ // hand — so `margin_stage_plan`, the per-step model/effort advice, worked out of
101
+ // the box for a Python customer and needed manual plumbing for a TS one. That is a
102
+ // real asymmetry on the integration path we describe to design partners as two
103
+ // plugs. These two blocks close it: wrap a step and every call it makes, however
104
+ // many awaits deep, records under one node with the right label — no ids by hand.
105
+ //
106
+ // Honesty, identical to the Python side: `stage`/`span` RE-LABEL and LINK only.
107
+ // Tokens, cost and the outcome are never touched, so a call inside one prices
108
+ // byte-for-byte the same as one before this existed.
109
+ /** A 32-hex span id, the shape of Python's `uuid4().hex` (no dashes). Uses the
110
+ * platform crypto UUID where present, a random-hex id otherwise — either is
111
+ * unique enough to key one run's tree. */
112
+ function newSpanId() {
113
+ const c = globalThis.crypto;
114
+ if (c?.randomUUID)
115
+ return c.randomUUID().replace(/-/g, "");
116
+ return (Math.random().toString(16).slice(2) +
117
+ Math.random().toString(16).slice(2) +
118
+ Date.now().toString(16))
119
+ .replace(/[^0-9a-f]/g, "")
120
+ .slice(0, 32)
121
+ .padEnd(32, "0");
122
+ }
123
+ const _spanStore = new AsyncLocalStorage();
124
+ const _stageStore = new AsyncLocalStorage();
125
+ export function stage(operation, optsOrFn, maybeFn) {
126
+ const opts = typeof optsOrFn === "function" ? {} : optsOrFn;
127
+ const fn = (typeof optsOrFn === "function" ? optsOrFn : maybeFn);
128
+ const parent = _spanStore.getStore();
129
+ const ctx = {
130
+ spanId: newSpanId(),
131
+ parentSpanId: opts.parentSpanId ?? parent?.spanId,
132
+ };
133
+ const override = {
134
+ operation,
135
+ sessionId: opts.sessionId,
136
+ taskClass: opts.taskClass,
137
+ };
138
+ return _stageStore.run(override, () => _spanStore.run(ctx, () => fn(ctx.spanId)));
139
+ }
140
+ /** The active `(operation, sessionId, taskClass)` step override, or `undefined`
141
+ * outside any `stage`. */
142
+ export function currentStage() {
143
+ return _stageStore.getStore();
144
+ }
145
+ /** Mark every metered call inside `fn` as one node of an agent tree — the tree the
146
+ * Money Map decomposes to ask what a BRANCH cost, which a flat list of calls
147
+ * sharing a `sessionId` cannot answer. Nesting is what builds the tree; returns
148
+ * whatever `fn` returns.
149
+ *
150
+ * ```ts
151
+ * await span({ pattern: "panel", branchWidth: 3, stoppingRule: "majority of 3" }, () =>
152
+ * Promise.all(candidates.map((c) => judge(c))), // each judge nests its own node
153
+ * );
154
+ * ```
155
+ *
156
+ * `stage()` is the primitive to reach for on a plain pipeline step; `span()` adds
157
+ * the orchestration shape when a block fans out. This is the TS twin of
158
+ * `margin_meter.span`. */
159
+ export function span(opts, fn) {
160
+ const parent = _spanStore.getStore();
161
+ const ctx = {
162
+ spanId: opts.spanId ?? newSpanId(),
163
+ parentSpanId: opts.parentSpanId ?? parent?.spanId,
164
+ pattern: opts.pattern,
165
+ branchWidth: opts.branchWidth,
166
+ stoppingRule: opts.stoppingRule,
167
+ };
168
+ return _spanStore.run(ctx, () => fn(ctx.spanId));
169
+ }
170
+ /** The active `(spanId, parentSpanId)`, or `undefined` outside any span/stage. */
171
+ export function currentSpan() {
172
+ const ctx = _spanStore.getStore();
173
+ return ctx === undefined
174
+ ? undefined
175
+ : { spanId: ctx.spanId, parentSpanId: ctx.parentSpanId };
176
+ }
48
177
  /** Raised (only in strict mode) when a row is rejected by the ingest API. */
49
178
  export class MarginIngestError extends Error {
50
179
  statusCode;
@@ -86,47 +215,406 @@ export class MarginMeter {
86
215
  apiKey;
87
216
  raiseOnError;
88
217
  transport;
218
+ attempted = 0;
219
+ delivered = 0;
220
+ droppedByReason = {};
221
+ attemptedByPath = {};
222
+ droppedByPath = {};
223
+ warnedOnDrop = false;
224
+ noUrl;
225
+ batcher;
226
+ exitFlush;
227
+ exitFlushArmed = false;
89
228
  constructor(options = {}) {
90
229
  const env = (typeof process !== "undefined" && process.env) || {};
91
- this.ingestUrl = options.ingestUrl || env.MARGIN_INGEST_URL || DEFAULT_INGEST_URL;
230
+ this.ingestUrl = options.ingestUrl || env.MARGIN_INGEST_URL || "";
231
+ // An injected transport already knows where it posts; only our own fetch needs a URL.
232
+ this.noUrl = !this.ingestUrl && !options.transport;
92
233
  this.apiKey = options.apiKey || env.MARGIN_INGEST_KEY;
93
234
  this.raiseOnError = options.raiseOnError ?? false;
94
235
  this.transport =
95
236
  options.transport ||
96
237
  new FetchTransport(this.ingestUrl, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
238
+ this.batcher = new Batcher((batch) => this.sendBatch(batch));
239
+ // ⭐ The shutdown flush. `beforeExit`, deliberately NOT a signal handler:
240
+ // installing SIGTERM/SIGINT from a library steals signal handling from a host
241
+ // that usually has its own graceful shutdown. It fires when the event loop
242
+ // empties — which the batcher's `unref`'d timer is careful not to prevent —
243
+ // and it is async-capable, unlike `exit`.
244
+ //
245
+ // ⛔ FLUSH, not close, and the difference is not cosmetic. `beforeExit` fires
246
+ // whenever the loop drains, which in a long-lived host happens between bursts
247
+ // of work rather than only at the end — closing there would wedge the meter
248
+ // for the rest of the process's life, and every later call would be dropped.
249
+ // Caught exactly that way: a test run went quiet for a tick and two subsequent
250
+ // rows were refused by a batcher that had "shut down" mid-suite. Flushing is
251
+ // idempotent and safe to fire any number of times; closing is an explicit,
252
+ // one-way act the host asks for with `await meter.close()`.
253
+ //
254
+ // ⚠️ A hard kill loses the buffer, bounded by the flush interval. Said plainly
255
+ // rather than glossed: no in-memory buffer survives SIGKILL, and pretending
256
+ // otherwise would be a fabricated guarantee.
257
+ //
258
+ // ⭐ BOUNDED, and registered LAZILY on the first buffered row (see `enqueue`).
259
+ // Registering in the constructor put one listener per meter on `process` for
260
+ // the process's lifetime, each pinning its meter and up to `maxQueue` records
261
+ // — Node warns at eleven, and the meter that never emits pays for a handler it
262
+ // will never need. `close()` removes it again.
263
+ this.exitFlush = () => {
264
+ void Promise.race([this.batcher.flush(), sleep(SHUTDOWN_TIMEOUT_MS)]);
265
+ };
266
+ }
267
+ /** Attach the process-exit flush, once, on the first row that will need it. */
268
+ armExitFlush() {
269
+ if (this.exitFlushArmed)
270
+ return;
271
+ if (typeof process === "undefined" || typeof process.on !== "function")
272
+ return;
273
+ this.exitFlushArmed = true;
274
+ process.on("beforeExit", this.exitFlush);
275
+ }
276
+ /** Record one emit attempt. Called at every exit point that knows its own
277
+ * outcome, BEFORE any `raiseOnError` throw, so a strict-mode failure is
278
+ * counted exactly like the fail-safe one it would otherwise have been. */
279
+ count(path, ok, reason) {
280
+ this.attempted += 1;
281
+ this.attemptedByPath[path] = (this.attemptedByPath[path] ?? 0) + 1;
282
+ if (ok) {
283
+ this.delivered += 1;
284
+ return;
285
+ }
286
+ const bucket = reason ?? "unknown";
287
+ this.droppedByReason[bucket] = (this.droppedByReason[bucket] ?? 0) + 1;
288
+ this.droppedByPath[path] = (this.droppedByPath[path] ?? 0) + 1;
289
+ if (!this.warnedOnDrop) {
290
+ this.warnedOnDrop = true;
291
+ // Once per meter, on the FIRST drop only, so a silent drop stops being
292
+ // silent without letting a flapping network flood the host's own logs.
293
+ // The RATE lives in deliveryStats(); this line exists to send someone there.
294
+ console.warn(`margin-meter: an emit was dropped (${bucket}) — this row is NOT in the ` +
295
+ `ledger. Metering is fail-safe, so the host call was unaffected. ` +
296
+ `Call meter.deliveryStats() for the running count.`);
297
+ }
298
+ }
299
+ /** A snapshot of what this meter delivered against what it tried to.
300
+ * See {@link DeliveryStats}. */
301
+ deliveryStats() {
302
+ const dropped = this.attempted - this.delivered;
303
+ return {
304
+ attempted: this.attempted,
305
+ delivered: this.delivered,
306
+ dropped,
307
+ dropRate: this.attempted ? dropped / this.attempted : 0,
308
+ droppedByReason: { ...this.droppedByReason },
309
+ attemptedByPath: { ...this.attemptedByPath },
310
+ droppedByPath: { ...this.droppedByPath },
311
+ };
97
312
  }
313
+ /**
314
+ * Route one built payload to the wire or the buffer.
315
+ *
316
+ * Buffering is the default and the whole point (`docs/reference/SDK_BATCHING.md`).
317
+ * Two cases deliberately stay immediate, and neither is a knob:
318
+ *
319
+ * - **Strict mode** (`raiseOnError`) means "throw at MY call site when this row
320
+ * is rejected", and a buffered emit has no call site left to throw at by the
321
+ * time the POST happens. The two are incompatible, so strict posts now.
322
+ * - **No key**, because buffering rows that can never be sent would fill the
323
+ * queue and then report `queue_full` — a diagnosis that sends the reader
324
+ * hunting for a network problem when the answer is a missing variable.
325
+ * A missing URL is the same case and is named `no_url`.
326
+ */
98
327
  async emit(path, payload) {
99
328
  if (!this.apiKey) {
100
329
  const error = "no ingest key (set MARGIN_INGEST_KEY or pass apiKey)";
330
+ this.count(path, false, "no_key");
331
+ if (this.raiseOnError)
332
+ throw new MarginConfigError(error);
333
+ return { ok: false, statusCode: 0, error, reason: "no_key" };
334
+ }
335
+ if (this.noUrl) {
336
+ const error = "no ingest URL (set MARGIN_INGEST_URL=https://trymargin.io or pass ingestUrl)";
337
+ this.count(path, false, "no_url");
101
338
  if (this.raiseOnError)
102
339
  throw new MarginConfigError(error);
103
- return { ok: false, statusCode: 0, error };
340
+ return { ok: false, statusCode: 0, error, reason: "no_url" };
341
+ }
342
+ if (!this.raiseOnError)
343
+ return this.enqueue(path, payload);
344
+ return await this.postNow(path, payload);
345
+ }
346
+ /** Hand one record to the buffer. Never blocks, never throws.
347
+ *
348
+ * `ok: true` here means ACCEPTED INTO THE BUFFER, not written to the ledger —
349
+ * the body says `queued` so nothing mistakes the two. What actually landed is
350
+ * `deliveryStats()`, which exists for exactly this question. */
351
+ enqueue(path, payload) {
352
+ if (this.batcher.submit({ path, payload })) {
353
+ this.armExitFlush();
354
+ return { ok: true, statusCode: 0, body: { queued: true } };
355
+ }
356
+ // Refused. Either our endpoint is unreachable and the buffer filled, or the
357
+ // meter was explicitly closed and is still being written to — opposite fixes,
358
+ // so they are counted apart rather than under one bucket. Dropping is correct
359
+ // in both cases (an unbounded buffer would OOM the customer's agent) but it is
360
+ // COUNTED, because a meter that silently stops metering is the one failure
361
+ // this product cannot have.
362
+ const reason = this.batcher.closed ? "meter_closed" : "queue_full";
363
+ const error = this.batcher.closed
364
+ ? "meter is closed — this row is NOT in the ledger"
365
+ : `meter buffer full (${this.batcher.maxQueue} records) — this row is NOT in the ledger`;
366
+ this.count(path, false, reason);
367
+ return { ok: false, statusCode: 0, error, reason };
368
+ }
369
+ /** Resolve once every buffered row has been delivered or counted as dropped.
370
+ *
371
+ * Call it before reading `deliveryStats()` if you need the counts to cover rows
372
+ * you have only just emitted — buffering separates an emit from its delivery
373
+ * in time by design. */
374
+ async flush() {
375
+ await this.batcher.flush();
376
+ }
377
+ /** Drain the buffer and stop the timer. Registered on `beforeExit`.
378
+ *
379
+ * Anything the timeout strands is COUNTED as dropped rather than abandoned: the
380
+ * records are handed back by `Batcher.close` precisely so the number is
381
+ * knowable. */
382
+ async close(timeoutMs = SHUTDOWN_TIMEOUT_MS) {
383
+ // Detached first so a meter the host closed explicitly does not also hold a
384
+ // process-exit listener (and a reference to itself) alive for the rest of the
385
+ // process.
386
+ if (this.exitFlushArmed && typeof process !== "undefined") {
387
+ process.off("beforeExit", this.exitFlush);
388
+ this.exitFlushArmed = false;
389
+ }
390
+ for (const record of await this.batcher.close(timeoutMs)) {
391
+ this.count(record.path, false, "shutdown_timeout");
104
392
  }
393
+ }
394
+ async postNow(path, payload) {
105
395
  let resp;
106
396
  try {
107
397
  resp = await this.transport.post(path, {
108
398
  json: payload,
109
- headers: { [KEY_HEADER]: this.apiKey },
399
+ headers: { [KEY_HEADER]: this.apiKey ?? "" },
110
400
  });
111
401
  }
112
402
  catch (e) {
113
403
  const error = `transport error: ${e.message}`;
404
+ this.count(path, false, "transport");
114
405
  if (this.raiseOnError)
115
406
  throw new MarginIngestError(0, e.message);
116
- return { ok: false, statusCode: 0, error };
407
+ return { ok: false, statusCode: 0, error, reason: "transport" };
117
408
  }
118
409
  if (resp.status === 200) {
119
410
  const body = (await resp.json());
411
+ this.count(path, true);
120
412
  return { ok: true, statusCode: 200, body };
121
413
  }
414
+ const reason = `http_${resp.status}`;
415
+ this.count(path, false, reason);
122
416
  const detail = await extractDetail(resp);
123
417
  if (this.raiseOnError)
124
418
  throw new MarginIngestError(resp.status, detail);
125
- return { ok: false, statusCode: resp.status, error: detail };
419
+ return { ok: false, statusCode: resp.status, error: detail, reason };
420
+ }
421
+ // --------------------------------------------------------------------- //
422
+ // Delivery — what happens off the caller's path
423
+ // --------------------------------------------------------------------- //
424
+ /** POST one buffered batch. Never rejects: it owns its own accounting.
425
+ *
426
+ * ⛔ The blanket `catch` is load-bearing, not defensive habit. `Batcher.flush`
427
+ * swallows a rejection to keep the drain going, so anything escaping here would
428
+ * vanish with the counters reading a clean sheet — an uncounted loss, which
429
+ * makes measured spend read CHEAPER than the bill. Scoped per chunk so records
430
+ * already accounted for in an earlier chunk are not counted twice. */
431
+ async sendBatch(records) {
432
+ for (const chunk of this.chunkByBytes(records)) {
433
+ try {
434
+ await this.deliverChunk(chunk);
435
+ }
436
+ catch (e) {
437
+ console.warn(`margin-meter: batch send failed unexpectedly: ${e.message}`);
438
+ for (const record of chunk)
439
+ this.count(record.path, false, "send_error");
440
+ }
441
+ }
442
+ }
443
+ /** Split a batch so no request exceeds {@link MAX_BATCH_BYTES}.
444
+ *
445
+ * ⛔ A single record over the cap on its own is DROPPED and counted as
446
+ * `oversize`. No split can send it, and keeping it would wedge the queue behind
447
+ * one poisoned row forever. It is a bug in the caller — every open-vocabulary
448
+ * field is already bounded server-side, so an honest row cannot come near a
449
+ * megabyte.
450
+ *
451
+ * ⛔ A payload that will not serialise is dropped and counted too, and that case
452
+ * is not exotic — a `BigInt` anywhere in the payload makes `JSON.stringify`
453
+ * throw. Unguarded, the throw escaped into the flush and the row vanished with
454
+ * the counters reading a clean sheet. */
455
+ chunkByBytes(records) {
456
+ const chunks = [];
457
+ let current = [];
458
+ let size = 0;
459
+ for (const record of records) {
460
+ let weight;
461
+ try {
462
+ weight = JSON.stringify(record.payload).length + 2;
463
+ }
464
+ catch {
465
+ this.count(record.path, false, "unserializable");
466
+ continue;
467
+ }
468
+ if (weight > MAX_BATCH_BYTES) {
469
+ this.count(record.path, false, "oversize");
470
+ continue;
471
+ }
472
+ if (current.length > 0 && size + weight > MAX_BATCH_BYTES) {
473
+ chunks.push(current);
474
+ current = [];
475
+ size = 0;
476
+ }
477
+ current.push(record);
478
+ size += weight;
479
+ }
480
+ if (current.length > 0)
481
+ chunks.push(current);
482
+ return chunks;
483
+ }
484
+ async deliverChunk(records) {
485
+ const calls = records.filter((r) => r.path === CALLS_PATH);
486
+ const outcomes = records.filter((r) => r.path === OUTCOMES_PATH);
487
+ const body = {};
488
+ if (calls.length > 0)
489
+ body.calls = calls.map((r) => r.payload);
490
+ if (outcomes.length > 0)
491
+ body.outcomes = outcomes.map((r) => r.payload);
492
+ if (calls.length === 0 && outcomes.length === 0)
493
+ return;
494
+ const { parsed, reason } = await this.postBatchWithRetry(body);
495
+ if (!parsed) {
496
+ // The request never succeeded. Every record in it is a loss, counted under
497
+ // the reason that caused it — and NOT re-queued: an unbounded retry queue is
498
+ // the OOM this design bounds, arriving by another door.
499
+ for (const record of records)
500
+ this.count(record.path, false, reason);
501
+ return;
502
+ }
503
+ for (const [key, group] of [
504
+ ["calls", calls],
505
+ ["outcomes", outcomes],
506
+ ]) {
507
+ const raw = parsed[key];
508
+ const results = Array.isArray(raw) ? raw : [];
509
+ group.forEach((record, index) => {
510
+ const outcome = results[index];
511
+ // ⚠️ Positional alignment is the contract, so a short or missing results
512
+ // array means we cannot say what happened to this row. Counted as a drop
513
+ // under its own reason rather than assumed delivered — assuming success is
514
+ // how an invisible loss starts.
515
+ if (!outcome || typeof outcome !== "object") {
516
+ this.count(record.path, false, "missing_result");
517
+ }
518
+ else if (outcome.ok) {
519
+ this.count(record.path, true);
520
+ }
521
+ else {
522
+ // ⭐ Split by the server's own `retryable` flag. A validation failure is
523
+ // the caller's bug (fix the payload) and a rate-cap refusal is our
524
+ // ceiling (the project is over its daily rows) — opposite fixes, and a
525
+ // reader who cannot tell them apart looks in the wrong place.
526
+ //
527
+ // ⛔ Neither is re-queued, including the retryable one: the cap is a
528
+ // 24-HOUR rolling window, so the server's honest `retry_after_seconds`
529
+ // can be hours. Holding the queue that long would stall every later row
530
+ // behind a wait the buffer cannot survive. The drop is made VISIBLE
531
+ // instead — `rate_limited` in deliveryStats() tells a customer their cap
532
+ // is the reason, and nothing else could.
533
+ this.count(record.path, false, outcome.retryable ? "rate_limited" : "rejected");
534
+ }
535
+ });
536
+ }
537
+ }
538
+ /** POST one batch, retrying the failures that clear with time. */
539
+ async postBatchWithRetry(body) {
540
+ let reason = "transport";
541
+ for (let attempt = 0; attempt < RETRY_ATTEMPTS; attempt += 1) {
542
+ let retryAfterMs;
543
+ try {
544
+ const resp = await this.transport.post(BATCH_PATH, {
545
+ json: body,
546
+ headers: { [KEY_HEADER]: this.apiKey ?? "" },
547
+ });
548
+ if (resp.status === 200) {
549
+ try {
550
+ return { parsed: (await resp.json()), reason: "" };
551
+ }
552
+ catch {
553
+ // A 200 whose body we cannot read tells us nothing about which rows
554
+ // landed. Retrying is safe (event_id dedup) and beats guessing.
555
+ reason = "unparseable_response";
556
+ }
557
+ }
558
+ else if (RETRYABLE_STATUS.has(resp.status)) {
559
+ reason = `http_${resp.status}`;
560
+ retryAfterMs = retryAfterMs_(resp);
561
+ }
562
+ else {
563
+ // 401/422/400: identical next time. Stop rather than loop.
564
+ return { reason: `http_${resp.status}` };
565
+ }
566
+ }
567
+ catch {
568
+ reason = "transport";
569
+ }
570
+ if (attempt + 1 >= RETRY_ATTEMPTS)
571
+ break;
572
+ await sleep(backoffMs(attempt, retryAfterMs));
573
+ }
574
+ return { reason };
126
575
  }
127
576
  /** Emit one measured LLM call. Cost is computed server-side when `costUsd`
128
577
  * is omitted. Never throws unless `raiseOnError`. */
129
578
  async recordCall(input) {
579
+ // Resolve the ambient step/tree BEFORE the payload, with the Python SDK's
580
+ // exact precedence — the two rules differ on purpose:
581
+ // • a `stage`'s operation/sessionId OVERRIDE what the call passed (that is
582
+ // how one run decomposes into named per-stage operations), while
583
+ // • its taskClass and every span field FILL only what the call left unset
584
+ // (an explicit `parentSpanId` is usually a branch reattached across a
585
+ // worker boundary — the ambient span must not overwrite it).
586
+ // Labels and links only: no cost/token/outcome field is touched here.
587
+ const stageOv = _stageStore.getStore();
588
+ const spanCtx = _spanStore.getStore();
589
+ let operation = input.operation;
590
+ let sessionId = input.sessionId;
591
+ let taskClass = input.taskClass;
592
+ if (stageOv !== undefined) {
593
+ if (stageOv.operation !== undefined)
594
+ operation = stageOv.operation;
595
+ if (stageOv.sessionId !== undefined)
596
+ sessionId = stageOv.sessionId;
597
+ if (stageOv.taskClass !== undefined && taskClass === undefined) {
598
+ taskClass = stageOv.taskClass;
599
+ }
600
+ }
601
+ let spanId = input.spanId;
602
+ let parentSpanId = input.parentSpanId;
603
+ let orchestrationPattern = input.orchestrationPattern;
604
+ let branchWidth = input.branchWidth;
605
+ let stoppingRule = input.stoppingRule;
606
+ if (spanCtx !== undefined) {
607
+ if (spanId === undefined)
608
+ spanId = spanCtx.spanId;
609
+ if (parentSpanId === undefined)
610
+ parentSpanId = spanCtx.parentSpanId;
611
+ if (orchestrationPattern === undefined)
612
+ orchestrationPattern = spanCtx.pattern;
613
+ if (branchWidth === undefined)
614
+ branchWidth = spanCtx.branchWidth;
615
+ if (stoppingRule === undefined)
616
+ stoppingRule = spanCtx.stoppingRule;
617
+ }
130
618
  const payload = {
131
619
  workflow_id: input.workflowId,
132
620
  provider: input.provider,
@@ -135,18 +623,30 @@ export class MarginMeter {
135
623
  output_tokens: input.outputTokens ?? 0,
136
624
  cache_read_tokens: input.cacheReadTokens ?? 0,
137
625
  cache_creation_tokens: input.cacheCreationTokens ?? 0,
138
- latency_ms: input.latencyMs ?? 0,
626
+ // ⛔ `?? null`, NOT `?? 0` (MAR-982). A caller who did not time the call must not
627
+ // have a zero invented for them — the server stores null as NULL, and nothing
628
+ // downstream can tell a substituted 0 from a real one.
629
+ latency_ms: input.latencyMs ?? null,
139
630
  status: input.status ?? "ok",
140
631
  is_retry: input.isRetry ?? false,
141
632
  is_simulated: input.isSimulated ?? false,
142
633
  event_id: input.eventId ?? newEventId(),
143
634
  };
144
- if (input.sessionId !== undefined)
145
- payload.session_id = input.sessionId;
635
+ if (sessionId !== undefined)
636
+ payload.session_id = sessionId;
146
637
  if (input.promptId !== undefined)
147
638
  payload.prompt_id = input.promptId;
148
- if (input.operation !== undefined)
149
- payload.operation = input.operation;
639
+ if (operation !== undefined)
640
+ payload.operation = operation;
641
+ // ⛔ CLAMPED to the server's column widths, matching the Python SDK and the in-repo meter.
642
+ // `ingest._require_str` RAISES rather than truncating, so an over-long label is a 422 for the
643
+ // whole row — and this client defaults to `raiseOnError=false`, so a caller setting e.g.
644
+ // environment="production-us-east-1-blue-green" (37 chars) would silently drop 100% of that
645
+ // service's metered spend. Truncating mislabels a row; raising loses it.
646
+ if (input.environment !== undefined)
647
+ payload.environment = input.environment.slice(0, 32);
648
+ if (input.team !== undefined)
649
+ payload.team = input.team.slice(0, 64);
150
650
  if (input.costUsd !== undefined)
151
651
  payload.cost_usd = input.costUsd;
152
652
  // Only send a non-default tier: a synchronous call omits it (the server
@@ -171,20 +671,57 @@ export class MarginMeter {
171
671
  // The agent-tree fields ride only when a call belongs to one, so a
172
672
  // single-agent call's wire is unchanged and an older server that predates them
173
673
  // is unaffected. They carry structure, never money — no cost branch reads them.
174
- if (input.spanId !== undefined)
175
- payload.span_id = input.spanId;
176
- if (input.parentSpanId !== undefined)
177
- payload.parent_span_id = input.parentSpanId;
178
- if (input.orchestrationPattern !== undefined) {
179
- payload.orchestration_pattern = input.orchestrationPattern;
674
+ if (spanId !== undefined)
675
+ payload.span_id = spanId;
676
+ if (parentSpanId !== undefined)
677
+ payload.parent_span_id = parentSpanId;
678
+ if (orchestrationPattern !== undefined) {
679
+ payload.orchestration_pattern = orchestrationPattern;
180
680
  }
181
- if (input.branchWidth !== undefined)
182
- payload.branch_width = input.branchWidth;
183
- if (input.stoppingRule !== undefined)
184
- payload.stopping_rule = input.stoppingRule;
681
+ if (branchWidth !== undefined)
682
+ payload.branch_width = branchWidth;
683
+ if (stoppingRule !== undefined)
684
+ payload.stopping_rule = stoppingRule;
185
685
  // The delivery path, sent only when it was actually determined.
186
686
  if (input.substrate !== undefined)
187
687
  payload.substrate = input.substrate;
688
+ // The unit of work this call served, sent only when the caller named it.
689
+ if (input.taskKey !== undefined)
690
+ payload.task_key = input.taskKey;
691
+ if (taskClass !== undefined)
692
+ payload.task_class = taskClass;
693
+ // WHICH AGENT SCAFFOLD drove the call (MAR-371). Clamped to the server's
694
+ // String(64) so an over-long harness costs the tail of the label, never the row
695
+ // (ingest._require_str raises on over-long, which would 422 the whole call).
696
+ if (input.harness !== undefined)
697
+ payload.harness = input.harness.slice(0, 64);
698
+ // WHY the generation ended, straight off the provider — a signal, never a
699
+ // billing verdict (MAR-209). Rides only when the provider reported one; absent
700
+ // it reads UNKNOWN server-side rather than a fabricated value. Never priced.
701
+ if (input.finishReason !== undefined)
702
+ payload.finish_reason = input.finishReason;
703
+ // MAR-2013: hashes and labels read off the call (see capture.ts). `verdict` is checked here so
704
+ // a sentence throws at the call site instead of being refused by ingest after the fact.
705
+ const verdict = checkVerdict(input.verdict);
706
+ const retryKind = input.retryKind ??
707
+ (input.isSimulated
708
+ ? undefined
709
+ : RETRIES.observe(input.workflowId, operation, input.promptHash, input.status ?? "ok", input.finishReason));
710
+ if (retryKind !== undefined) {
711
+ payload.retry_kind = retryKind;
712
+ payload.is_retry = true;
713
+ }
714
+ if (input.promptHash !== undefined)
715
+ payload.prompt_hash = input.promptHash;
716
+ if (input.prefixHash !== undefined) {
717
+ payload.prefix_hash = input.prefixHash;
718
+ if (input.prefixChars !== undefined)
719
+ payload.prefix_chars = input.prefixChars;
720
+ }
721
+ if (input.servingHost !== undefined)
722
+ payload.serving_host = input.servingHost;
723
+ if (verdict !== undefined)
724
+ payload.output_class = verdict;
188
725
  return await this.emit(CALLS_PATH, payload);
189
726
  }
190
727
  /** Emit one outcome (a unit of productivity). `qualityMethod` records HOW the
@@ -200,8 +737,29 @@ export class MarginMeter {
200
737
  payload.quality_score = input.qualityScore;
201
738
  if (input.qualityMethod !== undefined)
202
739
  payload.quality_method = input.qualityMethod;
740
+ if (input.graderKind !== undefined)
741
+ payload.grader_kind = input.graderKind;
742
+ if (input.judgeModelId !== undefined)
743
+ payload.judge_model_id = input.judgeModelId;
744
+ if (input.graderConfidence !== undefined)
745
+ payload.grader_confidence = input.graderConfidence;
203
746
  if (input.link !== undefined)
204
747
  payload.link = input.link;
748
+ // Rides only when a value was actually supplied — absent is the first-class
749
+ // normal case (cost-per-outcome), never a defaulted 0 the customer did not send.
750
+ if (input.valueUsd !== undefined)
751
+ payload.value_usd = input.valueUsd;
752
+ // WHERE this outcome was produced (MAR-1562). Truncated to the server column
753
+ // width exactly as `recordCall` does: `ingest` rejects an over-long value with a
754
+ // 422, so sending 33+ chars would DROP the metered outcome rather than mislabel
755
+ // it — a silently smaller denominator, which is the flattering direction.
756
+ if (input.environment !== undefined)
757
+ payload.environment = input.environment.slice(0, 32);
758
+ // WHICH JOB produced this outcome (MAR-1912). Truncated to the server column width
759
+ // for the same reason as `environment` above: `ingest` 422s an over-long value, and
760
+ // a dropped outcome is a silently smaller denominator.
761
+ if (input.operation !== undefined)
762
+ payload.operation = input.operation.slice(0, 64);
205
763
  return await this.emit(OUTCOMES_PATH, payload);
206
764
  }
207
765
  /**
@@ -263,6 +821,7 @@ export class MarginMeter {
263
821
  cacheCreationTokens: usage.cacheCreationTokens,
264
822
  cacheCreation1hTokens: usage.cacheCreation1hTokens,
265
823
  reasoningTokens: usage.reasoningTokens,
824
+ costUsd: usage.billedCostUsd,
266
825
  latencyMs: input.latencyMs,
267
826
  status: input.status ?? "ok",
268
827
  isRetry: input.isRetry,
@@ -271,6 +830,15 @@ export class MarginMeter {
271
830
  operation: input.operation,
272
831
  isSimulated: input.isSimulated,
273
832
  substrate: input.substrate,
833
+ taskKey: input.taskKey,
834
+ // The provider's stop/finish reason, read verbatim off the response and
835
+ // forwarded as finish_reason (MAR-209). A signal, never a billing verdict.
836
+ finishReason: usage.stopReason,
837
+ promptHash: input.promptHash,
838
+ prefixHash: input.prefixHash,
839
+ prefixChars: input.prefixChars,
840
+ servingHost: servingHost(usage.servingHost, usage.provider, input.substrate),
841
+ verdict: input.verdict,
274
842
  });
275
843
  }
276
844
  /**
@@ -300,9 +868,24 @@ export class MarginMeter {
300
868
  /* a resolver hiccup never breaks the wrapped call */
301
869
  }
302
870
  }
871
+ // The unit of work, resolved off the host's ambient context per call — never a
872
+ // constant, which would collapse a run into one task class.
873
+ let callTaskKey;
874
+ if (opts.resolveTaskKey) {
875
+ try {
876
+ callTaskKey = opts.resolveTaskKey();
877
+ }
878
+ catch {
879
+ /* a resolver hiccup reads as UNKNOWN, never breaks the wrapped call */
880
+ }
881
+ }
303
882
  // Ask OpenAI to include usage on a streamed call so the final chunk carries
304
883
  // the count the accumulator needs (only when this is an OpenAI chat patch).
305
884
  maybeInjectIncludeUsage(args, opts);
885
+ const req = requestFields(args[0]);
886
+ // `margin_verdict` rides in the request params and is taken off BEFORE the real call, which
887
+ // would reject an unknown field. A bad label throws here, before anything is sent.
888
+ const verdict = takeVerdict(args);
306
889
  const start = now();
307
890
  let response;
308
891
  try {
@@ -322,6 +905,9 @@ export class MarginMeter {
322
905
  operation: opts.operation,
323
906
  isSimulated: opts.isSimulated,
324
907
  substrate: opts.substrate,
908
+ taskKey: callTaskKey,
909
+ ...req,
910
+ verdict,
325
911
  });
326
912
  }
327
913
  catch {
@@ -333,7 +919,19 @@ export class MarginMeter {
333
919
  // response. Reading it here would throw and drop the call; instead hand back
334
920
  // a transparent proxy that meters ONCE as the host consumes the stream.
335
921
  if (isAsyncIterable(response)) {
336
- return meteredStream(response, meter, opts, callModel, start);
922
+ return meteredStream(response, meter, opts, callModel, callTaskKey, start, { ...req, verdict });
923
+ }
924
+ // Bedrock's streaming commands resolve to a plain object whose event stream rides
925
+ // under `.stream` (ConverseStream) or `.body` (InvokeModelWithResponseStream), with
926
+ // the usage in its final event. Swap that field for the metered proxy (MAR-1518).
927
+ const nestedKey = opts.provider === "bedrock" ? bedrockStreamKey(response) : undefined;
928
+ if (nestedKey !== undefined) {
929
+ const r = response;
930
+ r[nestedKey] = meteredStream(r[nestedKey], meter, opts, callModel, callTaskKey, start, {
931
+ ...req,
932
+ verdict,
933
+ });
934
+ return response;
337
935
  }
338
936
  const latencyMs = Math.round(now() - start);
339
937
  try {
@@ -348,6 +946,9 @@ export class MarginMeter {
348
946
  operation: opts.operation,
349
947
  isSimulated: opts.isSimulated,
350
948
  substrate: opts.substrate,
949
+ taskKey: callTaskKey,
950
+ ...req,
951
+ verdict,
351
952
  });
352
953
  }
353
954
  catch {
@@ -357,6 +958,23 @@ export class MarginMeter {
357
958
  };
358
959
  }
359
960
  }
961
+ /** Take `margin_verdict` off the first argument (a fresh copy, never the caller's object). */
962
+ function takeVerdict(args) {
963
+ const p = args[0];
964
+ if (p == null || typeof p !== "object" || !("margin_verdict" in p))
965
+ return undefined;
966
+ const { margin_verdict, ...rest } = p;
967
+ args[0] = rest;
968
+ // A bad label is dropped with a warning and the call still goes out: capture never breaks the
969
+ // host call. (recordCall's own verdict still throws; that is Margin's API, not the host call.)
970
+ try {
971
+ return checkVerdict(margin_verdict);
972
+ }
973
+ catch (err) {
974
+ console.warn(`margin_verdict dropped: ${err.message}`);
975
+ return undefined;
976
+ }
977
+ }
360
978
  /** True when `x` is an async-iterable (a provider stream), never a plain response
361
979
  * object, string, or array. The clean discriminator between a streamed call and
362
980
  * a normal one after the wrapped fn resolves. */
@@ -365,6 +983,19 @@ function isAsyncIterable(x) {
365
983
  (typeof x === "object" || typeof x === "function") &&
366
984
  typeof x[Symbol.asyncIterator] === "function");
367
985
  }
986
+ /** The field a Bedrock streaming response carries its event stream under, if any.
987
+ * A non-streaming `InvokeModel` body is a Uint8Array, not async-iterable, so it is
988
+ * never mistaken for one. */
989
+ function bedrockStreamKey(response) {
990
+ if (response == null || typeof response !== "object")
991
+ return undefined;
992
+ const r = response;
993
+ if (isAsyncIterable(r.stream))
994
+ return "stream";
995
+ if (isAsyncIterable(r.body))
996
+ return "body";
997
+ return undefined;
998
+ }
368
999
  /** When this wrapper is an OpenAI chat patch (`streamIncludeUsage`) AND the call
369
1000
  * requests `stream: true`, add `stream_options.include_usage` so OpenAI emits its
370
1001
  * usage on the final chunk. Clones the params object (never mutates the caller's),
@@ -402,7 +1033,7 @@ function maybeInjectIncludeUsage(args, opts) {
402
1033
  * delegates to the wrapped stream, so `for await (const c of stream)`, `.tee()`,
403
1034
  * and the rest work unchanged. Fail-safe: a metering hiccup never breaks iteration.
404
1035
  */
405
- function meteredStream(stream, meter, opts, callModel, start) {
1036
+ function meteredStream(stream, meter, opts, callModel, callTaskKey, start, extra = {}) {
406
1037
  const acc = new StreamUsageAccumulator(opts.provider, callModel);
407
1038
  let finished = false;
408
1039
  const finish = async () => {
@@ -426,6 +1057,7 @@ function meteredStream(stream, meter, opts, callModel, start) {
426
1057
  cacheCreationTokens: usage.cacheCreationTokens,
427
1058
  cacheCreation1hTokens: usage.cacheCreation1hTokens,
428
1059
  reasoningTokens: usage.reasoningTokens,
1060
+ costUsd: usage.billedCostUsd,
429
1061
  latencyMs,
430
1062
  status: "ok",
431
1063
  isRetry: opts.isRetry,
@@ -433,6 +1065,13 @@ function meteredStream(stream, meter, opts, callModel, start) {
433
1065
  operation: opts.operation,
434
1066
  isSimulated: opts.isSimulated,
435
1067
  substrate: opts.substrate,
1068
+ taskKey: callTaskKey,
1069
+ // A streaming Anthropic message_delta carries stop_reason too (the
1070
+ // accumulator merges it) — forward it so a streamed Fable fallback records
1071
+ // its reason like a non-streamed one.
1072
+ finishReason: usage.stopReason,
1073
+ ...extra,
1074
+ servingHost: servingHost(usage.servingHost, usage.provider ?? opts.provider, opts.substrate),
436
1075
  });
437
1076
  }
438
1077
  catch {
@@ -515,4 +1154,39 @@ async function extractDetail(resp) {
515
1154
  function now() {
516
1155
  return typeof performance !== "undefined" ? performance.now() : Date.now();
517
1156
  }
1157
+ function sleep(ms) {
1158
+ return new Promise((resolve) => {
1159
+ const timer = setTimeout(resolve, ms);
1160
+ // Unref'd for the same reason the flush timer is: a backoff must never be the
1161
+ // thing keeping a short-lived script alive.
1162
+ if (typeof timer.unref === "function") {
1163
+ timer.unref();
1164
+ }
1165
+ });
1166
+ }
1167
+ /** The server's own `Retry-After`, in ms, when it sent one.
1168
+ *
1169
+ * The ingest API computes the real wait from its rolling window rather than
1170
+ * sending a round number, so honouring it beats any backoff we could calculate
1171
+ * blind — a client that guesses either hammers or overshoots, and overshooting
1172
+ * drops exactly the calls the SDK was installed to record. */
1173
+ export function retryAfterMs_(resp) {
1174
+ const headers = resp.headers;
1175
+ const raw = headers?.get?.("Retry-After") ?? undefined;
1176
+ if (raw === undefined || raw === null)
1177
+ return undefined;
1178
+ const seconds = Number(raw);
1179
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : undefined;
1180
+ }
1181
+ /** FULL jitter — `random(0, base * 2**attempt)`, capped, or the server's wait.
1182
+ *
1183
+ * Jittered rather than plain exponential because every buffered client backs off
1184
+ * on the same schedule: un-jittered retries re-synchronise into a thundering herd
1185
+ * aimed at the endpoint that just failed, which is how a brief blip becomes an
1186
+ * outage. */
1187
+ export function backoffMs(attempt, retryAfter) {
1188
+ if (retryAfter !== undefined)
1189
+ return Math.min(RETRY_CAP_MS, retryAfter);
1190
+ return Math.random() * Math.min(RETRY_CAP_MS, RETRY_BASE_MS * 2 ** attempt);
1191
+ }
518
1192
  //# sourceMappingURL=index.js.map