@bitfab/sdk 0.36.6 → 0.36.8

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.
@@ -199,10 +199,11 @@ function looksBinary(s) {
199
199
 
200
200
  // src/errors.ts
201
201
  var BitfabError = class extends Error {
202
- constructor(message, url, status) {
202
+ constructor(message, url, status, retryAfterMs) {
203
203
  super(message);
204
204
  this.url = url;
205
205
  this.status = status;
206
+ this.retryAfterMs = retryAfterMs;
206
207
  this.name = "BitfabError";
207
208
  }
208
209
  };
@@ -247,38 +248,63 @@ function toArrayBuffer(view) {
247
248
  view.byteOffset + view.byteLength
248
249
  );
249
250
  }
251
+ function compressedRequest(body, rawBytes, compressed) {
252
+ if (compressed.byteLength >= rawBytes) {
253
+ return { body, rawBytes, wireBytes: rawBytes };
254
+ }
255
+ return {
256
+ body: compressed instanceof Uint8Array ? toArrayBuffer(compressed) : compressed,
257
+ contentEncoding: "gzip",
258
+ rawBytes,
259
+ wireBytes: compressed.byteLength
260
+ };
261
+ }
250
262
  async function gzipViaStream(bytes) {
251
263
  const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("gzip"));
252
264
  return await new Response(stream).arrayBuffer();
253
265
  }
254
266
  function encodeRequestBody(body) {
255
267
  if (readEnv(DISABLE_COMPRESSION_ENV)) {
256
- return { body };
268
+ const rawBytes = new TextEncoder().encode(body).byteLength;
269
+ return { body, rawBytes, wireBytes: rawBytes };
257
270
  }
258
271
  const bytes = new TextEncoder().encode(body);
259
272
  if (bytes.byteLength < MIN_COMPRESSED_BYTES) {
260
- return { body };
273
+ return {
274
+ body,
275
+ rawBytes: bytes.byteLength,
276
+ wireBytes: bytes.byteLength
277
+ };
261
278
  }
262
279
  if (gzipNode) {
263
280
  return gzipNode(bytes).then(
264
- (compressed) => ({
265
- body: toArrayBuffer(compressed),
266
- contentEncoding: "gzip"
267
- }),
268
- () => ({ body })
281
+ (compressed) => compressedRequest(body, bytes.byteLength, compressed),
282
+ () => ({
283
+ body,
284
+ rawBytes: bytes.byteLength,
285
+ wireBytes: bytes.byteLength
286
+ })
269
287
  );
270
288
  }
271
289
  if (typeof CompressionStream === "undefined") {
272
- return { body };
290
+ return {
291
+ body,
292
+ rawBytes: bytes.byteLength,
293
+ wireBytes: bytes.byteLength
294
+ };
273
295
  }
274
296
  return gzipViaStream(bytes).then(
275
- (compressed) => ({ body: compressed, contentEncoding: "gzip" }),
276
- () => ({ body })
297
+ (compressed) => compressedRequest(body, bytes.byteLength, compressed),
298
+ () => ({
299
+ body,
300
+ rawBytes: bytes.byteLength,
301
+ wireBytes: bytes.byteLength
302
+ })
277
303
  );
278
304
  }
279
305
 
280
306
  // src/version.generated.ts
281
- var __version__ = "0.36.6";
307
+ var __version__ = "0.36.8";
282
308
 
283
309
  // src/constants.ts
284
310
  var DEFAULT_SERVICE_URL = "https://bitfab.ai";
@@ -351,6 +377,7 @@ function runWithReplayContext(ctx, fn) {
351
377
 
352
378
  // src/payloadBudget.ts
353
379
  var MAX_SPAN_CARRIER_BYTES = 28e5;
380
+ var MAX_COMPRESSIBLE_SPAN_CARRIER_BYTES = 78e5;
354
381
  var textEncoder = typeof TextEncoder !== "undefined" ? new TextEncoder() : null;
355
382
  function byteLength(value) {
356
383
  return textEncoder ? textEncoder.encode(value).length : value.length;
@@ -374,15 +401,15 @@ function carrierBytesOf(encoded, body) {
374
401
  return encoded.length + extra;
375
402
  }
376
403
  var MAX_BYTES_PER_UNIT = 3;
377
- function fitsCarrierBudget(body) {
404
+ function fitsCarrierBudget(body, maxBytes = MAX_SPAN_CARRIER_BYTES) {
378
405
  const units = body.length;
379
- if (units * MAX_BYTES_PER_UNIT + 2 <= MAX_SPAN_CARRIER_BYTES) {
406
+ if (units * MAX_BYTES_PER_UNIT + 2 <= maxBytes) {
380
407
  return true;
381
408
  }
382
- if (units + 2 > MAX_SPAN_CARRIER_BYTES) {
409
+ if (units + 2 > maxBytes) {
383
410
  return false;
384
411
  }
385
- return carrierByteLength(body) <= MAX_SPAN_CARRIER_BYTES;
412
+ return carrierByteLength(body) <= maxBytes;
386
413
  }
387
414
  var STRUCTURAL_SPAN_KEYS = /* @__PURE__ */ new Set([
388
415
  "name",
@@ -432,7 +459,7 @@ function collectCandidates(containers) {
432
459
  }
433
460
  return candidates.sort((a, b) => b.size - a.size);
434
461
  }
435
- function trimPayloadToBudget(payload, encode) {
462
+ function trimPayloadToBudget(payload, encode, maxBytes = MAX_SPAN_CARRIER_BYTES) {
436
463
  const { copy, containers } = cloneTrimmable(payload);
437
464
  const candidates = collectCandidates(containers);
438
465
  if (candidates.length === 0) {
@@ -448,20 +475,20 @@ function trimPayloadToBudget(payload, encode) {
448
475
  } catch {
449
476
  return void 0;
450
477
  }
451
- if (fitsCarrierBudget(body)) {
478
+ if (fitsCarrierBudget(body, maxBytes)) {
452
479
  return { value: copy, trimmed };
453
480
  }
454
481
  }
455
482
  return void 0;
456
483
  }
457
- function markPayloadTrimmed(value, trimmed) {
484
+ function markPayloadTrimmed(value, trimmed, maxBytes = MAX_SPAN_CARRIER_BYTES) {
458
485
  const existing = Array.isArray(value.errors) ? value.errors : [];
459
486
  value.errors = [
460
487
  ...existing,
461
488
  {
462
489
  source: "sdk",
463
490
  step: "payload_budget",
464
- error: `trimmed oversized field(s) to fit the ${MAX_SPAN_CARRIER_BYTES}-byte span carrier budget: ${[
491
+ error: `trimmed oversized field(s) to fit the ${maxBytes}-byte span carrier budget: ${[
465
492
  ...new Set(trimmed)
466
493
  ].join(", ")}`
467
494
  }
@@ -482,30 +509,31 @@ function warnOnce(key, message) {
482
509
  }
483
510
 
484
511
  // src/serializePayload.ts
485
- function serializePayloadBody(payload) {
512
+ function serializePayloadBody(payload, maxCarrierBytes = MAX_SPAN_CARRIER_BYTES) {
486
513
  const encoded = encodePayloadBody(payload);
487
- if (fitsCarrierBudget(encoded.body)) {
514
+ if (fitsCarrierBudget(encoded.body, maxCarrierBytes)) {
488
515
  return { body: encoded.body, dropped: encoded.dropped };
489
516
  }
490
- return applyPayloadBudget(encoded);
517
+ return applyPayloadBudget(encoded, maxCarrierBytes);
491
518
  }
492
- function applyPayloadBudget(encoded) {
519
+ function applyPayloadBudget(encoded, maxCarrierBytes) {
493
520
  const result = encoded.value ? trimPayloadToBudget(
494
521
  encoded.value,
495
- (value) => encodePayloadBody(value).body
522
+ (value) => encodePayloadBody(value).body,
523
+ maxCarrierBytes
496
524
  ) : void 0;
497
525
  if (!result) {
498
526
  return { body: encoded.body, dropped: encoded.dropped };
499
527
  }
500
528
  warnOnce(
501
529
  "payload:over-budget",
502
- `a span payload exceeded the ${MAX_SPAN_CARRIER_BYTES}-byte carrier budget; its largest field(s) (${[
530
+ `a span payload exceeded the ${maxCarrierBytes}-byte carrier budget; its largest field(s) (${[
503
531
  ...new Set(result.trimmed)
504
532
  ].join(
505
533
  ", "
506
534
  )}) were replaced with placeholders so the span still ships. The span is incomplete and may not be replayable.`
507
535
  );
508
- markPayloadTrimmed(result.value, result.trimmed);
536
+ markPayloadTrimmed(result.value, result.trimmed, maxCarrierBytes);
509
537
  return {
510
538
  body: encodePayloadBody(result.value).body,
511
539
  dropped: encoded.dropped
@@ -616,6 +644,17 @@ import {
616
644
  BatchSpanProcessor
617
645
  } from "@opentelemetry/sdk-trace-base";
618
646
 
647
+ // src/transportTypes.ts
648
+ var DeliveryError = class extends Error {
649
+ constructor(message, options = {}) {
650
+ super(message);
651
+ this.name = "DeliveryError";
652
+ this.retryable = options.retryable ?? false;
653
+ this.oversized = options.oversized ?? false;
654
+ this.retryAfterMs = options.retryAfterMs;
655
+ }
656
+ };
657
+
619
658
  // src/unrefTimer.ts
620
659
  function unrefTimer(timer) {
621
660
  const handle = timer;
@@ -627,8 +666,8 @@ function unrefTimer(timer) {
627
666
  // src/otel.ts
628
667
  var OPERATION_ATTRIBUTE = "bitfab.operation";
629
668
  var PAYLOAD_ATTRIBUTE = "bitfab.payload";
630
- var OTLP_TRACES_ENDPOINT = "/api/sdk/otel/v1/traces";
631
669
  var MAX_EXPORT_REQUEST_BYTES = 3e6;
670
+ var MAX_DECOMPRESSED_REQUEST_BYTES = 8e6;
632
671
  var MAX_REQUEST_BYTES_ENV = "BITFAB_OTEL_MAX_REQUEST_BYTES";
633
672
  var EXPORT_CONCURRENCY_ENV = "BITFAB_OTEL_EXPORT_CONCURRENCY";
634
673
  var MAX_QUEUE_SIZE = 8192;
@@ -638,14 +677,12 @@ var DEFAULT_EXPORT_CONCURRENCY = 32;
638
677
  var MAX_EXPORT_CONCURRENCY = 64;
639
678
  var SCHEDULE_DELAY_MILLIS = 5e3;
640
679
  var EXPORT_TIMEOUT_MILLIS = 3e4;
641
- var RETRY_DELAY_MILLIS = 100;
680
+ var RETRY_BASE_DELAY_MILLIS = 100;
681
+ var RETRY_BACKOFF_CEILING_MILLIS = 5e3;
642
682
  var MAX_SEND_ATTEMPTS = 3;
643
683
  var DEFAULT_LIFECYCLE_TIMEOUT_MS = 3e4;
644
- var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 425, 429]);
645
684
  var liveTransports = /* @__PURE__ */ new Set();
646
- var traceSubmissionSpanIds = /* @__PURE__ */ new Map();
647
- var replayTraceSubmissions = /* @__PURE__ */ new Set();
648
- var submissionCounter = 0;
685
+ var carrierRefs = /* @__PURE__ */ new WeakMap();
649
686
  function readBoundedIntEnv(name, max, fallback, warnKey) {
650
687
  const raw = readEnv(name);
651
688
  if (raw === void 0) {
@@ -671,59 +708,6 @@ function logError(message, error) {
671
708
  } catch {
672
709
  }
673
710
  }
674
- function recordTraceSubmission(operation, payload) {
675
- const sourceTraceId = resolveSourceTraceId(payload);
676
- if (sourceTraceId === void 0) {
677
- return;
678
- }
679
- if (operation === "external_span") {
680
- const rawSpan = asRecord2(payload.rawSpan);
681
- if (typeof rawSpan?.id !== "string") {
682
- submissionCounter += 1;
683
- }
684
- const sourceSpanId = typeof rawSpan?.id === "string" ? rawSpan.id : `submission-${submissionCounter}`;
685
- const existing = traceSubmissionSpanIds.get(sourceTraceId);
686
- if (existing) {
687
- existing.add(sourceSpanId);
688
- } else {
689
- traceSubmissionSpanIds.set(sourceTraceId, /* @__PURE__ */ new Set([sourceSpanId]));
690
- }
691
- return;
692
- }
693
- if (payload.completed !== true) {
694
- return;
695
- }
696
- if (typeof payload.testRunId === "string") {
697
- replayTraceSubmissions.add(sourceTraceId);
698
- if (!traceSubmissionSpanIds.has(sourceTraceId)) {
699
- traceSubmissionSpanIds.set(sourceTraceId, /* @__PURE__ */ new Set());
700
- }
701
- } else {
702
- traceSubmissionSpanIds.delete(sourceTraceId);
703
- }
704
- }
705
- function takeReplaySpanCounts(traceIds) {
706
- const counts = {};
707
- for (const traceId of traceIds) {
708
- if (!replayTraceSubmissions.has(traceId)) {
709
- continue;
710
- }
711
- counts[traceId] = traceSubmissionSpanIds.get(traceId)?.size ?? 0;
712
- traceSubmissionSpanIds.delete(traceId);
713
- replayTraceSubmissions.delete(traceId);
714
- }
715
- return counts;
716
- }
717
- function asRecord2(value) {
718
- return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
719
- }
720
- function resolveSourceTraceId(payload) {
721
- if (typeof payload.sourceTraceId === "string") {
722
- return payload.sourceTraceId;
723
- }
724
- const rawTrace = asRecord2(payload.externalTrace) ?? asRecord2(payload.rawTrace);
725
- return typeof rawTrace?.id === "string" ? rawTrace.id : void 0;
726
- }
727
711
  function otlpValue(value) {
728
712
  if (typeof value === "boolean") {
729
713
  return { boolValue: value };
@@ -782,7 +766,36 @@ function spanToOtlp(span) {
782
766
  var SPAN_SEPARATOR_BYTES = 1;
783
767
  function encodeSpan(span) {
784
768
  const json = JSON.stringify(spanToOtlp(span));
785
- return { json, size: byteLength(json) };
769
+ return {
770
+ json,
771
+ size: byteLength(json),
772
+ ref: carrierRefs.get(span)
773
+ };
774
+ }
775
+ function trimEncodedSpan(span) {
776
+ try {
777
+ const carrier = JSON.parse(span.json);
778
+ const attribute = carrier.attributes?.find(
779
+ (entry) => entry.key === PAYLOAD_ATTRIBUTE
780
+ );
781
+ const payloadBody = attribute?.value?.stringValue;
782
+ if (!attribute?.value || payloadBody === void 0) {
783
+ return void 0;
784
+ }
785
+ const payload = JSON.parse(payloadBody);
786
+ attribute.value.stringValue = serializePayloadBody(
787
+ payload,
788
+ MAX_SPAN_CARRIER_BYTES
789
+ ).body;
790
+ const json = JSON.stringify(carrier);
791
+ return { json, size: byteLength(json) };
792
+ } catch {
793
+ return void 0;
794
+ }
795
+ }
796
+ async function prepareRequest(body) {
797
+ const prepared = encodeRequestBody(body);
798
+ return prepared instanceof Promise ? await prepared : prepared;
786
799
  }
787
800
  function requestEnvelope(first) {
788
801
  const scope = first.instrumentationScope;
@@ -840,26 +853,35 @@ async function mapWithConcurrency(items, limit, task) {
840
853
  await Promise.all(workers);
841
854
  return results;
842
855
  }
843
- var OtlpPayloadTooLargeError = class extends Error {
844
- };
845
- var OtlpPartialSuccessError = class extends Error {
846
- };
847
- function responseStatus(error) {
848
- return error instanceof BitfabError ? error.status : void 0;
849
- }
850
856
  function isRetryable(error) {
851
- const status = responseStatus(error);
852
- if (status === void 0) {
853
- return true;
857
+ return error instanceof DeliveryError && error.retryable;
858
+ }
859
+ function isOversized(error) {
860
+ return error instanceof DeliveryError && error.oversized;
861
+ }
862
+ function retryWaitMillis(error, attempt, remainingMillis) {
863
+ const requested = error instanceof DeliveryError ? error.retryAfterMs : void 0;
864
+ const affordable = remainingMillis / 2;
865
+ if (requested !== void 0) {
866
+ return requested < affordable ? requested : null;
854
867
  }
855
- return RETRYABLE_STATUSES.has(status) || status >= 500;
868
+ const backoff = Math.min(
869
+ RETRY_BASE_DELAY_MILLIS * 2 ** attempt,
870
+ RETRY_BACKOFF_CEILING_MILLIS
871
+ );
872
+ const jittered = backoff / 2 + Math.random() * (backoff / 2);
873
+ return jittered < affordable ? jittered : null;
856
874
  }
857
875
  var BitfabSpanExporter = class {
858
- constructor(directSender, maxRequestBytes, maxRequestBatchSize, exportConcurrency) {
876
+ constructor(directSender, maxRequestBytes, maxRequestBatchSize, exportConcurrency, onDelivered, exportTimeoutMillis = EXPORT_TIMEOUT_MILLIS) {
859
877
  this.directSender = directSender;
860
878
  this.maxRequestBytes = maxRequestBytes;
861
879
  this.maxRequestBatchSize = maxRequestBatchSize;
862
880
  this.exportConcurrency = exportConcurrency;
881
+ this.onDelivered = onDelivered;
882
+ this.exportTimeoutMillis = exportTimeoutMillis;
883
+ /** Epoch ms until which the server has asked this exporter to stay away. */
884
+ this.throttledUntil = 0;
863
885
  }
864
886
  export(spans, resultCallback) {
865
887
  void this.exportAsync(spans).then(
@@ -914,25 +936,51 @@ var BitfabSpanExporter = class {
914
936
  return batches;
915
937
  }
916
938
  async send(envelope, batch) {
917
- if (batch.size > this.maxRequestBytes) {
918
- logError(
919
- "a single OpenTelemetry span exceeded the configured request-size target and could not be exported"
920
- );
921
- return false;
922
- }
923
939
  try {
924
- await this.sendWithRetries(encodeRequest(envelope, batch.spans));
925
- return true;
940
+ let requestSpans = batch.spans;
941
+ let requestRawBytes = batch.size;
942
+ let alreadyTrimmed = false;
943
+ while (true) {
944
+ if (requestRawBytes <= MAX_DECOMPRESSED_REQUEST_BYTES) {
945
+ const prepared = await prepareRequest(
946
+ encodeRequest(envelope, requestSpans)
947
+ );
948
+ if (prepared.wireBytes <= this.maxRequestBytes) {
949
+ await this.sendWithRetries(prepared);
950
+ this.reportDelivered(batch.spans);
951
+ return true;
952
+ }
953
+ }
954
+ if (batch.spans.length !== 1) {
955
+ logError(
956
+ "an OpenTelemetry span batch exceeded the configured request-size target and could not be exported"
957
+ );
958
+ return false;
959
+ }
960
+ if (alreadyTrimmed) {
961
+ logError(
962
+ "a single OpenTelemetry span exceeded the configured request-size target after trimming"
963
+ );
964
+ return false;
965
+ }
966
+ const trimmed = trimEncodedSpan(batch.spans[0]);
967
+ if (!trimmed) {
968
+ logError(
969
+ "a single OpenTelemetry span exceeded the configured request-size target and could not be trimmed"
970
+ );
971
+ return false;
972
+ }
973
+ requestSpans = [trimmed];
974
+ requestRawBytes = envelope.size + trimmed.size;
975
+ alreadyTrimmed = true;
976
+ }
926
977
  } catch (error) {
927
- if (error instanceof OtlpPayloadTooLargeError) {
978
+ if (isOversized(error)) {
928
979
  logError(
929
980
  batch.spans.length === 1 ? "a single OpenTelemetry span exceeded the ingestion request limit and could not be exported" : "an OpenTelemetry span batch exceeded the ingestion request limit and could not be exported"
930
981
  );
931
982
  return false;
932
983
  }
933
- if (error instanceof OtlpPartialSuccessError) {
934
- return false;
935
- }
936
984
  logError("failed to export an OpenTelemetry span batch", error);
937
985
  return false;
938
986
  }
@@ -950,37 +998,79 @@ var BitfabSpanExporter = class {
950
998
  * the server does not yet understand. The fix is a client-supplied
951
999
  * idempotency key that ingestion dedupes on.
952
1000
  */
953
- async sendWithRetries(body) {
1001
+ /**
1002
+ * Remember a throttle the server asked for, so the requests fanned out
1003
+ * alongside this one respect it too. Delaying only the request that was
1004
+ * refused leaves the other seven in the window hitting a server that just
1005
+ * asked for room.
1006
+ */
1007
+ recordThrottle(error) {
1008
+ const requested = error instanceof DeliveryError ? error.retryAfterMs : void 0;
1009
+ if (requested !== void 0) {
1010
+ this.throttledUntil = Math.max(
1011
+ this.throttledUntil,
1012
+ Date.now() + requested
1013
+ );
1014
+ }
1015
+ }
1016
+ /**
1017
+ * Waits out an active throttle, or reports the batch undeliverable when the
1018
+ * throttle outlasts what we are willing to hold it for. Either way nothing is
1019
+ * sent while the server has asked us to stay away.
1020
+ */
1021
+ async awaitThrottle(deadline) {
1022
+ const remaining = this.throttledUntil - Date.now();
1023
+ if (remaining <= 0) {
1024
+ return;
1025
+ }
1026
+ if (remaining >= (deadline - Date.now()) / 2) {
1027
+ throw new DeliveryError(
1028
+ `OTLP ingestion is throttled for another ${remaining}ms, longer than the export budget`
1029
+ );
1030
+ }
1031
+ await delay(remaining);
1032
+ }
1033
+ async sendWithRetries(request) {
1034
+ const deadline = Date.now() + this.exportTimeoutMillis;
954
1035
  for (let attempt = 0; attempt < MAX_SEND_ATTEMPTS; attempt += 1) {
955
1036
  try {
956
- const response = await this.directSender(
957
- OTLP_TRACES_ENDPOINT,
958
- body,
959
- EXPORT_TIMEOUT_MILLIS
960
- );
961
- const partialSuccess = asRecord2(response?.partialSuccess);
962
- const rejected = partialSuccess?.rejectedSpans;
963
- if (rejected !== void 0 && rejected !== "0" && rejected !== 0) {
964
- logError(
965
- `OTLP ingestion rejected ${rejected} span(s): ${partialSuccess?.errorMessage ?? "no reason provided"}`
966
- );
967
- throw new OtlpPartialSuccessError();
968
- }
1037
+ await this.awaitThrottle(deadline);
1038
+ await this.directSender(request, Math.max(0, deadline - Date.now()));
969
1039
  return;
970
1040
  } catch (error) {
971
- if (error instanceof OtlpPartialSuccessError) {
1041
+ if (isOversized(error)) {
972
1042
  throw error;
973
1043
  }
974
- if (responseStatus(error) === 413) {
975
- throw new OtlpPayloadTooLargeError();
976
- }
1044
+ this.recordThrottle(error);
977
1045
  if (attempt === MAX_SEND_ATTEMPTS - 1 || !isRetryable(error)) {
978
1046
  throw error;
979
1047
  }
980
- await delay(RETRY_DELAY_MILLIS);
1048
+ const wait = retryWaitMillis(error, attempt, deadline - Date.now());
1049
+ if (wait === null) {
1050
+ throw error;
1051
+ }
1052
+ await delay(wait);
981
1053
  }
982
1054
  }
983
1055
  }
1056
+ /**
1057
+ * Announce the carriers a request delivered. Wrapped because a listener that
1058
+ * throws must never turn a delivered batch into a failed export.
1059
+ */
1060
+ reportDelivered(spans) {
1061
+ if (this.onDelivered === void 0) {
1062
+ return;
1063
+ }
1064
+ const refs = spans.map((span) => span.ref).filter((ref) => ref !== void 0);
1065
+ if (refs.length === 0) {
1066
+ return;
1067
+ }
1068
+ try {
1069
+ this.onDelivered(refs);
1070
+ } catch (error) {
1071
+ logError("a delivery listener threw", error);
1072
+ }
1073
+ }
984
1074
  async shutdown() {
985
1075
  }
986
1076
  async forceFlush() {
@@ -1038,7 +1128,9 @@ var OtelBatchTransport = class {
1038
1128
  options.directSender,
1039
1129
  maxRequestBytes,
1040
1130
  maxRequestBatchSize,
1041
- options.exportConcurrency ?? DEFAULT_EXPORT_CONCURRENCY
1131
+ options.exportConcurrency ?? DEFAULT_EXPORT_CONCURRENCY,
1132
+ options.onDelivered,
1133
+ options.exportTimeoutMillis ?? EXPORT_TIMEOUT_MILLIS
1042
1134
  )
1043
1135
  );
1044
1136
  this.processor = new BatchSpanProcessor(this.deliveryTracker, {
@@ -1062,8 +1154,7 @@ var OtelBatchTransport = class {
1062
1154
  this.tracer = this.provider.getTracer("bitfab", __version__);
1063
1155
  liveTransports.add(this);
1064
1156
  }
1065
- submit(operation, payload) {
1066
- recordTraceSubmission(operation, payload);
1157
+ submit(operation, payload, meta = {}) {
1067
1158
  if (this.closed) {
1068
1159
  warnOnce(
1069
1160
  "otel-submit-after-shutdown",
@@ -1072,7 +1163,10 @@ var OtelBatchTransport = class {
1072
1163
  return;
1073
1164
  }
1074
1165
  try {
1075
- const { body, dropped } = serializePayloadBody(payload);
1166
+ const { body, dropped } = serializePayloadBody(
1167
+ payload,
1168
+ MAX_COMPRESSIBLE_SPAN_CARRIER_BYTES
1169
+ );
1076
1170
  if (dropped.length > 0) {
1077
1171
  warnOnce(
1078
1172
  "otel-carrier-payload-stubbed",
@@ -1081,17 +1175,20 @@ var OtelBatchTransport = class {
1081
1175
  ].join(", ")}); they were stubbed so the span still ships, but the trace may be incomplete or not replayable.`
1082
1176
  );
1083
1177
  }
1084
- const span = this.tracer.startSpan(spanName(operation, payload), {
1178
+ const span = this.tracer.startSpan(meta.name ?? `bitfab.${operation}`, {
1085
1179
  attributes: {
1086
1180
  [OPERATION_ATTRIBUTE]: operation,
1087
1181
  [PAYLOAD_ATTRIBUTE]: body
1088
1182
  },
1089
- startTime: payloadTimestamp(payload, "started_at")
1183
+ startTime: meta.startTime
1090
1184
  });
1091
- if (hasError(payload)) {
1185
+ if (meta.ref !== void 0) {
1186
+ carrierRefs.set(span, meta.ref);
1187
+ }
1188
+ if (meta.errored === true) {
1092
1189
  span.setStatus({ code: SpanStatusCode.ERROR });
1093
1190
  }
1094
- endSpan(span, payloadTimestamp(payload, "ended_at"));
1191
+ endSpan(span, meta.endTime);
1095
1192
  } catch (error) {
1096
1193
  logError("failed to queue an OpenTelemetry span", error);
1097
1194
  }
@@ -1131,36 +1228,6 @@ var OtelBatchTransport = class {
1131
1228
  function endSpan(span, endTime) {
1132
1229
  span.end(endTime);
1133
1230
  }
1134
- function spanName(operation, payload) {
1135
- if (operation === "external_span") {
1136
- const spanData = asRecord2(asRecord2(payload.rawSpan)?.span_data);
1137
- if (typeof spanData?.name === "string") {
1138
- return spanData.name;
1139
- }
1140
- }
1141
- if (typeof payload.traceFunctionKey === "string") {
1142
- return payload.traceFunctionKey;
1143
- }
1144
- return `bitfab.${operation}`;
1145
- }
1146
- function payloadTimestamp(payload, field) {
1147
- const rawSpan = asRecord2(payload.rawSpan);
1148
- const rawTrace = asRecord2(payload.externalTrace) ?? asRecord2(payload.rawTrace);
1149
- const raw = rawSpan?.[field] ?? rawTrace?.[field];
1150
- if (typeof raw !== "string") {
1151
- return void 0;
1152
- }
1153
- const parsed = Date.parse(raw);
1154
- return Number.isNaN(parsed) ? void 0 : parsed;
1155
- }
1156
- function hasError(payload) {
1157
- const spanData = asRecord2(asRecord2(payload.rawSpan)?.span_data);
1158
- if (spanData?.error != null) {
1159
- return true;
1160
- }
1161
- const errors = payload.errors;
1162
- return Array.isArray(errors) ? errors.length > 0 : Boolean(errors);
1163
- }
1164
1231
  function createOtelTransport(options) {
1165
1232
  return new OtelBatchTransport({
1166
1233
  ...options,
@@ -1209,12 +1276,11 @@ function flushTraceTransports(timeoutMs) {
1209
1276
  function shutdownTraceTransports(timeoutMs) {
1210
1277
  return shutdownOtelTransports(timeoutMs);
1211
1278
  }
1212
- function takeReplaySpanCounts2(traceIds) {
1213
- return takeReplaySpanCounts(traceIds);
1214
- }
1215
1279
 
1216
1280
  // src/http.ts
1217
1281
  var REPLAY_DB_BRANCH_REQUEST_TIMEOUT_MS = 3e5;
1282
+ var OTLP_TRACES_ENDPOINT = "/api/sdk/otel/v1/traces";
1283
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
1218
1284
  var EXIT_FLUSH_TIMEOUT_MS = 5e3;
1219
1285
  var DEFAULT_LIFECYCLE_TIMEOUT_MS2 = 3e4;
1220
1286
  var pendingTracePromises = /* @__PURE__ */ new Set();
@@ -1274,8 +1340,101 @@ if (typeof process !== "undefined" && process.versions != null && process.versio
1274
1340
  });
1275
1341
  });
1276
1342
  }
1343
+ function readHeader(response, name) {
1344
+ try {
1345
+ return response.headers?.get(name) ?? null;
1346
+ } catch {
1347
+ return null;
1348
+ }
1349
+ }
1350
+ function parseRetryAfterMs(header) {
1351
+ const value = header?.trim();
1352
+ if (!value) {
1353
+ return void 0;
1354
+ }
1355
+ const seconds = Number(value);
1356
+ if (Number.isFinite(seconds)) {
1357
+ return seconds >= 0 ? seconds * 1e3 : void 0;
1358
+ }
1359
+ const at = Date.parse(value);
1360
+ if (Number.isNaN(at)) {
1361
+ return void 0;
1362
+ }
1363
+ return Math.max(0, at - Date.now());
1364
+ }
1365
+ function carrierMeta(operation, payload, ref) {
1366
+ return {
1367
+ ref,
1368
+ name: carrierName(operation, payload),
1369
+ startTime: payloadTimestamp(payload, "started_at"),
1370
+ endTime: payloadTimestamp(payload, "ended_at"),
1371
+ errored: payloadHasError(payload)
1372
+ };
1373
+ }
1374
+ function carrierName(operation, payload) {
1375
+ if (operation === "external_span") {
1376
+ const spanData = asPayloadRecord(
1377
+ asPayloadRecord(payload.rawSpan)?.span_data
1378
+ );
1379
+ if (typeof spanData?.name === "string") {
1380
+ return spanData.name;
1381
+ }
1382
+ }
1383
+ if (typeof payload.traceFunctionKey === "string") {
1384
+ return payload.traceFunctionKey;
1385
+ }
1386
+ return `bitfab.${operation}`;
1387
+ }
1388
+ function payloadTimestamp(payload, field) {
1389
+ const rawSpan = asPayloadRecord(payload.rawSpan);
1390
+ const rawTrace = asPayloadRecord(payload.externalTrace) ?? asPayloadRecord(payload.rawTrace);
1391
+ const raw = rawSpan?.[field] ?? rawTrace?.[field];
1392
+ if (typeof raw !== "string") {
1393
+ return void 0;
1394
+ }
1395
+ const parsed = Date.parse(raw);
1396
+ return Number.isNaN(parsed) ? void 0 : parsed;
1397
+ }
1398
+ function payloadHasError(payload) {
1399
+ const spanData = asPayloadRecord(asPayloadRecord(payload.rawSpan)?.span_data);
1400
+ if (spanData?.error != null) {
1401
+ return true;
1402
+ }
1403
+ const errors = payload.errors;
1404
+ return Array.isArray(errors) ? errors.length > 0 : Boolean(errors);
1405
+ }
1406
+ function asPayloadRecord(value) {
1407
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
1408
+ }
1409
+ function carrierRef(payload) {
1410
+ const traceId = sourceTraceIdOf(payload);
1411
+ if (traceId === void 0) {
1412
+ return void 0;
1413
+ }
1414
+ const rawSpan = payload.rawSpan;
1415
+ if (rawSpan === void 0) {
1416
+ return { traceId };
1417
+ }
1418
+ const spanId = rawSpan?.id;
1419
+ return {
1420
+ traceId,
1421
+ spanId: typeof spanId === "string" ? spanId : `submission-${++carrierSeq}`
1422
+ };
1423
+ }
1424
+ function sourceTraceIdOf(payload) {
1425
+ if (typeof payload.sourceTraceId === "string") {
1426
+ return payload.sourceTraceId;
1427
+ }
1428
+ const rawTrace = payload.externalTrace ?? payload.rawTrace;
1429
+ const id = rawTrace?.id;
1430
+ return typeof id === "string" ? id : void 0;
1431
+ }
1432
+ var carrierSeq = 0;
1277
1433
  var HttpClient = class {
1278
1434
  constructor(config) {
1435
+ // Only traces a caller asked about are tracked, so ordinary tracing stores
1436
+ // nothing here.
1437
+ this.traceDeliveries = /* @__PURE__ */ new Map();
1279
1438
  // Deferred span work owned by THIS client. The module-global set backs the
1280
1439
  // process-wide `flushTraces()` and the exit hook, but per-client lifecycle
1281
1440
  // must not wait on another client's slow finalize: a false `close()` failure
@@ -1311,13 +1470,131 @@ var HttpClient = class {
1311
1470
  }
1312
1471
  if (!this.traceTransport) {
1313
1472
  this.traceTransport = createTraceTransport({
1314
- directSender: (endpoint, body, timeoutMs) => this.sendEncoded(endpoint, body, {
1315
- timeout: timeoutMs
1316
- })
1473
+ directSender: (request, timeoutMs) => this.deliverCarriers(request, timeoutMs),
1474
+ onDelivered: (refs) => this.recordDeliveredCarriers(refs)
1317
1475
  });
1318
1476
  }
1319
1477
  return this.traceTransport;
1320
1478
  }
1479
+ /**
1480
+ * Post one encoded batch and decide what the server's answer means, so the
1481
+ * transport never reads a response. Rejections and permanent statuses come
1482
+ * back as a non-retryable {@link DeliveryError}; anything the server might
1483
+ * still accept on a second try comes back retryable.
1484
+ */
1485
+ async deliverCarriers(request, timeoutMs) {
1486
+ let response;
1487
+ try {
1488
+ response = await this.sendPrepared(
1489
+ OTLP_TRACES_ENDPOINT,
1490
+ request,
1491
+ { timeout: timeoutMs }
1492
+ );
1493
+ } catch (error) {
1494
+ const status = error instanceof BitfabError ? error.status : void 0;
1495
+ if (status === void 0) {
1496
+ throw new DeliveryError(`OTLP ingestion failed: ${String(error)}`, {
1497
+ retryable: true
1498
+ });
1499
+ }
1500
+ throw new DeliveryError(`OTLP ingestion failed with HTTP ${status}`, {
1501
+ retryable: RETRYABLE_STATUSES.has(status),
1502
+ oversized: status === 413,
1503
+ ...error instanceof BitfabError && error.retryAfterMs !== void 0 ? { retryAfterMs: error.retryAfterMs } : {}
1504
+ });
1505
+ }
1506
+ const partialSuccess = asPayloadRecord(response?.partialSuccess);
1507
+ const rejected = partialSuccess?.rejectedSpans;
1508
+ if (rejected !== void 0 && rejected !== "0" && rejected !== 0) {
1509
+ throw new DeliveryError(
1510
+ `OTLP ingestion rejected ${rejected} span(s): ${partialSuccess?.errorMessage ?? "no reason provided"}`
1511
+ );
1512
+ }
1513
+ }
1514
+ /**
1515
+ * Start tracking delivery for `traceIds`. Nothing is recorded for a trace
1516
+ * that was never tracked, so ordinary tracing costs no bookkeeping at all.
1517
+ */
1518
+ trackTraceDeliveries(traceIds) {
1519
+ for (const traceId of traceIds) {
1520
+ if (!this.traceDeliveries.has(traceId)) {
1521
+ this.traceDeliveries.set(traceId, {
1522
+ submittedSpanIds: /* @__PURE__ */ new Set(),
1523
+ ackedSpanIds: /* @__PURE__ */ new Set(),
1524
+ closed: false,
1525
+ closingAcked: false
1526
+ });
1527
+ }
1528
+ }
1529
+ }
1530
+ /** Whether any tracked trace has had its closing carrier submitted. */
1531
+ hasClosedDeliveries(traceIds) {
1532
+ return traceIds.some((traceId) => this.traceDeliveries.get(traceId)?.closed);
1533
+ }
1534
+ /**
1535
+ * Report what each tracked trace submitted and whether the server confirmed
1536
+ * it, and stop tracking them. Every id passed is freed, so a caller cannot
1537
+ * leak a record for a trace that never closed.
1538
+ *
1539
+ * `delivered` is only meaningful once a flush has settled: acks land before
1540
+ * an export resolves, so a flush that reported success has already collected
1541
+ * every ack it is going to collect.
1542
+ */
1543
+ takeTraceDeliveries(traceIds) {
1544
+ const reports = {};
1545
+ for (const traceId of traceIds) {
1546
+ const delivery = this.traceDeliveries.get(traceId);
1547
+ if (delivery === void 0) {
1548
+ continue;
1549
+ }
1550
+ this.traceDeliveries.delete(traceId);
1551
+ reports[traceId] = {
1552
+ spanCount: delivery.submittedSpanIds.size,
1553
+ closed: delivery.closed,
1554
+ delivered: delivery.closingAcked && [...delivery.submittedSpanIds].every(
1555
+ (spanId) => delivery.ackedSpanIds.has(spanId)
1556
+ )
1557
+ };
1558
+ }
1559
+ return reports;
1560
+ }
1561
+ /** Build a carrier's meta and record what it adds to its trace's expected set. */
1562
+ recordedMeta(operation, payload, ref) {
1563
+ this.recordSubmittedCarrier(ref);
1564
+ return carrierMeta(operation, payload, ref);
1565
+ }
1566
+ recordSubmittedCarrier(ref) {
1567
+ if (ref === void 0) {
1568
+ return;
1569
+ }
1570
+ const delivery = this.traceDeliveries.get(ref.traceId);
1571
+ if (delivery === void 0) {
1572
+ return;
1573
+ }
1574
+ if (ref.spanId === void 0) {
1575
+ delivery.closed = true;
1576
+ } else {
1577
+ delivery.submittedSpanIds.add(ref.spanId);
1578
+ }
1579
+ }
1580
+ /**
1581
+ * Ingestion commits every carrier in a request before it answers, so a
1582
+ * delivered ref is proof its row exists: the same fact the replay status
1583
+ * endpoint would report, already in hand.
1584
+ */
1585
+ recordDeliveredCarriers(refs) {
1586
+ for (const ref of refs) {
1587
+ const delivery = this.traceDeliveries.get(ref.traceId);
1588
+ if (delivery === void 0) {
1589
+ continue;
1590
+ }
1591
+ if (ref.spanId === void 0) {
1592
+ delivery.closingAcked = true;
1593
+ } else {
1594
+ delivery.ackedSpanIds.add(ref.spanId);
1595
+ }
1596
+ }
1597
+ }
1321
1598
  /**
1322
1599
  * Track deferred span work so this client's own lifecycle waits for it, and
1323
1600
  * so the process-wide flush and exit hook do too.
@@ -1398,13 +1675,16 @@ var HttpClient = class {
1398
1675
  * same data twice.
1399
1676
  */
1400
1677
  async sendEncoded(endpoint, body, options) {
1678
+ const prepared = encodeRequestBody(body);
1679
+ const encoded = prepared instanceof Promise ? await prepared : prepared;
1680
+ return this.sendPrepared(endpoint, encoded, options);
1681
+ }
1682
+ async sendPrepared(endpoint, encoded, options) {
1401
1683
  const url = `${this.serviceUrl}${endpoint}`;
1402
1684
  const timeout = options?.timeout ?? this.timeout;
1403
1685
  const method = options?.method ?? "POST";
1404
1686
  const controller = new AbortController();
1405
1687
  const timeoutId = setTimeout(() => controller.abort(), timeout);
1406
- const prepared = encodeRequestBody(body);
1407
- const encoded = prepared instanceof Promise ? await prepared : prepared;
1408
1688
  const headers = {
1409
1689
  "Content-Type": "application/json",
1410
1690
  Authorization: `Bearer ${this.resolveApiKey() ?? ""}`
@@ -1424,7 +1704,8 @@ var HttpClient = class {
1424
1704
  throw new BitfabError(
1425
1705
  `HTTP ${response.status}: ${errorText.slice(0, 500)}`,
1426
1706
  void 0,
1427
- response.status
1707
+ response.status,
1708
+ parseRetryAfterMs(readHeader(response, "retry-after"))
1428
1709
  );
1429
1710
  }
1430
1711
  const result = await response.json();
@@ -1510,11 +1791,12 @@ var HttpClient = class {
1510
1791
  * the OTLP carrier has no path to carry it.
1511
1792
  */
1512
1793
  sendInternalTrace(functionId, payload) {
1513
- this.getTraceTransport()?.submit("internal_trace", {
1514
- ...payload,
1515
- functionId,
1516
- sdkVersion: __version__
1517
- });
1794
+ const body = { ...payload, functionId, sdkVersion: __version__ };
1795
+ this.getTraceTransport()?.submit(
1796
+ "internal_trace",
1797
+ body,
1798
+ carrierMeta("internal_trace", body, void 0)
1799
+ );
1518
1800
  }
1519
1801
  /**
1520
1802
  * Queue an external span (from withSpan wrapper or OpenAI tracing) onto this
@@ -1523,10 +1805,11 @@ var HttpClient = class {
1523
1805
  * promise.
1524
1806
  */
1525
1807
  sendExternalSpan(payload) {
1526
- this.getTraceTransport()?.submit("external_span", {
1527
- ...payload,
1528
- sdkVersion: __version__
1529
- });
1808
+ this.getTraceTransport()?.submit(
1809
+ "external_span",
1810
+ { ...payload, sdkVersion: __version__ },
1811
+ this.recordedMeta("external_span", payload, carrierRef(payload))
1812
+ );
1530
1813
  }
1531
1814
  /**
1532
1815
  * Queue an external trace completion (from OpenAI tracing) onto this
@@ -1535,10 +1818,15 @@ var HttpClient = class {
1535
1818
  * server-authoritative barrier in `replay.ts`, not by awaiting this call.
1536
1819
  */
1537
1820
  sendExternalTrace(payload) {
1538
- this.getTraceTransport()?.submit("external_trace", {
1539
- ...payload,
1540
- sdkVersion: __version__
1541
- });
1821
+ this.getTraceTransport()?.submit(
1822
+ "external_trace",
1823
+ { ...payload, sdkVersion: __version__ },
1824
+ this.recordedMeta(
1825
+ "external_trace",
1826
+ payload,
1827
+ payload.completed === true ? carrierRef(payload) : void 0
1828
+ )
1829
+ );
1542
1830
  }
1543
1831
  /**
1544
1832
  * Partial update of an existing trace identified by its Bitfab trace ID.
@@ -1767,8 +2055,8 @@ function fallbackUuidV4() {
1767
2055
 
1768
2056
  // src/serialize.ts
1769
2057
  import superjson from "superjson";
1770
- var MAX_SERIALIZED_BYTES = MAX_SPAN_CARRIER_BYTES;
1771
- var MAX_FRAMEWORK_SERIALIZED_BYTES = MAX_SPAN_CARRIER_BYTES;
2058
+ var MAX_SERIALIZED_BYTES = MAX_COMPRESSIBLE_SPAN_CARRIER_BYTES;
2059
+ var MAX_FRAMEWORK_SERIALIZED_BYTES = MAX_COMPRESSIBLE_SPAN_CARRIER_BYTES;
1772
2060
  function describeValue(value) {
1773
2061
  try {
1774
2062
  const ctorName = value?.constructor?.name;
@@ -2216,15 +2504,29 @@ async function waitForReplayPersistence(httpClient, testRunId, replayedTraceIds)
2216
2504
  REPLAY_PERSISTENCE_TIMEOUT_MS
2217
2505
  );
2218
2506
  if (!deferredSettled) {
2507
+ httpClient.takeTraceDeliveries(replayedTraceIds);
2219
2508
  throw new BitfabError(
2220
2509
  `Replay could not settle deferred span work before the deadline, so the expected span counts are incomplete (testRunId ${testRunId}).`
2221
2510
  );
2222
2511
  }
2223
- const expectedSpanCounts = takeReplaySpanCounts2(replayedTraceIds);
2224
- if (Object.keys(expectedSpanCounts).length === 0) {
2512
+ if (!httpClient.hasClosedDeliveries(replayedTraceIds)) {
2513
+ httpClient.takeTraceDeliveries(replayedTraceIds);
2225
2514
  return;
2226
2515
  }
2227
2516
  const flushed = await flushTraces(REPLAY_PERSISTENCE_TIMEOUT_MS);
2517
+ const deliveries = httpClient.takeTraceDeliveries(replayedTraceIds);
2518
+ const expectedSpanCounts = {};
2519
+ let allDelivered = true;
2520
+ for (const [traceId, delivery] of Object.entries(deliveries)) {
2521
+ if (!delivery.closed) {
2522
+ continue;
2523
+ }
2524
+ expectedSpanCounts[traceId] = delivery.spanCount;
2525
+ allDelivered = allDelivered && delivery.delivered;
2526
+ }
2527
+ if (allDelivered) {
2528
+ return;
2529
+ }
2228
2530
  const deadline = Date.now() + REPLAY_PERSISTENCE_TIMEOUT_MS;
2229
2531
  let missing = Object.keys(expectedSpanCounts).length;
2230
2532
  while (true) {
@@ -2333,6 +2635,7 @@ async function replay(httpClient, serviceUrl, traceFunctionKey, fn, options, reg
2333
2635
  ...registeredOverrides
2334
2636
  ];
2335
2637
  const replayedTraceIds = serverItems.map(() => randomUuid());
2638
+ httpClient.trackTraceDeliveries(replayedTraceIds);
2336
2639
  const tasks = serverItems.map(
2337
2640
  (serverItem, index) => () => processItem(
2338
2641
  httpClient,
@@ -2547,6 +2850,7 @@ export {
2547
2850
  ReplayError,
2548
2851
  DbBranchReplayError,
2549
2852
  serializeReplayResult,
2853
+ waitForReplayPersistence,
2550
2854
  replay
2551
2855
  };
2552
- //# sourceMappingURL=chunk-ENOQ2K2H.js.map
2856
+ //# sourceMappingURL=chunk-QUX6N7ON.js.map