@spendgraph/sdk 0.1.0 → 0.2.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.
Files changed (62) hide show
  1. package/dist/client.d.ts +37 -0
  2. package/dist/client.js +48 -0
  3. package/dist/core/client/client.d.ts +30 -0
  4. package/dist/core/client/client.js +143 -0
  5. package/dist/core/client/errors.d.ts +25 -0
  6. package/dist/core/client/errors.js +31 -0
  7. package/dist/core/client/index.d.ts +2 -0
  8. package/dist/core/client/index.js +2 -0
  9. package/dist/core/types.d.ts +28 -0
  10. package/dist/core/types.js +1 -0
  11. package/dist/index.d.ts +9 -124
  12. package/dist/index.js +5 -421
  13. package/dist/langchain.js +8 -2
  14. package/dist/resources/alerts.d.ts +18 -0
  15. package/dist/resources/alerts.js +10 -0
  16. package/dist/resources/credentials.d.ts +22 -0
  17. package/dist/resources/credentials.js +22 -0
  18. package/dist/resources/events.d.ts +41 -0
  19. package/dist/resources/events.js +20 -0
  20. package/dist/resources/index.d.ts +21 -0
  21. package/dist/resources/index.js +12 -0
  22. package/dist/resources/ingest.d.ts +42 -0
  23. package/dist/resources/ingest.js +30 -0
  24. package/dist/resources/keys.d.ts +34 -0
  25. package/dist/resources/keys.js +20 -0
  26. package/dist/resources/playground.d.ts +7 -0
  27. package/dist/resources/playground.js +10 -0
  28. package/dist/resources/pricing.d.ts +49 -0
  29. package/dist/resources/pricing.js +42 -0
  30. package/dist/resources/projects.d.ts +54 -0
  31. package/dist/resources/projects.js +51 -0
  32. package/dist/resources/prompts-admin.d.ts +23 -0
  33. package/dist/resources/prompts-admin.js +26 -0
  34. package/dist/resources/prompts.d.ts +124 -0
  35. package/dist/resources/prompts.js +67 -0
  36. package/dist/resources/stats.d.ts +65 -0
  37. package/dist/resources/stats.js +29 -0
  38. package/dist/resources/tools.d.ts +66 -0
  39. package/dist/resources/tools.js +30 -0
  40. package/dist/rollout/index.d.ts +1 -0
  41. package/dist/rollout/index.js +1 -0
  42. package/dist/rollout/rollout.d.ts +75 -0
  43. package/dist/rollout/rollout.js +1 -0
  44. package/dist/schema/index.d.ts +3 -0
  45. package/dist/schema/index.js +2 -0
  46. package/dist/schema/serialize/index.d.ts +1 -0
  47. package/dist/schema/serialize/index.js +1 -0
  48. package/dist/schema/serialize/serialize.d.ts +12 -0
  49. package/dist/schema/serialize/serialize.js +42 -0
  50. package/dist/schema/types/index.d.ts +1 -0
  51. package/dist/schema/types/index.js +1 -0
  52. package/dist/schema/types/types.d.ts +58 -0
  53. package/dist/schema/types/types.js +1 -0
  54. package/dist/schema/validate/index.d.ts +1 -0
  55. package/dist/schema/validate/index.js +1 -0
  56. package/dist/schema/validate/validate.d.ts +28 -0
  57. package/dist/schema/validate/validate.js +108 -0
  58. package/dist/track/index.d.ts +2 -0
  59. package/dist/track/index.js +1 -0
  60. package/dist/track/track.d.ts +209 -0
  61. package/dist/track/track.js +513 -0
  62. package/package.json +3 -2
@@ -0,0 +1,513 @@
1
+ import { Client, SpendgraphError } from "../core/client/index.js";
2
+ /** How long a flush may block waiting out a rate limit before giving up. */
3
+ const MAX_RETRY_WAIT_MS = 2_000;
4
+ /**
5
+ * Last-chance flush when the process winds down, shared by every meter.
6
+ *
7
+ * `beforeExit` is the right hook — unlike `exit` it permits async work, so the
8
+ * final send can actually complete. But it used to be registered per instance,
9
+ * so an app holding more than ten meters tripped Node's
10
+ * MaxListenersExceededWarning and kept every one of them alive forever.
11
+ *
12
+ * One listener, and meters are held weakly so a discarded one can still be
13
+ * collected. Deliberately no SIGTERM handler: installing one removes Node's
14
+ * default signal behaviour from the host application, and a telemetry library
15
+ * has no business changing how its host shuts down.
16
+ */
17
+ const liveMeters = new Set();
18
+ let exitHookInstalled = false;
19
+ function registerForExitFlush(meter) {
20
+ if (typeof process === "undefined" || typeof process.on !== "function")
21
+ return;
22
+ // WeakRef is everywhere the SDK runs, but fall back rather than assume
23
+ const ref = typeof WeakRef === "function"
24
+ ? new WeakRef(meter)
25
+ : { deref: () => meter };
26
+ liveMeters.add(ref);
27
+ if (exitHookInstalled)
28
+ return;
29
+ exitHookInstalled = true;
30
+ process.on("beforeExit", () => {
31
+ for (const r of liveMeters) {
32
+ const m = r.deref();
33
+ if (m)
34
+ void m.flush();
35
+ else
36
+ liveMeters.delete(r); // collected; stop tracking it
37
+ }
38
+ });
39
+ }
40
+ /**
41
+ * Fire-and-forget token tracking. track() never throws and never blocks;
42
+ * events batch in memory and flush every 5s or 20 events. Network failures
43
+ * retry once, then drop with one console.warn — tracking can never break
44
+ * the host app.
45
+ */
46
+ export class SpendGraph {
47
+ opts;
48
+ queue = [];
49
+ timer = null;
50
+ warned = false;
51
+ /** Events lost in the current run of failures, reset by a successful flush. */
52
+ dropped = 0;
53
+ /** Unpriced model ids already reported, so each is named at most once. */
54
+ unpricedSeen = new Set();
55
+ interval;
56
+ maxBatch;
57
+ /** When the pending flush was scheduled, for the suspend check in track(). */
58
+ timerAt = 0;
59
+ suspendWarned = false;
60
+ noKeyWarned = false;
61
+ streamUsageWarned = false;
62
+ /** The flush currently on the wire, so a later flush() can wait for it. */
63
+ inFlight = null;
64
+ constructor(opts) {
65
+ this.opts = opts;
66
+ this.interval = opts.flushIntervalMs ?? 5000;
67
+ this.maxBatch = opts.maxBatch ?? 20;
68
+ registerForExitFlush(this);
69
+ }
70
+ /**
71
+ * The package's client, given no retries of its own.
72
+ *
73
+ * The policy in `send` stays this class's: a flush is awaited inside
74
+ * serverless handlers, where blocking out a minute-long rate-limit window is
75
+ * worse than losing the batch, and the client's backoff would do exactly that.
76
+ */
77
+ clientFor(apiKey) {
78
+ return new Client({ apiKey, baseUrl: this.opts.baseUrl, attempts: 1 });
79
+ }
80
+ /** Record one LLM call. Synchronous, returns void, never throws. */
81
+ track(event) {
82
+ try {
83
+ if (!this.opts.apiKey) {
84
+ this.warnNoKey();
85
+ return;
86
+ }
87
+ this.detectSuspendedRuntime();
88
+ this.queue.push(event);
89
+ if (this.queue.length >= this.maxBatch) {
90
+ void this.flush();
91
+ }
92
+ else if (!this.timer) {
93
+ this.timer = setTimeout(() => void this.flush(), this.interval);
94
+ this.timerAt = Date.now();
95
+ // don't hold the process open just to flush telemetry
96
+ this.timer.unref?.();
97
+ }
98
+ }
99
+ catch {
100
+ /* fail-open */
101
+ }
102
+ }
103
+ /**
104
+ * Says once that tracking is off.
105
+ *
106
+ * Running without a key stays a no-op on purpose — it keeps spendgraph out of
107
+ * tests and local runs without branching at every call site. But an unset
108
+ * SPENDGRAPH_API_KEY is indistinguishable from that choice, and it is the
109
+ * likeliest reason a fresh integration records nothing at all: the code is
110
+ * wired up correctly, the dashboard is empty, and nothing anywhere says why.
111
+ * Every other failure in this class warns once; this was the one that stayed
112
+ * quiet, which made it the hardest to find.
113
+ */
114
+ warnNoKey() {
115
+ if (this.noKeyWarned)
116
+ return;
117
+ this.noKeyWarned = true;
118
+ console.warn("spendgraph: no apiKey set, so track() is recording nothing. Pass apiKey " +
119
+ "(usually from SPENDGRAPH_API_KEY) to start tracking, or ignore this if " +
120
+ "tracking is meant to be off here.");
121
+ }
122
+ /**
123
+ * Notices that the runtime froze with events still buffered.
124
+ *
125
+ * Serverless platforms suspend a function once it returns rather than
126
+ * exiting it, so neither the flush timer nor `beforeExit` ever runs and the
127
+ * queue is lost without a sound — the failure Langfuse documents for Lambda
128
+ * and Vercel. There is no reliable flag for "am I serverless", but there is
129
+ * direct evidence: a pending timer whose deadline passed long ago did not
130
+ * fire, which only happens if the runtime stopped executing between calls.
131
+ *
132
+ * Seeing that, send the stragglers now (they survive into this invocation)
133
+ * and say once what the fix is. Correct callers await flush(), which clears
134
+ * the timer, so this never fires for them.
135
+ */
136
+ detectSuspendedRuntime() {
137
+ if (!this.timer)
138
+ return;
139
+ // 2x the interval, so ordinary event-loop lag is never mistaken for a freeze
140
+ if (Date.now() - this.timerAt <= this.interval * 2)
141
+ return;
142
+ if (!this.suspendWarned) {
143
+ this.suspendWarned = true;
144
+ console.warn("spendgraph: a scheduled flush never ran — this runtime suspends between " +
145
+ "invocations, so buffered events are lost. Await meter.flush() before " +
146
+ "your handler returns.");
147
+ }
148
+ void this.flush();
149
+ }
150
+ /**
151
+ * Send everything buffered now. Call at the end of serverless handlers.
152
+ *
153
+ * Serialised against a flush already running, because this method is not the
154
+ * only thing that starts one: track() flushes by itself the moment the batch
155
+ * is full, and so do the timer, the suspend check and the exit hook. Those
156
+ * empty the queue synchronously, so an awaited flush() used to find nothing
157
+ * left to send and hand back an already-resolved promise while the real
158
+ * batch was still on the wire — and the next thing a serverless handler does
159
+ * after awaiting is return, freezing the runtime and losing exactly the
160
+ * events the await was there to save.
161
+ *
162
+ * The drain still starts synchronously — `drain()` runs as far as its first
163
+ * await before returning a promise, so the queue is taken during this call
164
+ * and not a microtask later. That ordering is load-bearing: the suspend
165
+ * check and the exit hook both fire a flush and then expect the buffer to be
166
+ * gone, and deferring it broke them.
167
+ */
168
+ async flush() {
169
+ const prev = this.inFlight;
170
+ const mine = this.drain();
171
+ // Both, not one after the other: they carry disjoint events, since this
172
+ // call already took everything the earlier one left behind.
173
+ const run = Promise.allSettled([prev, mine]).then(() => undefined);
174
+ this.inFlight = run;
175
+ try {
176
+ await run;
177
+ }
178
+ finally {
179
+ // Only the newest link clears the slot. An older one settling last must
180
+ // not strand a flush that has already queued up behind it.
181
+ if (this.inFlight === run)
182
+ this.inFlight = null;
183
+ }
184
+ }
185
+ /** One pass: stop the timer, take the queue, put it on the wire. */
186
+ async drain() {
187
+ if (this.timer) {
188
+ clearTimeout(this.timer);
189
+ this.timer = null;
190
+ }
191
+ const apiKey = this.opts.apiKey;
192
+ if (this.queue.length === 0 || !apiKey)
193
+ return;
194
+ const events = this.queue.splice(0, this.queue.length);
195
+ for (let i = 0; i < events.length; i += 100) {
196
+ await this.send(events.slice(i, i + 100), apiKey);
197
+ }
198
+ }
199
+ /**
200
+ * Wrap an Anthropic or OpenAI client. Use the wrapped client exactly as
201
+ * before — token usage is read off each response and tracked automatically.
202
+ *
203
+ * Streaming is covered too:
204
+ * - helper streams (`anthropic.messages.stream()`,
205
+ * `openai.beta.chat.completions.stream()`) are tracked via their
206
+ * final-message promise — the stream you get back is untouched;
207
+ * - raw streams (`create({ stream: true })`) are tee'd: you receive one
208
+ * branch, usage is accumulated off the other. For OpenAI raw streams,
209
+ * pass `stream_options: { include_usage: true }` or there is no usage
210
+ * to read and the call goes untracked.
211
+ */
212
+ wrap(client) {
213
+ return this.proxy(client);
214
+ }
215
+ /**
216
+ * The recursive wrapper behind wrap().
217
+ *
218
+ * Two things here are deliberate and were both wrong before.
219
+ *
220
+ * `Reflect.get` is called without a receiver, so a getter runs with the real
221
+ * client as `this`. Passing the proxy — the obvious reading of the Proxy
222
+ * docs — makes any getter that touches a `#private` field throw
223
+ * "Cannot read private member", and the provider SDKs this is built to wrap
224
+ * use private fields. That turned a tracking wrapper into a crash on a
225
+ * client that works perfectly well unwrapped, in an SDK whose whole contract
226
+ * is to fail open.
227
+ *
228
+ * And each wrapper is kept, so `client.messages` and `client.messages.create`
229
+ * are the same object and the same function every time they are read.
230
+ * Rebuilding them per access allocated a proxy and a closure on every call
231
+ * and quietly broke any caller that holds onto a method or compares
232
+ * identity. The cache is per proxy and keyed by property, so a method always
233
+ * applies to the object it was read from, and it re-wraps if the underlying
234
+ * value is ever replaced.
235
+ */
236
+ proxy(target) {
237
+ const cache = new Map();
238
+ return new Proxy(target, {
239
+ get: (obj, prop) => {
240
+ const value = Reflect.get(obj, prop);
241
+ if (value === null || (typeof value !== "function" && typeof value !== "object")) {
242
+ return value;
243
+ }
244
+ const hit = cache.get(prop);
245
+ if (hit && hit.from === value)
246
+ return hit.out;
247
+ const out = typeof value === "function"
248
+ ? (...args) => this.observeResult(value.apply(obj, args), args)
249
+ : this.proxy(value);
250
+ cache.set(prop, { from: value, out });
251
+ return out;
252
+ },
253
+ });
254
+ }
255
+ observeResult(result, args) {
256
+ try {
257
+ // Helper streams return synchronously and expose a final-message
258
+ // promise (Anthropic MessageStream / OpenAI ChatCompletionStream).
259
+ // Awaiting it does not consume the caller's iterator.
260
+ const helper = result;
261
+ const final = typeof helper?.finalMessage === "function"
262
+ ? helper.finalMessage()
263
+ : typeof helper?.finalChatCompletion === "function"
264
+ ? helper.finalChatCompletion()
265
+ : null;
266
+ if (final instanceof Promise) {
267
+ final.then((m) => this.trackFromResponse(m), () => { });
268
+ return result;
269
+ }
270
+ if (result instanceof Promise) {
271
+ // Raw streaming (create({stream: true})) resolves to an SSE stream;
272
+ // swap in a tee'd branch so we can read usage without consuming the
273
+ // caller's. Only then do we replace the promise — non-streaming
274
+ // calls keep the SDK's original promise (withResponse() etc.).
275
+ const wantsStream = !!args[0]?.stream;
276
+ if (wantsStream) {
277
+ return result.then((v) => this.interceptStream(v));
278
+ }
279
+ // provider errors are the caller's to handle
280
+ result.then((v) => this.trackFromResponse(v), () => { });
281
+ }
282
+ }
283
+ catch {
284
+ /* fail-open */
285
+ }
286
+ return result;
287
+ }
288
+ interceptStream(v) {
289
+ try {
290
+ const s = v;
291
+ if (s && typeof s.tee === "function" && typeof s[Symbol.asyncIterator] === "function") {
292
+ const [mine, theirs] = s.tee();
293
+ void this.consumeStream(mine);
294
+ return theirs;
295
+ }
296
+ this.trackFromResponse(v);
297
+ }
298
+ catch {
299
+ /* fail-open */
300
+ }
301
+ return v;
302
+ }
303
+ /** Accumulate usage off a tee'd SSE branch (Anthropic events / OpenAI chunks). */
304
+ async consumeStream(iter) {
305
+ try {
306
+ let model;
307
+ let inputTokens;
308
+ let outputTokens;
309
+ let cacheReadTokens = 0;
310
+ let cacheWriteTokens = 0;
311
+ let citationTokens;
312
+ let reasoningTokens;
313
+ let sawChunk = false;
314
+ for await (const raw of iter) {
315
+ sawChunk = true;
316
+ const ev = raw;
317
+ if (ev?.type === "message_start" && ev.message) {
318
+ // Anthropic: input + cache usage arrive up front
319
+ model = ev.message.model ?? model;
320
+ const u = ev.message.usage ?? {};
321
+ inputTokens = u.input_tokens ?? inputTokens;
322
+ cacheReadTokens = u.cache_read_input_tokens ?? 0;
323
+ cacheWriteTokens = u.cache_creation_input_tokens ?? 0;
324
+ }
325
+ else if (ev?.type === "message_delta" && ev.usage) {
326
+ // Anthropic: cumulative output count
327
+ outputTokens = ev.usage.output_tokens ?? outputTokens;
328
+ }
329
+ else if (ev?.object === "chat.completion.chunk") {
330
+ // OpenAI: usage only on the final chunk, and only with
331
+ // stream_options: { include_usage: true }
332
+ model = ev.model ?? model;
333
+ if (ev.usage) {
334
+ const u = ev.usage;
335
+ const cached = u.prompt_tokens_details?.cached_tokens ?? 0;
336
+ inputTokens = Math.max(0, (u.prompt_tokens ?? 0) - cached);
337
+ cacheReadTokens = cached;
338
+ outputTokens = u.completion_tokens;
339
+ citationTokens = u.citation_tokens;
340
+ reasoningTokens = u.reasoning_tokens;
341
+ }
342
+ }
343
+ }
344
+ if (model && typeof inputTokens === "number" && typeof outputTokens === "number") {
345
+ this.track({
346
+ model,
347
+ inputTokens,
348
+ outputTokens,
349
+ cacheReadTokens,
350
+ cacheWriteTokens,
351
+ citationTokens,
352
+ reasoningTokens,
353
+ });
354
+ }
355
+ else if (sawChunk) {
356
+ this.warnStreamNoUsage();
357
+ }
358
+ }
359
+ catch {
360
+ /* fail-open — never disturb the caller's branch */
361
+ }
362
+ }
363
+ /**
364
+ * Says once that a streamed call went untracked.
365
+ *
366
+ * A raw OpenAI stream carries no usage unless the caller asks for it, so the
367
+ * accumulator above finishes with nothing to report and simply returns —
368
+ * correct, and completely silent. That is the same hole warnNoKey was added
369
+ * to close: the integration looks right, the dashboard stays empty for those
370
+ * calls, and nothing anywhere connects the two. Anthropic streams always
371
+ * carry usage, so in practice this names the one option that fixes it.
372
+ *
373
+ * Guarded on having seen at least one chunk, so an empty or aborted stream —
374
+ * where there was never anything to read — stays quiet.
375
+ */
376
+ warnStreamNoUsage() {
377
+ if (this.streamUsageWarned)
378
+ return;
379
+ this.streamUsageWarned = true;
380
+ console.warn("spendgraph: a streamed response carried no token usage, so that call " +
381
+ "was not tracked. For OpenAI raw streams pass " +
382
+ "stream_options: { include_usage: true }. Further untracked streams " +
383
+ "are not logged.");
384
+ }
385
+ trackFromResponse(res) {
386
+ try {
387
+ if (!res || typeof res !== "object")
388
+ return;
389
+ const r = res;
390
+ if (!r.usage || !r.model)
391
+ return;
392
+ const u = r.usage;
393
+ if (typeof u.input_tokens === "number" && typeof u.output_tokens === "number") {
394
+ // Anthropic: cache tokens are separate fields already
395
+ this.track({
396
+ model: r.model,
397
+ inputTokens: u.input_tokens,
398
+ outputTokens: u.output_tokens,
399
+ cacheReadTokens: u.cache_read_input_tokens ?? 0,
400
+ cacheWriteTokens: u.cache_creation_input_tokens ?? 0,
401
+ });
402
+ return;
403
+ }
404
+ if (typeof u.prompt_tokens === "number" && typeof u.completion_tokens === "number") {
405
+ // OpenAI: cached tokens are included in prompt_tokens — split them out
406
+ const cached = u.prompt_tokens_details?.cached_tokens ?? 0;
407
+ this.track({
408
+ model: r.model,
409
+ inputTokens: Math.max(0, u.prompt_tokens - cached),
410
+ outputTokens: u.completion_tokens,
411
+ cacheReadTokens: cached,
412
+ citationTokens: u.citation_tokens,
413
+ reasoningTokens: u.reasoning_tokens,
414
+ });
415
+ }
416
+ }
417
+ catch {
418
+ /* fail-open */
419
+ }
420
+ }
421
+ async send(events, apiKey, attempt = 0) {
422
+ let body;
423
+ try {
424
+ body = await this.clientFor(apiKey).post("/api/v1/ingest", { events });
425
+ }
426
+ catch (err) {
427
+ return this.onSendFailed(err, events, apiKey, attempt);
428
+ }
429
+ // A run of failures is over; a later one is news again rather than more
430
+ // of the same, so the warning is allowed to fire once more.
431
+ this.warned = false;
432
+ this.dropped = 0;
433
+ this.reportUnpriced(body);
434
+ }
435
+ /**
436
+ * The drop policy, which is this class's rather than the client's.
437
+ *
438
+ * 429 is the one failure the server tells you how to fix: it sends
439
+ * retry-after, and the window is at most a minute wide, so a batch that
440
+ * arrived at the end of one is deliverable a second later. Dropping it
441
+ * immediately threw that away and reported the least useful thing it could —
442
+ * "ingest returned 429" — for the one status with a documented remedy. The
443
+ * wait is capped because flush() is awaited inside serverless handlers, where
444
+ * blocking for a minute is worse than losing the batch.
445
+ */
446
+ async onSendFailed(err, events, apiKey, attempt) {
447
+ const failure = err instanceof SpendgraphError ? err : null;
448
+ if (!failure || failure.status === 0) {
449
+ if (attempt === 0)
450
+ return this.send(events, apiKey, 1);
451
+ this.reportDropped(events.length, `unreachable (${String(err)})`);
452
+ return;
453
+ }
454
+ if (failure.status >= 500 && attempt === 0) {
455
+ return this.send(events, apiKey, 1);
456
+ }
457
+ if (failure.status === 429 && attempt === 0) {
458
+ const waitMs = failure.retryAfterMs;
459
+ if (waitMs !== null && waitMs <= MAX_RETRY_WAIT_MS) {
460
+ await new Promise((r) => setTimeout(r, waitMs));
461
+ return this.send(events, apiKey, 1);
462
+ }
463
+ this.reportDropped(events.length, waitMs === null
464
+ ? "rate limited by ingest"
465
+ : `rate limited by ingest, clear in ${Math.ceil(waitMs / 1000)}s`);
466
+ return;
467
+ }
468
+ this.reportDropped(events.length, `ingest returned ${failure.status}`);
469
+ }
470
+ /**
471
+ * Surfaces model ids the server could not price, once each.
472
+ *
473
+ * These are accepted and stored, so nothing here is an error — but they cost
474
+ * $0, and a dashboard reading $0 is indistinguishable from one reading
475
+ * "nothing happened". Naming the id in the integrator's own console is the
476
+ * cheapest possible moment to catch a typo or an unmapped model, and the
477
+ * per-id guard keeps a steady stream of the same unknown model from becoming
478
+ * log noise.
479
+ */
480
+ reportUnpriced(body) {
481
+ {
482
+ const parsed = body;
483
+ if (!Array.isArray(parsed?.unpricedModels))
484
+ return;
485
+ const fresh = parsed.unpricedModels
486
+ .filter((m) => typeof m === "string")
487
+ .filter((m) => !this.unpricedSeen.has(m));
488
+ if (fresh.length === 0)
489
+ return;
490
+ for (const m of fresh)
491
+ this.unpricedSeen.add(m);
492
+ console.warn(`spendgraph: no price for ${fresh.map((m) => `"${m}"`).join(", ")}. ` +
493
+ `These events are recorded but cost $0 until the model is in the catalog.`);
494
+ }
495
+ }
496
+ /**
497
+ * Warns once per outage, with a running count of what was lost.
498
+ *
499
+ * Warning on every flush would spam a hot loop, but warning exactly once per
500
+ * process — the previous behaviour — hid a server-side bug that failed every
501
+ * full batch: one line early in a long-lived process, then silence, while the
502
+ * dashboard quietly undercounted. The count is what makes the silence legible
503
+ * when someone does go looking.
504
+ */
505
+ reportDropped(count, reason) {
506
+ this.dropped += count;
507
+ if (this.warned)
508
+ return;
509
+ this.warned = true;
510
+ console.warn(`spendgraph: ${reason}; dropped ${this.dropped} event(s). ` +
511
+ `Further drops are counted but not logged until a flush succeeds.`);
512
+ }
513
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spendgraph/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Track LLM input/output tokens and cost. Three functions, zero dependencies, fail-open.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -43,7 +43,8 @@
43
43
  "README.md"
44
44
  ],
45
45
  "scripts": {
46
- "build": "tsc -p tsconfig.json"
46
+ "build": "tsc -p tsconfig.json",
47
+ "test": "vitest run"
47
48
  },
48
49
  "devDependencies": {
49
50
  "typescript": "^5"