@bitfab/sdk 0.28.11 → 0.29.1

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.cjs CHANGED
@@ -282,6 +282,22 @@ var init_asyncStorage = __esm({
282
282
  }
283
283
  });
284
284
 
285
+ // src/mockOverride.ts
286
+ function resolveMockValue(value, ctx) {
287
+ return typeof value === "function" ? value(ctx) : value;
288
+ }
289
+ function normalizeMockOverrides(mockOverride) {
290
+ if (mockOverride === void 0) {
291
+ return [];
292
+ }
293
+ return Array.isArray(mockOverride) ? mockOverride : [mockOverride];
294
+ }
295
+ var init_mockOverride = __esm({
296
+ "src/mockOverride.ts"() {
297
+ "use strict";
298
+ }
299
+ });
300
+
285
301
  // src/replayContext.ts
286
302
  function getReplayContext() {
287
303
  return replayContextStorage?.getStore() ?? null;
@@ -357,6 +373,7 @@ function buildMockTree(rootNode) {
357
373
  counters.set(counterKey, index + 1);
358
374
  spans.set(`${counterKey}:${index}`, {
359
375
  sourceSpanId: node.sourceSpanId,
376
+ externalSpanId: node.externalSpanId,
360
377
  output: node.output,
361
378
  outputMeta: node.outputMeta
362
379
  });
@@ -370,57 +387,79 @@ function buildMockTree(rootNode) {
370
387
  }
371
388
  return { spans };
372
389
  }
373
- async function processItem(httpClient, serverItem, fn, testRunId, mockStrategy, environment, adaptInputs) {
390
+ async function processItem(httpClient, serverItem, fn, testRunId, mockStrategy, resolvedOverrides, replayedTraceId, environment, adaptInputs) {
374
391
  const lease = environment ? serverItem.dbBranchLease : void 0;
375
392
  let inputs = [];
376
393
  let originalOutput;
377
394
  let result;
378
395
  let error = null;
379
- const replayedTraceId = randomUuid();
380
396
  const pendingPersistence = [];
397
+ const originalTraceId = serverItem.originalTraceId ?? serverItem.sourceTraceId;
398
+ const originalSpanId = serverItem.originalSpanId ?? serverItem.sourceSpanId;
381
399
  try {
382
- const span = await httpClient.getExternalSpan(serverItem.externalSpanId);
400
+ const span = await httpClient.getExternalSpan(originalSpanId);
383
401
  const spanData = span.rawData?.span_data ?? {};
384
402
  inputs = deserializeInputs(spanData);
385
403
  originalOutput = deserializeOutput(spanData);
386
404
  if (adaptInputs) {
387
405
  inputs = adaptInputs(inputs, {
388
- traceId: serverItem.traceId,
389
- sourceSpanId: serverItem.externalSpanId
406
+ originalTraceId,
407
+ originalSpanId,
408
+ // Deprecated aliases for originalTraceId/originalSpanId.
409
+ sourceTraceId: originalTraceId,
410
+ sourceSpanId: originalSpanId
390
411
  });
391
412
  }
413
+ const hasOverrides = resolvedOverrides.length > 0;
414
+ const needTree = mockStrategy === "all" || mockStrategy === "marked" || hasOverrides;
415
+ const includeOutputs = mockStrategy === "all";
392
416
  let mockTree;
393
- if (mockStrategy === "all" || mockStrategy === "marked") {
417
+ if (needTree) {
394
418
  try {
395
- const treeResponse = await httpClient.getSpanTree(
396
- serverItem.externalSpanId
397
- );
419
+ const treeResponse = await httpClient.getSpanTree(originalSpanId, {
420
+ includeOutputs
421
+ });
398
422
  if (treeResponse.root) {
399
423
  mockTree = buildMockTree(treeResponse.root);
400
- } else if (mockStrategy === "all") {
424
+ } else if (mockStrategy === "all" || hasOverrides) {
401
425
  throw new BitfabError(
402
- `Replay mock strategy "all" requires a span tree root for source span ${serverItem.externalSpanId}.`
426
+ `Replay mock strategy "${mockStrategy}"${hasOverrides ? " with overrides" : ""} requires a span tree root for original span ${originalSpanId}.`
403
427
  );
404
428
  } else {
405
429
  mockTree = void 0;
406
430
  }
407
431
  } catch (e) {
408
- if (mockStrategy === "all") {
432
+ if (mockStrategy === "all" || hasOverrides) {
409
433
  throw e;
410
434
  }
411
435
  mockTree = void 0;
412
436
  }
413
437
  }
438
+ const outputCache = /* @__PURE__ */ new Map();
439
+ const fetchSpanOutput = mockTree && !includeOutputs ? (externalSpanId) => {
440
+ let pending = outputCache.get(externalSpanId);
441
+ if (!pending) {
442
+ pending = httpClient.getExternalSpan(externalSpanId).then(
443
+ (s) => deserializeOutput(
444
+ s.rawData?.span_data ?? {}
445
+ )
446
+ );
447
+ outputCache.set(externalSpanId, pending);
448
+ }
449
+ return pending;
450
+ } : void 0;
414
451
  const maybePromise = runWithReplayContext(
415
452
  {
416
453
  testRunId,
417
454
  traceId: replayedTraceId,
418
455
  inputSourceSpanId: span.id,
419
456
  inputSourceTraceId: span.externalTraceId,
420
- sourceBitfabTraceId: serverItem.traceId,
457
+ sourceBitfabTraceId: originalTraceId,
421
458
  mockTree,
422
459
  callCounters: mockTree ? /* @__PURE__ */ new Map() : void 0,
423
460
  mockStrategy,
461
+ mockOverrides: hasOverrides ? resolvedOverrides : void 0,
462
+ fetchSpanOutput,
424
463
  dbBranchLease: lease,
425
464
  pendingPersistence
426
465
  },
@@ -445,7 +484,15 @@ async function processItem(httpClient, serverItem, fn, testRunId, mockStrategy,
445
484
  }
446
485
  }
447
486
  return {
448
- traceId: replayedTraceId,
487
+ // Written in by replay() from the complete-replay response once the server
488
+ // has minted this replay trace's row. Null until then: the client-side
489
+ // correlation id (replayedTraceId) is never surfaced as the item's traceId.
490
+ traceId: null,
491
+ originalTraceId,
492
+ originalSpanId,
493
+ // Deprecated aliases for originalTraceId/originalSpanId.
494
+ sourceTraceId: originalTraceId,
495
+ sourceSpanId: originalSpanId,
449
496
  input: inputs,
450
497
  result,
451
498
  originalOutput,
@@ -477,7 +524,7 @@ async function mapWithConcurrency(tasks, maxConcurrency, onSettled) {
477
524
  await Promise.all(workers);
478
525
  return results;
479
526
  }
480
- async function replay(httpClient, serviceUrl, traceFunctionKey, fn, options) {
527
+ async function replay(httpClient, serviceUrl, traceFunctionKey, fn, options, registeredOverrides = []) {
481
528
  if (options?.traceIds !== void 0) {
482
529
  if (options.traceIds.length === 0) {
483
530
  throw new BitfabError("traceIds must contain at least one trace ID.");
@@ -517,13 +564,20 @@ async function replay(httpClient, serviceUrl, traceFunctionKey, fn, options) {
517
564
  );
518
565
  const mockStrategy = options?.mock ?? "marked";
519
566
  const maxConcurrency = options?.maxConcurrency ?? 10;
567
+ const resolvedOverrides = [
568
+ ...normalizeMockOverrides(options?.mockOverride),
569
+ ...registeredOverrides
570
+ ];
571
+ const replayedTraceIds = serverItems.map(() => randomUuid());
520
572
  const tasks = serverItems.map(
521
- (serverItem) => () => processItem(
573
+ (serverItem, index) => () => processItem(
522
574
  httpClient,
523
575
  serverItem,
524
576
  fn,
525
577
  testRunId,
526
578
  mockStrategy,
579
+ resolvedOverrides,
580
+ replayedTraceIds[index],
527
581
  options?.environment,
528
582
  options?.adaptInputs
529
583
  )
@@ -550,11 +604,17 @@ async function replay(httpClient, serviceUrl, traceFunctionKey, fn, options) {
550
604
  succeeded,
551
605
  errored,
552
606
  item: {
553
- // Source (historical) trace id, so a UI can identify the trace
554
- // that just settled. The item's own traceId is the new replay
555
- // trace and is assigned later (below), so use the server item.
556
- traceId: serverItems[index]?.traceId ?? null,
557
- replayTraceId: item.traceId,
607
+ // The server replay trace id isn't known until completeReplay
608
+ // runs (below), so it can't be reported mid-run and we never
609
+ // emit the client-side placeholder. originalTraceId (the
610
+ // historical trace) is known now and is what a UI keys on to
611
+ // identify what just settled.
612
+ traceId: null,
613
+ originalTraceId: item.originalTraceId ?? null,
614
+ originalSpanId: item.originalSpanId ?? null,
615
+ // Deprecated aliases for originalTraceId/originalSpanId.
616
+ sourceTraceId: item.originalTraceId ?? null,
617
+ sourceSpanId: item.originalSpanId ?? null,
558
618
  input: item.input,
559
619
  result: item.result,
560
620
  originalOutput: item.originalOutput,
@@ -572,56 +632,58 @@ async function replay(httpClient, serviceUrl, traceFunctionKey, fn, options) {
572
632
  const completeResult = await httpClient.completeReplay(testRunId);
573
633
  const serverTraceIds = completeResult.traceIds;
574
634
  const replayTokens = completeResult.tokens;
575
- if (serverTraceIds === void 0) {
576
- try {
577
- console.warn(
578
- "Bitfab: server did not return replay trace IDs; item.traceId will be null (server upgrade required for verdict persistence)"
579
- );
580
- } catch {
581
- }
582
- for (const item of resultItems) {
583
- item.traceId = null;
584
- }
585
- } else {
635
+ if (serverTraceIds !== void 0) {
586
636
  const missing = [];
587
637
  let completedCount = 0;
588
- for (const item of resultItems) {
589
- if (item.traceId) {
590
- const mapped = serverTraceIds[item.traceId];
591
- if (item.error === null) {
592
- completedCount += 1;
593
- if (mapped === void 0) {
594
- missing.push(item.traceId);
595
- }
596
- }
597
- if (mapped !== void 0) {
598
- item.tokens = replayTokens?.[mapped] ?? null;
638
+ for (let index = 0; index < resultItems.length; index += 1) {
639
+ const item = resultItems[index];
640
+ const localId = replayedTraceIds[index];
641
+ const mapped = localId ? serverTraceIds[localId] : void 0;
642
+ item.traceId = mapped ?? null;
643
+ if (item.error === null) {
644
+ completedCount += 1;
645
+ if (mapped === void 0) {
646
+ missing.push(localId ?? item.originalTraceId);
599
647
  }
600
- item.traceId = mapped ?? null;
648
+ }
649
+ if (mapped !== void 0) {
650
+ item.tokens = replayTokens?.[mapped] ?? null;
601
651
  }
602
652
  }
603
- if (missing.length > 0) {
653
+ if (completedCount > 0 && missing.length === completedCount) {
604
654
  const serverCount = completeResult.traceCount !== void 0 ? ` The server persisted ${completeResult.traceCount} trace(s) for this run.` : "";
605
- if (missing.length === completedCount) {
606
- throw new BitfabError(
607
- `Replay completed but the server has no persisted trace for any of the ${completedCount} completed item(s) (testRunId ${testRunId}).${serverCount} Trace uploads were awaited, so either the uploads failed (check for "Bitfab: Failed to create" errors above) or the replayed function is not wrapped with withSpan.`
608
- );
609
- }
655
+ throw new BitfabError(
656
+ `Replay completed but the server has no persisted trace for any of the ${completedCount} completed item(s) (testRunId ${testRunId}).${serverCount} Trace uploads were awaited, so either the uploads failed (check for "Bitfab: Failed to create" errors above) or the replayed function is not wrapped with withSpan.`
657
+ );
658
+ }
659
+ if (missing.length > 0) {
610
660
  try {
611
661
  console.error(
612
- `Bitfab: server has no persisted trace for ${missing.length} of ${completedCount} completed replay item(s) (testRunId ${testRunId}).${serverCount} Their traceId is null and verdicts cannot be persisted for them. Missing: ${missing.join(", ")}`
662
+ `Bitfab: server has no persisted trace for ${missing.length} of ${completedCount} completed replay item(s) (testRunId ${testRunId}). Their replay token usage is unavailable and they cannot be labeled.`
613
663
  );
614
664
  } catch {
615
665
  }
616
666
  }
617
667
  }
618
- const replayResult = {
668
+ const result = {
619
669
  items: resultItems,
620
670
  testRunId,
621
671
  testRunUrl: `${serviceUrl}${testRunUrl}`
622
672
  };
623
- await writeReplayResultFile(replayResult);
624
- return replayResult;
673
+ await writeReplayResultFile(result);
674
+ try {
675
+ options?.onProgress?.({
676
+ type: "complete",
677
+ testRunId,
678
+ completed: total,
679
+ total,
680
+ succeeded,
681
+ errored,
682
+ result
683
+ });
684
+ } catch {
685
+ }
686
+ return result;
625
687
  }
626
688
  async function writeReplayResultFile(result) {
627
689
  const resultPath = typeof process !== "undefined" ? process.env?.BITFAB_REPLAY_RESULT_PATH : void 0;
@@ -650,6 +712,7 @@ var init_replay = __esm({
650
712
  "src/replay.ts"() {
651
713
  "use strict";
652
714
  init_errors();
715
+ init_mockOverride();
653
716
  init_randomUuid();
654
717
  init_replayContext();
655
718
  init_serialize();
@@ -683,7 +746,7 @@ __export(index_exports, {
683
746
  module.exports = __toCommonJS(index_exports);
684
747
 
685
748
  // src/version.generated.ts
686
- var __version__ = "0.28.11";
749
+ var __version__ = "0.29.1";
687
750
 
688
751
  // src/constants.ts
689
752
  var DEFAULT_SERVICE_URL = "https://bitfab.ai";
@@ -1112,9 +1175,14 @@ var HttpClient = class {
1112
1175
  /**
1113
1176
  * Fetch the span tree for a root span.
1114
1177
  * Blocking GET request.
1178
+ *
1179
+ * Pass `includeOutputs: false` for a payload-free tree (structure +
1180
+ * `externalSpanId` only), so recorded outputs are fetched lazily per mocked
1181
+ * span instead of all up front. Omit it (default eager) for `mock: "all"`.
1115
1182
  */
1116
- async getSpanTree(externalSpanId) {
1117
- const url = `${this.serviceUrl}/api/sdk/replay/spanTree/${externalSpanId}`;
1183
+ async getSpanTree(externalSpanId, options) {
1184
+ const query = options?.includeOutputs === false ? "?includeOutputs=false" : "";
1185
+ const url = `${this.serviceUrl}/api/sdk/replay/spanTree/${externalSpanId}${query}`;
1118
1186
  const controller = new AbortController();
1119
1187
  const timeoutId = setTimeout(() => controller.abort(), 3e4);
1120
1188
  try {
@@ -2700,6 +2768,9 @@ var BitfabLangGraphCallbackHandler = class {
2700
2768
  }
2701
2769
  };
2702
2770
 
2771
+ // src/client.ts
2772
+ init_mockOverride();
2773
+
2703
2774
  // src/openaiAgentSdk.ts
2704
2775
  var BitfabOpenAIAgentHandler = class {
2705
2776
  constructor(config) {
@@ -3515,6 +3586,12 @@ var Bitfab = class {
3515
3586
  constructor(config) {
3516
3587
  /** Gate the empty-key warning to fire at most once. */
3517
3588
  this.apiKeyWarned = false;
3589
+ /**
3590
+ * Mock overrides registered via {@link Bitfab.registerMockOverride}, applied
3591
+ * to every `replay` on this client (after any per-call `mockOverride`). In
3592
+ * registration order; first matcher wins within this list.
3593
+ */
3594
+ this.mockOverrides = [];
3518
3595
  this.apiKeyConfig = config.apiKey;
3519
3596
  this.serviceUrl = config.serviceUrl ?? DEFAULT_SERVICE_URL;
3520
3597
  this.timeout = config.timeout ?? 12e4;
@@ -4099,7 +4176,7 @@ var Bitfab = class {
4099
4176
  dbSnapshotUsage: {
4100
4177
  neonBranchId: replayCtx.dbBranchLease.neonBranchId,
4101
4178
  snapshotTimestamp: replayCtx.dbBranchLease.snapshotTimestamp,
4102
- sourceTraceId: replayCtx.sourceBitfabTraceId,
4179
+ originalTraceId: replayCtx.sourceBitfabTraceId,
4103
4180
  accessed: replayCtx.dbSnapshotAccessed === true
4104
4181
  }
4105
4182
  }
@@ -4127,24 +4204,77 @@ var Bitfab = class {
4127
4204
  const counterKey = `${traceFunctionKey}:${baseSpanParams.spanName}`;
4128
4205
  const callIndex = counters.get(counterKey) ?? 0;
4129
4206
  counters.set(counterKey, callIndex + 1);
4130
- const shouldMock = replayCtxForMock.mockStrategy === "all" || replayCtxForMock.mockStrategy === "marked" && options.mockOnReplay === true;
4131
- if (shouldMock) {
4132
- const mockKey = `${counterKey}:${callIndex}`;
4133
- const mockSpan = replayCtxForMock.mockTree.spans.get(mockKey);
4134
- if (mockSpan) {
4135
- let output = mockSpan.output;
4136
- if (mockSpan.outputMeta !== void 0 && mockSpan.outputMeta !== null) {
4137
- output = deserializeValue({
4138
- json: mockSpan.output,
4139
- meta: mockSpan.outputMeta
4140
- });
4141
- }
4207
+ const mockKey = `${counterKey}:${callIndex}`;
4208
+ const mockSpan = replayCtxForMock.mockTree.spans.get(mockKey);
4209
+ const emitMock = (output) => {
4210
+ void sendSpan({ result: output, mocked: true });
4211
+ if (fnReturnsPromise) {
4212
+ return Promise.resolve(output);
4213
+ }
4214
+ return output;
4215
+ };
4216
+ const emitMockAsync = (pending) => {
4217
+ if (!fnReturnsPromise) {
4218
+ throw new BitfabError(
4219
+ `Cannot mock synchronous span "${traceFunctionKey}" with an asynchronously-resolved value (lazy recorded-output fetch or an async value function). Make the wrapped function async, or use mock: "all" so recorded outputs are fetched eagerly.`
4220
+ );
4221
+ }
4222
+ return (async () => {
4223
+ const output = await pending;
4142
4224
  void sendSpan({ result: output, mocked: true });
4143
- if (fnReturnsPromise) {
4144
- return Promise.resolve(output);
4145
- }
4146
4225
  return output;
4226
+ })();
4227
+ };
4228
+ const resolveRecordedOutput = () => {
4229
+ const hasInlineOutput = mockSpan?.output !== void 0 || mockSpan?.outputMeta !== void 0;
4230
+ if (!hasInlineOutput && replayCtxForMock.fetchSpanOutput && mockSpan?.externalSpanId) {
4231
+ return replayCtxForMock.fetchSpanOutput(mockSpan.externalSpanId);
4232
+ }
4233
+ if (!mockSpan) {
4234
+ return Promise.reject(
4235
+ new BitfabError(
4236
+ `No recorded span to source output for "${traceFunctionKey}".`
4237
+ )
4238
+ );
4147
4239
  }
4240
+ let output = mockSpan.output;
4241
+ if (mockSpan.outputMeta !== void 0 && mockSpan.outputMeta !== null) {
4242
+ output = deserializeValue({
4243
+ json: mockSpan.output,
4244
+ meta: mockSpan.outputMeta
4245
+ });
4246
+ }
4247
+ return output;
4248
+ };
4249
+ if (replayCtxForMock.mockOverrides?.length) {
4250
+ const nodeMeta = {
4251
+ traceFunctionKey,
4252
+ spanName: baseSpanParams.spanName,
4253
+ type: options.type ?? "custom",
4254
+ originalSpanId: mockSpan?.sourceSpanId
4255
+ };
4256
+ const override = replayCtxForMock.mockOverrides.find(
4257
+ (o) => o.match(nodeMeta)
4258
+ );
4259
+ if (override) {
4260
+ const injected = resolveMockValue(override.value, {
4261
+ node: nodeMeta,
4262
+ inputs: args,
4263
+ getOriginalOutput: () => Promise.resolve(resolveRecordedOutput())
4264
+ });
4265
+ if (injected instanceof Promise) {
4266
+ return emitMockAsync(injected);
4267
+ }
4268
+ return emitMock(injected);
4269
+ }
4270
+ }
4271
+ const shouldMock = replayCtxForMock.mockStrategy === "all" || replayCtxForMock.mockStrategy === "marked" && options.mockOnReplay === true;
4272
+ if (shouldMock && mockSpan) {
4273
+ const recorded = resolveRecordedOutput();
4274
+ if (recorded instanceof Promise) {
4275
+ return emitMockAsync(recorded);
4276
+ }
4277
+ return emitMock(recorded);
4148
4278
  }
4149
4279
  }
4150
4280
  const recordSpan = (result) => {
@@ -4350,8 +4480,11 @@ var Bitfab = class {
4350
4480
  ...params.dbSnapshotUsage.snapshotTimestamp && {
4351
4481
  snapshot_timestamp: params.dbSnapshotUsage.snapshotTimestamp
4352
4482
  },
4353
- ...params.dbSnapshotUsage.sourceTraceId && {
4354
- source_trace_id: params.dbSnapshotUsage.sourceTraceId
4483
+ ...params.dbSnapshotUsage.originalTraceId && {
4484
+ original_trace_id: params.dbSnapshotUsage.originalTraceId,
4485
+ // Deprecated wire alias, kept so this SDK still reports usage
4486
+ // against servers that predate the rename.
4487
+ source_trace_id: params.dbSnapshotUsage.originalTraceId
4355
4488
  },
4356
4489
  accessed: params.dbSnapshotUsage.accessed
4357
4490
  };
@@ -4424,26 +4557,14 @@ var Bitfab = class {
4424
4557
  ...params.mocked && { mocked: true }
4425
4558
  });
4426
4559
  }
4427
- /**
4428
- * Replay historical traces through a function and create a test run.
4429
- *
4430
- * Fetches the last N traces for the given trace function key, re-runs each
4431
- * through the provided function, and returns comparison data.
4432
- *
4433
- * Accepts either a `withSpan`-wrapped function (under the same key) or any
4434
- * plain callable: plain callables are wrapped internally so each replayed
4435
- * invocation records a trace tied to the test run. The plain-callable form
4436
- * is how handler-instrumented workflows (LangGraph/LangChain, Claude Agent
4437
- * SDK) replay - those record traces under a key with no `withSpan`-wrapped
4438
- * root in the app.
4439
- *
4440
- * @param traceFunctionKey - The trace function key to replay
4441
- * @param fn - The function to run recorded inputs through
4442
- * @param options - Optional replay options. When `traceIds` is passed,
4443
- * `limit` is ignored (with a warning): an explicit ID list already
4444
- * determines how many traces replay.
4445
- * @returns ReplayResult with items, testRunId, and testRunUrl
4446
- */
4560
+ registerMockOverride(overrideOrMatch, value) {
4561
+ const override = typeof overrideOrMatch === "function" ? { match: overrideOrMatch, value } : overrideOrMatch;
4562
+ this.mockOverrides.push(override);
4563
+ }
4564
+ /** Remove all overrides registered via {@link registerMockOverride}. */
4565
+ clearMockOverrides() {
4566
+ this.mockOverrides.length = 0;
4567
+ }
4447
4568
  async replay(traceFunctionKey, fn, options) {
4448
4569
  const wrappedKey = fn._bitfabTraceFunctionKey;
4449
4570
  let replayFn = fn;
@@ -4464,7 +4585,8 @@ var Bitfab = class {
4464
4585
  this.serviceUrl,
4465
4586
  traceFunctionKey,
4466
4587
  replayFn,
4467
- options
4588
+ options,
4589
+ this.mockOverrides
4468
4590
  );
4469
4591
  }
4470
4592
  };