bitfab 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
@@ -291,6 +291,22 @@ var init_asyncStorage = __esm({
291
291
  }
292
292
  });
293
293
 
294
+ // src/mockOverride.ts
295
+ function resolveMockValue(value, ctx) {
296
+ return typeof value === "function" ? value(ctx) : value;
297
+ }
298
+ function normalizeMockOverrides(mockOverride) {
299
+ if (mockOverride === void 0) {
300
+ return [];
301
+ }
302
+ return Array.isArray(mockOverride) ? mockOverride : [mockOverride];
303
+ }
304
+ var init_mockOverride = __esm({
305
+ "src/mockOverride.ts"() {
306
+ "use strict";
307
+ }
308
+ });
309
+
294
310
  // src/replayContext.ts
295
311
  function getReplayContext() {
296
312
  return replayContextStorage?.getStore() ?? null;
@@ -366,6 +382,7 @@ function buildMockTree(rootNode) {
366
382
  counters.set(counterKey, index + 1);
367
383
  spans.set(`${counterKey}:${index}`, {
368
384
  sourceSpanId: node.sourceSpanId,
385
+ externalSpanId: node.externalSpanId,
369
386
  output: node.output,
370
387
  outputMeta: node.outputMeta
371
388
  });
@@ -379,57 +396,79 @@ function buildMockTree(rootNode) {
379
396
  }
380
397
  return { spans };
381
398
  }
382
- async function processItem(httpClient, serverItem, fn, testRunId, mockStrategy, environment, adaptInputs) {
399
+ async function processItem(httpClient, serverItem, fn, testRunId, mockStrategy, resolvedOverrides, replayedTraceId, environment, adaptInputs) {
383
400
  const lease = environment ? serverItem.dbBranchLease : void 0;
384
401
  let inputs = [];
385
402
  let originalOutput;
386
403
  let result;
387
404
  let error = null;
388
- const replayedTraceId = randomUuid();
389
405
  const pendingPersistence = [];
406
+ const originalTraceId = serverItem.originalTraceId ?? serverItem.sourceTraceId;
407
+ const originalSpanId = serverItem.originalSpanId ?? serverItem.sourceSpanId;
390
408
  try {
391
- const span = await httpClient.getExternalSpan(serverItem.externalSpanId);
409
+ const span = await httpClient.getExternalSpan(originalSpanId);
392
410
  const spanData = span.rawData?.span_data ?? {};
393
411
  inputs = deserializeInputs(spanData);
394
412
  originalOutput = deserializeOutput(spanData);
395
413
  if (adaptInputs) {
396
414
  inputs = adaptInputs(inputs, {
397
- traceId: serverItem.traceId,
398
- sourceSpanId: serverItem.externalSpanId
415
+ originalTraceId,
416
+ originalSpanId,
417
+ // Deprecated aliases for originalTraceId/originalSpanId.
418
+ sourceTraceId: originalTraceId,
419
+ sourceSpanId: originalSpanId
399
420
  });
400
421
  }
422
+ const hasOverrides = resolvedOverrides.length > 0;
423
+ const needTree = mockStrategy === "all" || mockStrategy === "marked" || hasOverrides;
424
+ const includeOutputs = mockStrategy === "all";
401
425
  let mockTree;
402
- if (mockStrategy === "all" || mockStrategy === "marked") {
426
+ if (needTree) {
403
427
  try {
404
- const treeResponse = await httpClient.getSpanTree(
405
- serverItem.externalSpanId
406
- );
428
+ const treeResponse = await httpClient.getSpanTree(originalSpanId, {
429
+ includeOutputs
430
+ });
407
431
  if (treeResponse.root) {
408
432
  mockTree = buildMockTree(treeResponse.root);
409
- } else if (mockStrategy === "all") {
433
+ } else if (mockStrategy === "all" || hasOverrides) {
410
434
  throw new BitfabError(
411
- `Replay mock strategy "all" requires a span tree root for source span ${serverItem.externalSpanId}.`
435
+ `Replay mock strategy "${mockStrategy}"${hasOverrides ? " with overrides" : ""} requires a span tree root for original span ${originalSpanId}.`
412
436
  );
413
437
  } else {
414
438
  mockTree = void 0;
415
439
  }
416
440
  } catch (e) {
417
- if (mockStrategy === "all") {
441
+ if (mockStrategy === "all" || hasOverrides) {
418
442
  throw e;
419
443
  }
420
444
  mockTree = void 0;
421
445
  }
422
446
  }
447
+ const outputCache = /* @__PURE__ */ new Map();
448
+ const fetchSpanOutput = mockTree && !includeOutputs ? (externalSpanId) => {
449
+ let pending = outputCache.get(externalSpanId);
450
+ if (!pending) {
451
+ pending = httpClient.getExternalSpan(externalSpanId).then(
452
+ (s) => deserializeOutput(
453
+ s.rawData?.span_data ?? {}
454
+ )
455
+ );
456
+ outputCache.set(externalSpanId, pending);
457
+ }
458
+ return pending;
459
+ } : void 0;
423
460
  const maybePromise = runWithReplayContext(
424
461
  {
425
462
  testRunId,
426
463
  traceId: replayedTraceId,
427
464
  inputSourceSpanId: span.id,
428
465
  inputSourceTraceId: span.externalTraceId,
429
- sourceBitfabTraceId: serverItem.traceId,
466
+ sourceBitfabTraceId: originalTraceId,
430
467
  mockTree,
431
468
  callCounters: mockTree ? /* @__PURE__ */ new Map() : void 0,
432
469
  mockStrategy,
470
+ mockOverrides: hasOverrides ? resolvedOverrides : void 0,
471
+ fetchSpanOutput,
433
472
  dbBranchLease: lease,
434
473
  pendingPersistence
435
474
  },
@@ -454,7 +493,15 @@ async function processItem(httpClient, serverItem, fn, testRunId, mockStrategy,
454
493
  }
455
494
  }
456
495
  return {
457
- traceId: replayedTraceId,
496
+ // Written in by replay() from the complete-replay response once the server
497
+ // has minted this replay trace's row. Null until then: the client-side
498
+ // correlation id (replayedTraceId) is never surfaced as the item's traceId.
499
+ traceId: null,
500
+ originalTraceId,
501
+ originalSpanId,
502
+ // Deprecated aliases for originalTraceId/originalSpanId.
503
+ sourceTraceId: originalTraceId,
504
+ sourceSpanId: originalSpanId,
458
505
  input: inputs,
459
506
  result,
460
507
  originalOutput,
@@ -486,7 +533,7 @@ async function mapWithConcurrency(tasks, maxConcurrency, onSettled) {
486
533
  await Promise.all(workers);
487
534
  return results;
488
535
  }
489
- async function replay(httpClient, serviceUrl, traceFunctionKey, fn, options) {
536
+ async function replay(httpClient, serviceUrl, traceFunctionKey, fn, options, registeredOverrides = []) {
490
537
  if (options?.traceIds !== void 0) {
491
538
  if (options.traceIds.length === 0) {
492
539
  throw new BitfabError("traceIds must contain at least one trace ID.");
@@ -526,13 +573,20 @@ async function replay(httpClient, serviceUrl, traceFunctionKey, fn, options) {
526
573
  );
527
574
  const mockStrategy = options?.mock ?? "marked";
528
575
  const maxConcurrency = options?.maxConcurrency ?? 10;
576
+ const resolvedOverrides = [
577
+ ...normalizeMockOverrides(options?.mockOverride),
578
+ ...registeredOverrides
579
+ ];
580
+ const replayedTraceIds = serverItems.map(() => randomUuid());
529
581
  const tasks = serverItems.map(
530
- (serverItem) => () => processItem(
582
+ (serverItem, index) => () => processItem(
531
583
  httpClient,
532
584
  serverItem,
533
585
  fn,
534
586
  testRunId,
535
587
  mockStrategy,
588
+ resolvedOverrides,
589
+ replayedTraceIds[index],
536
590
  options?.environment,
537
591
  options?.adaptInputs
538
592
  )
@@ -559,11 +613,17 @@ async function replay(httpClient, serviceUrl, traceFunctionKey, fn, options) {
559
613
  succeeded,
560
614
  errored,
561
615
  item: {
562
- // Source (historical) trace id, so a UI can identify the trace
563
- // that just settled. The item's own traceId is the new replay
564
- // trace and is assigned later (below), so use the server item.
565
- traceId: serverItems[index]?.traceId ?? null,
566
- replayTraceId: item.traceId,
616
+ // The server replay trace id isn't known until completeReplay
617
+ // runs (below), so it can't be reported mid-run and we never
618
+ // emit the client-side placeholder. originalTraceId (the
619
+ // historical trace) is known now and is what a UI keys on to
620
+ // identify what just settled.
621
+ traceId: null,
622
+ originalTraceId: item.originalTraceId ?? null,
623
+ originalSpanId: item.originalSpanId ?? null,
624
+ // Deprecated aliases for originalTraceId/originalSpanId.
625
+ sourceTraceId: item.originalTraceId ?? null,
626
+ sourceSpanId: item.originalSpanId ?? null,
567
627
  input: item.input,
568
628
  result: item.result,
569
629
  originalOutput: item.originalOutput,
@@ -581,56 +641,58 @@ async function replay(httpClient, serviceUrl, traceFunctionKey, fn, options) {
581
641
  const completeResult = await httpClient.completeReplay(testRunId);
582
642
  const serverTraceIds = completeResult.traceIds;
583
643
  const replayTokens = completeResult.tokens;
584
- if (serverTraceIds === void 0) {
585
- try {
586
- console.warn(
587
- "Bitfab: server did not return replay trace IDs; item.traceId will be null (server upgrade required for verdict persistence)"
588
- );
589
- } catch {
590
- }
591
- for (const item of resultItems) {
592
- item.traceId = null;
593
- }
594
- } else {
644
+ if (serverTraceIds !== void 0) {
595
645
  const missing = [];
596
646
  let completedCount = 0;
597
- for (const item of resultItems) {
598
- if (item.traceId) {
599
- const mapped = serverTraceIds[item.traceId];
600
- if (item.error === null) {
601
- completedCount += 1;
602
- if (mapped === void 0) {
603
- missing.push(item.traceId);
604
- }
605
- }
606
- if (mapped !== void 0) {
607
- item.tokens = replayTokens?.[mapped] ?? null;
647
+ for (let index = 0; index < resultItems.length; index += 1) {
648
+ const item = resultItems[index];
649
+ const localId = replayedTraceIds[index];
650
+ const mapped = localId ? serverTraceIds[localId] : void 0;
651
+ item.traceId = mapped ?? null;
652
+ if (item.error === null) {
653
+ completedCount += 1;
654
+ if (mapped === void 0) {
655
+ missing.push(localId ?? item.originalTraceId);
608
656
  }
609
- item.traceId = mapped ?? null;
657
+ }
658
+ if (mapped !== void 0) {
659
+ item.tokens = replayTokens?.[mapped] ?? null;
610
660
  }
611
661
  }
612
- if (missing.length > 0) {
662
+ if (completedCount > 0 && missing.length === completedCount) {
613
663
  const serverCount = completeResult.traceCount !== void 0 ? ` The server persisted ${completeResult.traceCount} trace(s) for this run.` : "";
614
- if (missing.length === completedCount) {
615
- throw new BitfabError(
616
- `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.`
617
- );
618
- }
664
+ throw new BitfabError(
665
+ `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.`
666
+ );
667
+ }
668
+ if (missing.length > 0) {
619
669
  try {
620
670
  console.error(
621
- `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(", ")}`
671
+ `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.`
622
672
  );
623
673
  } catch {
624
674
  }
625
675
  }
626
676
  }
627
- const replayResult = {
677
+ const result = {
628
678
  items: resultItems,
629
679
  testRunId,
630
680
  testRunUrl: `${serviceUrl}${testRunUrl}`
631
681
  };
632
- await writeReplayResultFile(replayResult);
633
- return replayResult;
682
+ await writeReplayResultFile(result);
683
+ try {
684
+ options?.onProgress?.({
685
+ type: "complete",
686
+ testRunId,
687
+ completed: total,
688
+ total,
689
+ succeeded,
690
+ errored,
691
+ result
692
+ });
693
+ } catch {
694
+ }
695
+ return result;
634
696
  }
635
697
  async function writeReplayResultFile(result) {
636
698
  const resultPath = typeof process !== "undefined" ? process.env?.BITFAB_REPLAY_RESULT_PATH : void 0;
@@ -659,6 +721,7 @@ var init_replay = __esm({
659
721
  "src/replay.ts"() {
660
722
  "use strict";
661
723
  init_errors();
724
+ init_mockOverride();
662
725
  init_randomUuid();
663
726
  init_replayContext();
664
727
  init_serialize();
@@ -692,7 +755,7 @@ __export(index_exports, {
692
755
  module.exports = __toCommonJS(index_exports);
693
756
 
694
757
  // src/version.generated.ts
695
- var __version__ = "0.28.11";
758
+ var __version__ = "0.29.1";
696
759
 
697
760
  // src/constants.ts
698
761
  var DEFAULT_SERVICE_URL = "https://bitfab.ai";
@@ -1121,9 +1184,14 @@ var HttpClient = class {
1121
1184
  /**
1122
1185
  * Fetch the span tree for a root span.
1123
1186
  * Blocking GET request.
1187
+ *
1188
+ * Pass `includeOutputs: false` for a payload-free tree (structure +
1189
+ * `externalSpanId` only), so recorded outputs are fetched lazily per mocked
1190
+ * span instead of all up front. Omit it (default eager) for `mock: "all"`.
1124
1191
  */
1125
- async getSpanTree(externalSpanId) {
1126
- const url = `${this.serviceUrl}/api/sdk/replay/spanTree/${externalSpanId}`;
1192
+ async getSpanTree(externalSpanId, options) {
1193
+ const query = options?.includeOutputs === false ? "?includeOutputs=false" : "";
1194
+ const url = `${this.serviceUrl}/api/sdk/replay/spanTree/${externalSpanId}${query}`;
1127
1195
  const controller = new AbortController();
1128
1196
  const timeoutId = setTimeout(() => controller.abort(), 3e4);
1129
1197
  try {
@@ -2709,6 +2777,9 @@ var BitfabLangGraphCallbackHandler = class {
2709
2777
  }
2710
2778
  };
2711
2779
 
2780
+ // src/client.ts
2781
+ init_mockOverride();
2782
+
2712
2783
  // src/openaiAgentSdk.ts
2713
2784
  var BitfabOpenAIAgentHandler = class {
2714
2785
  constructor(config) {
@@ -3524,6 +3595,12 @@ var Bitfab = class {
3524
3595
  constructor(config) {
3525
3596
  /** Gate the empty-key warning to fire at most once. */
3526
3597
  this.apiKeyWarned = false;
3598
+ /**
3599
+ * Mock overrides registered via {@link Bitfab.registerMockOverride}, applied
3600
+ * to every `replay` on this client (after any per-call `mockOverride`). In
3601
+ * registration order; first matcher wins within this list.
3602
+ */
3603
+ this.mockOverrides = [];
3527
3604
  this.apiKeyConfig = config.apiKey;
3528
3605
  this.serviceUrl = config.serviceUrl ?? DEFAULT_SERVICE_URL;
3529
3606
  this.timeout = config.timeout ?? 12e4;
@@ -4108,7 +4185,7 @@ var Bitfab = class {
4108
4185
  dbSnapshotUsage: {
4109
4186
  neonBranchId: replayCtx.dbBranchLease.neonBranchId,
4110
4187
  snapshotTimestamp: replayCtx.dbBranchLease.snapshotTimestamp,
4111
- sourceTraceId: replayCtx.sourceBitfabTraceId,
4188
+ originalTraceId: replayCtx.sourceBitfabTraceId,
4112
4189
  accessed: replayCtx.dbSnapshotAccessed === true
4113
4190
  }
4114
4191
  }
@@ -4136,24 +4213,77 @@ var Bitfab = class {
4136
4213
  const counterKey = `${traceFunctionKey}:${baseSpanParams.spanName}`;
4137
4214
  const callIndex = counters.get(counterKey) ?? 0;
4138
4215
  counters.set(counterKey, callIndex + 1);
4139
- const shouldMock = replayCtxForMock.mockStrategy === "all" || replayCtxForMock.mockStrategy === "marked" && options.mockOnReplay === true;
4140
- if (shouldMock) {
4141
- const mockKey = `${counterKey}:${callIndex}`;
4142
- const mockSpan = replayCtxForMock.mockTree.spans.get(mockKey);
4143
- if (mockSpan) {
4144
- let output = mockSpan.output;
4145
- if (mockSpan.outputMeta !== void 0 && mockSpan.outputMeta !== null) {
4146
- output = deserializeValue({
4147
- json: mockSpan.output,
4148
- meta: mockSpan.outputMeta
4149
- });
4150
- }
4216
+ const mockKey = `${counterKey}:${callIndex}`;
4217
+ const mockSpan = replayCtxForMock.mockTree.spans.get(mockKey);
4218
+ const emitMock = (output) => {
4219
+ void sendSpan({ result: output, mocked: true });
4220
+ if (fnReturnsPromise) {
4221
+ return Promise.resolve(output);
4222
+ }
4223
+ return output;
4224
+ };
4225
+ const emitMockAsync = (pending) => {
4226
+ if (!fnReturnsPromise) {
4227
+ throw new BitfabError(
4228
+ `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.`
4229
+ );
4230
+ }
4231
+ return (async () => {
4232
+ const output = await pending;
4151
4233
  void sendSpan({ result: output, mocked: true });
4152
- if (fnReturnsPromise) {
4153
- return Promise.resolve(output);
4154
- }
4155
4234
  return output;
4235
+ })();
4236
+ };
4237
+ const resolveRecordedOutput = () => {
4238
+ const hasInlineOutput = mockSpan?.output !== void 0 || mockSpan?.outputMeta !== void 0;
4239
+ if (!hasInlineOutput && replayCtxForMock.fetchSpanOutput && mockSpan?.externalSpanId) {
4240
+ return replayCtxForMock.fetchSpanOutput(mockSpan.externalSpanId);
4241
+ }
4242
+ if (!mockSpan) {
4243
+ return Promise.reject(
4244
+ new BitfabError(
4245
+ `No recorded span to source output for "${traceFunctionKey}".`
4246
+ )
4247
+ );
4156
4248
  }
4249
+ let output = mockSpan.output;
4250
+ if (mockSpan.outputMeta !== void 0 && mockSpan.outputMeta !== null) {
4251
+ output = deserializeValue({
4252
+ json: mockSpan.output,
4253
+ meta: mockSpan.outputMeta
4254
+ });
4255
+ }
4256
+ return output;
4257
+ };
4258
+ if (replayCtxForMock.mockOverrides?.length) {
4259
+ const nodeMeta = {
4260
+ traceFunctionKey,
4261
+ spanName: baseSpanParams.spanName,
4262
+ type: options.type ?? "custom",
4263
+ originalSpanId: mockSpan?.sourceSpanId
4264
+ };
4265
+ const override = replayCtxForMock.mockOverrides.find(
4266
+ (o) => o.match(nodeMeta)
4267
+ );
4268
+ if (override) {
4269
+ const injected = resolveMockValue(override.value, {
4270
+ node: nodeMeta,
4271
+ inputs: args,
4272
+ getOriginalOutput: () => Promise.resolve(resolveRecordedOutput())
4273
+ });
4274
+ if (injected instanceof Promise) {
4275
+ return emitMockAsync(injected);
4276
+ }
4277
+ return emitMock(injected);
4278
+ }
4279
+ }
4280
+ const shouldMock = replayCtxForMock.mockStrategy === "all" || replayCtxForMock.mockStrategy === "marked" && options.mockOnReplay === true;
4281
+ if (shouldMock && mockSpan) {
4282
+ const recorded = resolveRecordedOutput();
4283
+ if (recorded instanceof Promise) {
4284
+ return emitMockAsync(recorded);
4285
+ }
4286
+ return emitMock(recorded);
4157
4287
  }
4158
4288
  }
4159
4289
  const recordSpan = (result) => {
@@ -4359,8 +4489,11 @@ var Bitfab = class {
4359
4489
  ...params.dbSnapshotUsage.snapshotTimestamp && {
4360
4490
  snapshot_timestamp: params.dbSnapshotUsage.snapshotTimestamp
4361
4491
  },
4362
- ...params.dbSnapshotUsage.sourceTraceId && {
4363
- source_trace_id: params.dbSnapshotUsage.sourceTraceId
4492
+ ...params.dbSnapshotUsage.originalTraceId && {
4493
+ original_trace_id: params.dbSnapshotUsage.originalTraceId,
4494
+ // Deprecated wire alias, kept so this SDK still reports usage
4495
+ // against servers that predate the rename.
4496
+ source_trace_id: params.dbSnapshotUsage.originalTraceId
4364
4497
  },
4365
4498
  accessed: params.dbSnapshotUsage.accessed
4366
4499
  };
@@ -4433,26 +4566,14 @@ var Bitfab = class {
4433
4566
  ...params.mocked && { mocked: true }
4434
4567
  });
4435
4568
  }
4436
- /**
4437
- * Replay historical traces through a function and create a test run.
4438
- *
4439
- * Fetches the last N traces for the given trace function key, re-runs each
4440
- * through the provided function, and returns comparison data.
4441
- *
4442
- * Accepts either a `withSpan`-wrapped function (under the same key) or any
4443
- * plain callable: plain callables are wrapped internally so each replayed
4444
- * invocation records a trace tied to the test run. The plain-callable form
4445
- * is how handler-instrumented workflows (LangGraph/LangChain, Claude Agent
4446
- * SDK) replay - those record traces under a key with no `withSpan`-wrapped
4447
- * root in the app.
4448
- *
4449
- * @param traceFunctionKey - The trace function key to replay
4450
- * @param fn - The function to run recorded inputs through
4451
- * @param options - Optional replay options. When `traceIds` is passed,
4452
- * `limit` is ignored (with a warning): an explicit ID list already
4453
- * determines how many traces replay.
4454
- * @returns ReplayResult with items, testRunId, and testRunUrl
4455
- */
4569
+ registerMockOverride(overrideOrMatch, value) {
4570
+ const override = typeof overrideOrMatch === "function" ? { match: overrideOrMatch, value } : overrideOrMatch;
4571
+ this.mockOverrides.push(override);
4572
+ }
4573
+ /** Remove all overrides registered via {@link registerMockOverride}. */
4574
+ clearMockOverrides() {
4575
+ this.mockOverrides.length = 0;
4576
+ }
4456
4577
  async replay(traceFunctionKey, fn, options) {
4457
4578
  const wrappedKey = fn._bitfabTraceFunctionKey;
4458
4579
  let replayFn = fn;
@@ -4473,7 +4594,8 @@ var Bitfab = class {
4473
4594
  this.serviceUrl,
4474
4595
  traceFunctionKey,
4475
4596
  replayFn,
4476
- options
4597
+ options,
4598
+ this.mockOverrides
4477
4599
  );
4478
4600
  }
4479
4601
  };