@hue-run/sdk 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/transport.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { gzip } from "node:zlib";
2
3
  import { ExportResultCode } from "@opentelemetry/core";
3
4
  import { createOtlpNetworkExportDelegate, OTLPExporterBase, OTLPExporterError, } from "@opentelemetry/otlp-exporter-base";
@@ -6,7 +7,8 @@ import { LogsExporterMetricsHelper, ProtobufLogsSerializer, ProtobufTraceSeriali
6
7
  import { BatchSpanProcessor, } from "@opentelemetry/sdk-trace";
7
8
  import { BatchLogRecordProcessor, } from "@opentelemetry/sdk-logs";
8
9
  import { isInsecureOrigin, REDACTION_CONTEXT_UNITS, validateOptions } from "./config.js";
9
- import { BLOBS_KEY, MAX_BLOB_ENTRIES, blobEntry, countFallback, BlobUploader, FALLBACK_MESSAGES, fallbackAttributes, fallbackRecord, MAX_HELD_BLOB_BYTES, newTally, mediaKey, offloadAttributes, offloadRecord, uploadBudgetMillis, } from "./blobs.js";
10
+ import { BLOBS_KEY, MAX_BLOB_ENTRIES, blobEntry, countFallback, BlobUploader, FALLBACK_MESSAGES, fallbackAttributes, fallbackRecord, heldBytesOf, LATE_FALLBACK_MESSAGES, MAX_HELD_BLOB_BYTES, mergeWhenSettled, newTally, mediaKey, offloadAttributes, offloadRecord, sizeOf, uploadBudgetMillis, } from "./blobs.js";
11
+ import { isByteSource } from "./large-media.js";
10
12
  import { advertisedLimits, BATCH_TARGET_BYTES, DEFAULT_RECEIVER_LIMITS, fitsWithoutCompression, } from "./limits.js";
11
13
  import { announcesLiveSpan, LIVE_SPAN_INTERVAL_MILLIS, MAX_LIVE_SPANS, pendingPlaceholder, PLACEHOLDERS_HEADER, withoutPlaceholderMarkers, } from "./live-spans.js";
12
14
  import { estimateRecordBytes, spanSource } from "./safety.js";
@@ -108,9 +110,11 @@ function mediaEntry(file) {
108
110
  * them, reported like any refused request.
109
111
  */
110
112
  export const MAX_RATE_LIMIT_HOLD_MILLIS = 60_000;
111
- /** How long one export may take with requests of `timeoutMillis` each: its uploads of values over
113
+ /** How long one export may take with requests of `timeoutMillis` each: hashing its values over
112
114
  * the inline limit, its requests, and a hold for a rate-limited receiver on top. Processors and
113
- * providers wait this long for a drain. */
115
+ * providers wait this long for a drain. A drain also waits for the uploads its exports queued,
116
+ * which run beside the requests and end within six timeouts and a minute of being queued, so they
117
+ * fit within the same budget. */
114
118
  export function exportBudgetMillis(timeoutMillis) {
115
119
  return uploadBudgetMillis(timeoutMillis) + timeoutMillis * 16 + 1000 + MAX_RATE_LIMIT_HOLD_MILLIS;
116
120
  }
@@ -194,7 +198,11 @@ export class HueTransport {
194
198
  logExporter;
195
199
  closed = false;
196
200
  shutdownPromise;
197
- flushPromise;
201
+ /** The latest flush's send of its records; the next flush's send starts after it. */
202
+ sendPromise;
203
+ /** Set while a flush sends its records: the processors leave the uploads to that flush, which
204
+ * waits for them after its send, so no flush's send waits behind another's uploads. */
205
+ sendingRecords = new AsyncLocalStorage();
198
206
  // Open spans not yet announced. Each is considered once, at the next tick after it starts.
199
207
  live = new Set();
200
208
  liveTimer;
@@ -218,11 +226,17 @@ export class HueTransport {
218
226
  admissionDigests = new WeakMap();
219
227
  // The span's own messages each admitted span inspected, or left to export to inspect.
220
228
  admissionInspected = new WeakMap();
229
+ // The span's own values each admitted span held whole past the inspection bounds.
230
+ admissionLarge = new WeakMap();
221
231
  /** Uploads values over the inline limit apart from their spans; absent when disabled. */
222
232
  uploader;
223
233
  /** Bytes of values over the inline limit queued spans hold whole for upload, and each span's. */
224
234
  heldBytes = 0;
225
235
  held = new WeakMap();
236
+ /** Bytes of each record's charge (its own held values and its staged files) that its queued
237
+ * uploads took over before the record was finished: charged to the uploads alone until they
238
+ * settle or the record is finished. */
239
+ heldByUploads = new WeakMap();
226
240
  mediaMemo = new MediaDigestMemo();
227
241
  // Context identity also matches safe API wrappers to the provider's ended span. Abandoned
228
242
  // spans retain no span objects, and share the 2048-span / 128 MiB holding bounds.
@@ -239,15 +253,23 @@ export class HueTransport {
239
253
  });
240
254
  uploaded = 0;
241
255
  uploadFallbacks = 0;
256
+ /** Each export's queued uploads, until they all settle and are reported. */
257
+ uploadReports = new Set();
258
+ /** Settles once every upload report registered so far has: each drain awaits this one promise,
259
+ * so a drain costs the same however many uploads are pending. */
260
+ uploadsSettled = Promise.resolve();
242
261
  constructor(options) {
243
262
  this.options = validateOptions(options);
244
263
  Object.defineProperty(this, "options", { enumerable: false });
245
264
  this.liveSpans = this.options.enabled !== false && this.options.liveSpans;
246
265
  this.announcesLiveSpans = this.liveSpans;
247
266
  this.traceExporter = new ReportingExporter(this, "traces", ProtobufTraceSerializer, TraceExporterMetricsHelper, (span, cache, offload) => {
248
- // Export inspects only the messages admission inspected within the record's allowance.
249
- if (offload)
267
+ // Export inspects only the messages admission inspected within the record's allowance,
268
+ // and reads in place the values it held whole past the inspection bounds.
269
+ if (offload) {
250
270
  offload.inspected = this.admissionInspected.get(span);
271
+ offload.large = this.admissionLarge.get(span);
272
+ }
251
273
  return redactSpan(span, this.options, cache, {
252
274
  valueBytes: this.limits.valueBytes,
253
275
  cut: this.admissionCuts.get(span),
@@ -308,7 +330,7 @@ export class HueTransport {
308
330
  if (!queued) {
309
331
  const tally = newTally();
310
332
  for (const file of this.mediaAttachments(span))
311
- if (file.bytes.byteLength)
333
+ if (sizeOf(file.bytes))
312
334
  countFallback(tally, "budget", span.spanContext().traceId);
313
335
  this.reportOffload(tally);
314
336
  this.releaseMedia(span);
@@ -327,10 +349,25 @@ export class HueTransport {
327
349
  this.issue("traces", "invalid", 1, "Telemetry processor could not accept a record", undefined, traceIdsOf([span]));
328
350
  }
329
351
  },
330
- forceFlush: () => spans.forceFlush(),
331
- shutdown: () => {
352
+ // The spans are sent first, then the uploads queued so far are waited for, unless a
353
+ // flush sends them: that flush waits for the uploads after its send.
354
+ forceFlush: async () => {
355
+ try {
356
+ await spans.forceFlush();
357
+ }
358
+ finally {
359
+ if (!this.sendingRecords.getStore())
360
+ await this.drainUploads();
361
+ }
362
+ },
363
+ shutdown: async () => {
332
364
  this.stopLiveSpans();
333
- return spans.shutdown();
365
+ try {
366
+ await spans.shutdown();
367
+ }
368
+ finally {
369
+ await this.drainUploads();
370
+ }
334
371
  },
335
372
  };
336
373
  this.logRecordProcessor = {
@@ -361,8 +398,23 @@ export class HueTransport {
361
398
  this.issue("logs", "invalid", 1, "Telemetry processor could not accept a record", undefined, traceIdsOf([log]));
362
399
  }
363
400
  },
364
- forceFlush: () => logs.forceFlush(),
365
- shutdown: () => logs.shutdown(),
401
+ forceFlush: async () => {
402
+ try {
403
+ await logs.forceFlush();
404
+ }
405
+ finally {
406
+ if (!this.sendingRecords.getStore())
407
+ await this.drainUploads();
408
+ }
409
+ },
410
+ shutdown: async () => {
411
+ try {
412
+ await logs.shutdown();
413
+ }
414
+ finally {
415
+ await this.drainUploads();
416
+ }
417
+ },
366
418
  };
367
419
  }
368
420
  /**
@@ -394,8 +446,8 @@ export class HueTransport {
394
446
  const hold = !advisory && this.holdsValues()
395
447
  ? {
396
448
  valueBytes: this.limits.valueBytes,
397
- budget: Math.max(0, MAX_HELD_BLOB_BYTES - this.heldBytes),
398
- first: this.heldBytes === 0,
449
+ budget: this.heldRoom(),
450
+ first: this.heldBytes === 0 && !this.uploader?.pendingBytes(),
399
451
  }
400
452
  : undefined;
401
453
  const snapshot = signal === "traces"
@@ -407,6 +459,8 @@ export class HueTransport {
407
459
  this.admissionDigests.set(snapshot.record, snapshot.digested);
408
460
  if (signal === "traces")
409
461
  this.admissionInspected.set(snapshot.record, snapshot.inspected);
462
+ if (snapshot.large.size)
463
+ this.admissionLarge.set(snapshot.record, snapshot.large);
410
464
  if (snapshot.held) {
411
465
  this.heldBytes += snapshot.held;
412
466
  this.held.set(snapshot.record, snapshot.held);
@@ -526,6 +580,12 @@ export class HueTransport {
526
580
  this.heldBytes -= held;
527
581
  this.held.delete(record);
528
582
  }
583
+ // Released whole above: the part its uploads took over stays charged to them.
584
+ const taken = this.heldByUploads.get(record);
585
+ if (taken) {
586
+ this.heldBytes += taken;
587
+ this.heldByUploads.delete(record);
588
+ }
529
589
  }
530
590
  }
531
591
  /** @internal The same bounded digest memo is used by explicit and inline media. */
@@ -534,7 +594,7 @@ export class HueTransport {
534
594
  }
535
595
  /** @internal Retain a defensive copy within the existing held-value budget. */
536
596
  stageMedia(span, file) {
537
- if (this.closed || !this.offloadsValues() || file?.bytes.byteLength === 0)
597
+ if (this.closed || !this.offloadsValues() || (file && sizeOf(file.bytes) === 0))
538
598
  return true;
539
599
  if (!file) {
540
600
  const tally = newTally();
@@ -552,16 +612,18 @@ export class HueTransport {
552
612
  const files = this.media.get(identity) ?? [];
553
613
  if (files.length >= 64 ||
554
614
  (!this.media.has(identity) && this.media.size >= 2048) ||
555
- this.heldBytes + file.bytes.byteLength > MAX_HELD_BLOB_BYTES) {
615
+ heldBytesOf(file.bytes) > this.heldRoom()) {
556
616
  this.uploadFallbacks++;
557
617
  this.issue("traces", "warning", 1, "File bytes did not fit the SDK's upload budget; their hue.file metadata was recorded", undefined, [span.spanContext().traceId]);
558
618
  return false;
559
619
  }
560
- const bytes = Uint8Array.from(file.bytes);
620
+ // A file read from disk as it is uploaded holds no bytes; bytes are copied, so a later change
621
+ // to the caller's buffer cannot alter what was recorded.
622
+ const bytes = isByteSource(file.bytes) ? file.bytes : Uint8Array.from(file.bytes);
561
623
  files.push({ ...file, bytes });
562
624
  this.media.set(identity, files);
563
625
  this.ownMedia(span, identity);
564
- this.heldBytes += bytes.byteLength;
626
+ this.heldBytes += heldBytesOf(bytes);
565
627
  return true;
566
628
  }
567
629
  /**
@@ -579,7 +641,7 @@ export class HueTransport {
579
641
  const kept = previous.filter((file) => {
580
642
  if (file.attribute !== attribute)
581
643
  return true;
582
- this.heldBytes -= file.bytes.byteLength;
644
+ this.heldBytes -= heldBytesOf(file.bytes);
583
645
  return false;
584
646
  });
585
647
  if (kept.length)
@@ -589,7 +651,7 @@ export class HueTransport {
589
651
  }
590
652
  const tally = newTally();
591
653
  for (const file of files) {
592
- if (!file.bytes.byteLength)
654
+ if (!sizeOf(file.bytes))
593
655
  continue;
594
656
  const refused = this.uploader?.refusal();
595
657
  if (!this.holdsValues() || refused) {
@@ -600,14 +662,14 @@ export class HueTransport {
600
662
  const staged = this.media.get(identity) ?? [];
601
663
  if (staged.length >= MAX_BLOB_ENTRIES ||
602
664
  (!this.media.has(identity) && this.media.size >= 2048) ||
603
- this.heldBytes + file.bytes.byteLength > MAX_HELD_BLOB_BYTES) {
665
+ heldBytesOf(file.bytes) > this.heldRoom()) {
604
666
  countFallback(tally, "budget", traceId);
605
667
  continue;
606
668
  }
607
669
  // Attachment bytes are the attachment's own copy: held as they are.
608
670
  staged.push({ ...file, attribute });
609
671
  this.media.set(identity, staged);
610
- this.heldBytes += file.bytes.byteLength;
672
+ this.heldBytes += heldBytesOf(file.bytes);
611
673
  this.ownMedia(span, identity);
612
674
  }
613
675
  if (tally.fallbacks.size)
@@ -624,7 +686,7 @@ export class HueTransport {
624
686
  const tally = newTally();
625
687
  const staged = [];
626
688
  for (const file of files) {
627
- if (!file.bytes.byteLength)
689
+ if (!sizeOf(file.bytes))
628
690
  continue;
629
691
  const refused = this.uploader?.refusal();
630
692
  if (!this.holdsValues() || refused)
@@ -651,8 +713,8 @@ export class HueTransport {
651
713
  if (!pending)
652
714
  return;
653
715
  this.logParts.delete(body);
654
- const size = pending.files.reduce((total, file) => total + file.bytes.byteLength, 0);
655
- if (this.heldBytes + size > MAX_HELD_BLOB_BYTES) {
716
+ const size = pending.files.reduce((total, file) => total + heldBytesOf(file.bytes), 0);
717
+ if (size > this.heldRoom()) {
656
718
  const tally = newTally();
657
719
  for (const _file of pending.files)
658
720
  countFallback(tally, "budget", pending.traceId);
@@ -663,6 +725,44 @@ export class HueTransport {
663
725
  this.heldBytes += size;
664
726
  this.held.set(queued, (this.held.get(queued) ?? 0) + size);
665
727
  }
728
+ /** What the held-value budget has room for: values queued records hold and bytes unsettled
729
+ * uploads hold share it. */
730
+ heldRoom() {
731
+ return Math.max(0, MAX_HELD_BLOB_BYTES - this.heldBytes - (this.uploader?.pendingBytes() ?? 0));
732
+ }
733
+ /** An upload `record`'s export queued, as it is queued: flushes and the processors wait for it
734
+ * from now on, and the `shared` bytes it holds of the record's own are charged to the upload
735
+ * alone, so the held-value budget never counts them twice while the export goes on. A charge
736
+ * `released` before the record is finished is handed back to the record, which still holds
737
+ * them. */
738
+ uploadQueued(record, settled, shared, released) {
739
+ this.awaitUploads(settled);
740
+ const identity = this.mediaIdentity(record);
741
+ const staged = identity
742
+ ? (this.media.get(identity) ?? []).reduce((total, file) => total + heldBytesOf(file.bytes), 0)
743
+ : 0;
744
+ const taken = this.heldByUploads.get(record) ?? 0;
745
+ const moved = Math.min(shared, (this.held.get(record) ?? 0) + staged - taken);
746
+ if (moved <= 0)
747
+ return;
748
+ this.heldBytes -= moved;
749
+ this.heldByUploads.set(record, taken + moved);
750
+ // Held weakly: a pending upload never keeps its finished record alive.
751
+ const queued = new WeakRef(record);
752
+ const handBack = () => {
753
+ const owner = queued.deref();
754
+ const still = owner && this.heldByUploads.get(owner);
755
+ if (!owner || !still)
756
+ return;
757
+ const back = Math.min(moved, still);
758
+ this.heldBytes += back;
759
+ if (still > back)
760
+ this.heldByUploads.set(owner, still - back);
761
+ else
762
+ this.heldByUploads.delete(owner);
763
+ };
764
+ void released.then(handBack, handBack);
765
+ }
666
766
  /** Registers a span whose staged files are released if it is never exported. */
667
767
  ownMedia(span, identity) {
668
768
  if (this.mediaOwners.has(identity))
@@ -714,7 +814,7 @@ export class HueTransport {
714
814
  if (!files)
715
815
  return;
716
816
  for (const file of files)
717
- this.heldBytes -= file.bytes.byteLength;
817
+ this.heldBytes -= heldBytesOf(file.bytes);
718
818
  this.media.delete(identity);
719
819
  }
720
820
  sweepMedia() {
@@ -753,11 +853,19 @@ export class HueTransport {
753
853
  return this.offloadsValues() && this.uploader?.available() === true;
754
854
  }
755
855
  /** @internal Exporter callback: a redacted span with the values its redaction left to the
756
- * upload step placed: uploaded and listed under `hue.blobs`, or cut as before. Never rejects. */
757
- async offloadSpan(span, collector, upload, tally) {
856
+ * upload step placed: listed under `hue.blobs` with their uploads queued (each added to `late`
857
+ * once it settles, and charged in place of the `queued` record it came from), or cut as before.
858
+ * Never rejects. */
859
+ async offloadSpan(span, collector, upload, tally, late, queued) {
758
860
  // A record without a readable context cannot name its trace: its values are cut.
759
861
  const traceId = recordTraceId(span);
760
862
  const valueBytes = this.limits.valueBytes;
863
+ // A record placed as a fallback counts its values there: its queued uploads are not added.
864
+ const own = {
865
+ tally: newTally(),
866
+ settled: [],
867
+ queued: (settled, shared, released) => this.uploadQueued(queued, settled, shared, released),
868
+ };
761
869
  try {
762
870
  if (!this.uploader)
763
871
  throw new Error("Uploads are unavailable");
@@ -768,7 +876,10 @@ export class HueTransport {
768
876
  upload,
769
877
  tally,
770
878
  memo: this.mediaMemo,
879
+ background: own,
771
880
  });
881
+ if (own.settled.length)
882
+ late.settled.push(mergeWhenSettled(late.tally, own.tally, own.settled));
772
883
  return { ...span, attributes: attributes };
773
884
  }
774
885
  catch {
@@ -779,10 +890,11 @@ export class HueTransport {
779
890
  }
780
891
  }
781
892
  /** @internal Exporter callback: a redacted log record with the media its redaction left to
782
- * the upload step placed: its attributes' and its body's files uploaded and listed under its own
783
- * `hue.blobs` (keys `<attribute>#<pointer>`, `body#<pointer>`), under the log record's trace;
784
- * a file not uploaded keeps its bytes inline, and the record is cut as before. Never rejects. */
785
- async offloadLog(log, collector, upload, tally) {
893
+ * the upload step placed: its attributes' and its body's files listed under its own `hue.blobs`
894
+ * (keys `<attribute>#<pointer>`, `body#<pointer>`) with their uploads queued under the log
895
+ * record's trace (charged in place of the `queued` record); a file refused keeps its bytes
896
+ * inline, and the record is cut as before. Never rejects. */
897
+ async offloadLog(log, collector, upload, tally, late, queued) {
786
898
  const traceId = log.spanContext?.traceId ?? "";
787
899
  const valueBytes = this.limits.valueBytes;
788
900
  const placed = (result) => ({
@@ -790,29 +902,39 @@ export class HueTransport {
790
902
  attributes: result.attributes,
791
903
  ...(collector.body ? { body: result.body } : {}),
792
904
  });
905
+ const own = {
906
+ tally: newTally(),
907
+ settled: [],
908
+ queued: (settled, shared, released) => this.uploadQueued(queued, settled, shared, released),
909
+ };
793
910
  try {
794
911
  if (!this.uploader)
795
912
  throw new Error("Uploads are unavailable");
796
- return placed(await offloadRecord(log.attributes, collector, {
913
+ const result = await offloadRecord(log.attributes, collector, {
797
914
  uploader: this.uploader,
798
915
  traceId,
799
916
  valueBytes,
800
917
  upload,
801
918
  tally,
802
919
  memo: this.mediaMemo,
803
- }));
920
+ background: own,
921
+ });
922
+ if (own.settled.length)
923
+ late.settled.push(mergeWhenSettled(late.tally, own.tally, own.settled));
924
+ return placed(result);
804
925
  }
805
926
  catch {
806
927
  return placed(fallbackRecord(log.attributes, collector, valueBytes, tally, traceId, this.mediaMemo));
807
928
  }
808
929
  }
809
930
  /** @internal Exporter callback: counts an export's uploads and reports each kind of fallback
810
- * once, as a warning naming its traces: the values were exported, cut as before. */
811
- reportOffload(tally, signal = "traces") {
931
+ * once, as a warning naming its traces: the values were exported cut as before, or, `late`,
932
+ * their uploads failed after their records listed them. */
933
+ reportOffload(tally, signal = "traces", late = false) {
812
934
  this.uploaded += tally.uploaded;
813
935
  for (const [reason, { count, traceIds }] of tally.fallbacks) {
814
936
  this.uploadFallbacks += count;
815
- this.issue(signal, "warning", count, FALLBACK_MESSAGES[reason], undefined, traceIds.size && traceIds.size <= MAX_ISSUE_TRACE_IDS ? [...traceIds] : undefined);
937
+ this.issue(signal, "warning", count, (late ? LATE_FALLBACK_MESSAGES : FALLBACK_MESSAGES)[reason], undefined, traceIds.size && traceIds.size <= MAX_ISSUE_TRACE_IDS ? [...traceIds] : undefined);
816
938
  }
817
939
  }
818
940
  /** @internal Exporter callback: counts records the collector acknowledged. */
@@ -880,8 +1002,9 @@ export class HueTransport {
880
1002
  }
881
1003
  /** @internal Ends every rate-limit hold now and refuses new ones, once a shutdown's caller has
882
1004
  * stopped waiting: the held records are reported lost to the rate limit, and no timer of theirs
883
- * keeps the process running for the receiver's `Retry-After`. Uploads in flight end too, and no
884
- * other starts: their values are exported cut, as before uploads existed. */
1005
+ * keeps the process running for the receiver's `Retry-After`. Queued and in-flight uploads end
1006
+ * too, and no other starts: their records' entries stay and the uploads count as failed, and
1007
+ * values not yet exported are exported cut, as before uploads existed. */
885
1008
  releaseRateLimitHolds() {
886
1009
  this.holdsReleased = true;
887
1010
  for (const release of [...this.holds])
@@ -1005,27 +1128,67 @@ export class HueTransport {
1005
1128
  this.endedSpans.delete(this.endedSpans.keys().next().value);
1006
1129
  }
1007
1130
  /**
1008
- * Waits for the processors' and exporters' in-flight work; drain the providers first.
1131
+ * Waits for the processors' and exporters' in-flight work, then for the uploads their exports
1132
+ * queued; drain the providers first. Only the sends are serialized: a flush sends its records
1133
+ * once an earlier flush sent its own, never after that flush's uploads.
1009
1134
  *
1010
1135
  * @throws HueExportError when a new non-warning issue was recorded since the previous observation.
1011
1136
  */
1012
1137
  flush() {
1138
+ return this.flushRecords().report;
1139
+ }
1140
+ /**
1141
+ * @internal A flush in two steps: its send settles `sent`, then it waits on its own for the
1142
+ * uploads queued so far and settles `report` as {@link flush} does. `providers` (the client's
1143
+ * provider flush) runs first, before the flush joins the transport's sends, so a hung borrowed
1144
+ * provider never holds up the transport's own flushes. `sent` waits for it, though, and the
1145
+ * client starts each flush once the previous one's `sent` settles: it holds up the client's
1146
+ * later flushes.
1147
+ */
1148
+ flushRecords(providers) {
1013
1149
  const from = this.observedSequence;
1014
- const next = (this.flushPromise ?? Promise.resolve()).then(() => this.flushOnce(from), () => this.flushOnce(from));
1015
- this.flushPromise = next;
1150
+ const join = () => this.sendRecords();
1151
+ const sent = providers ? this.sendingRecords.run(true, providers).then(join, join) : join();
1152
+ const report = sent.then(async (processors) => {
1153
+ // Exports the processors did not start (a timer's, still in flight) queued uploads too.
1154
+ await this.drainUploads();
1155
+ return this.flushReport(from, processors);
1156
+ });
1157
+ return { sent, report };
1158
+ }
1159
+ /** Sends what the processors and exporters hold, once the previous flush's send ended. */
1160
+ sendRecords() {
1161
+ const send = () => this.sendingRecords.run(true, async () => {
1162
+ const processors = await Promise.allSettled([
1163
+ this.spanProcessor.forceFlush(),
1164
+ this.logRecordProcessor.forceFlush(),
1165
+ ]);
1166
+ await Promise.all([this.traceExporter.forceFlush(), this.logExporter.forceFlush()]);
1167
+ return processors;
1168
+ });
1169
+ const sent = (this.sendPromise ?? Promise.resolve()).then(send, send);
1170
+ this.sendPromise = sent;
1016
1171
  const clear = () => {
1017
- if (this.flushPromise === next)
1018
- this.flushPromise = undefined;
1172
+ if (this.sendPromise === sent)
1173
+ this.sendPromise = undefined;
1019
1174
  };
1020
- void next.then(clear, clear);
1021
- return next;
1022
- }
1023
- async flushOnce(from) {
1024
- const processors = await Promise.allSettled([
1025
- this.spanProcessor.forceFlush(),
1026
- this.logRecordProcessor.forceFlush(),
1027
- ]);
1028
- await Promise.all([this.traceExporter.forceFlush(), this.logExporter.forceFlush()]);
1175
+ void sent.then(clear, clear);
1176
+ return sent;
1177
+ }
1178
+ /** @internal Exporter callback: an export's queued uploads, reported once they all settle. */
1179
+ awaitUploads(report) {
1180
+ // Never an unhandled rejection, whatever the report does.
1181
+ const tracked = report.catch(() => { });
1182
+ this.uploadReports.add(tracked);
1183
+ void tracked.then(() => this.uploadReports.delete(tracked));
1184
+ const earlier = this.uploadsSettled;
1185
+ this.uploadsSettled = tracked.then(() => earlier);
1186
+ }
1187
+ /** Waits for the uploads queued so far; each ends within its lifetime, so this is bounded. */
1188
+ drainUploads() {
1189
+ return this.uploadsSettled;
1190
+ }
1191
+ flushReport(from, processors) {
1029
1192
  for (const [index, result] of processors.entries())
1030
1193
  if (result.status === "rejected") {
1031
1194
  const signal = index === 0 ? "traces" : "logs";
@@ -1040,8 +1203,9 @@ export class HueTransport {
1040
1203
  return report;
1041
1204
  }
1042
1205
  /**
1043
- * Flushes and stops the processors; records emitted afterwards are dropped and counted. In attach
1044
- * mode call it after shutting down the application's providers.
1206
+ * Flushes and stops the processors, waits for queued uploads, then ends the uploader; records
1207
+ * emitted afterwards are dropped and counted. In attach mode call it after shutting down the
1208
+ * application's providers.
1045
1209
  *
1046
1210
  * @throws HueExportError when the final flush observed new failures.
1047
1211
  */
@@ -1057,7 +1221,10 @@ export class HueTransport {
1057
1221
  this.logRecordProcessor.shutdown(),
1058
1222
  ]);
1059
1223
  await Promise.allSettled([this.traceExporter.forceFlush(), this.logExporter.forceFlush()]);
1224
+ await this.drainUploads();
1060
1225
  this.uploader?.close();
1226
+ // The reports of uploads the close ended land before shutdown resolves.
1227
+ await this.drainUploads();
1061
1228
  for (const identity of this.media.keys())
1062
1229
  this.releaseMediaIdentity(identity);
1063
1230
  this.logParts.clear();
@@ -1256,7 +1423,24 @@ class ReportingExporter {
1256
1423
  real.push(placeholder);
1257
1424
  return real;
1258
1425
  }
1426
+ /** Places and sends the records; the uploads their placement queued are reported once they
1427
+ * settle, after the records were sent, never awaited here. */
1259
1428
  async exportRecords(records) {
1429
+ const late = { tally: newTally(), settled: [] };
1430
+ try {
1431
+ await this.placeAndSend(records, late);
1432
+ }
1433
+ finally {
1434
+ if (late.settled.length)
1435
+ this.reportWhenSettled(late);
1436
+ }
1437
+ }
1438
+ /** Reports an export's uploads once they all settle. Built apart from the export, so a pending
1439
+ * upload never keeps the export's records alive. */
1440
+ reportWhenSettled(late) {
1441
+ this.transport.awaitUploads(Promise.all(late.settled).then(() => this.transport.reportOffload(late.tally, this.signal, true)));
1442
+ }
1443
+ async placeAndSend(records, late) {
1260
1444
  // Completed records and placeholders travel in separate requests, completed records first,
1261
1445
  // so a rejection count is always one kind of record's. Placeholders are advisory: losing one
1262
1446
  // is a warning, never an export failure.
@@ -1278,21 +1462,17 @@ class ReportingExporter {
1278
1462
  }
1279
1463
  };
1280
1464
  const resourceDeadline = Date.now() + this.transport.options.timeoutMillis;
1281
- // How long this export has held its records for a rate-limited receiver, its value uploads'
1282
- // waits included.
1465
+ // How long this export has held its records for a rate-limited receiver.
1283
1466
  const hold = { millis: 0 };
1284
- // A span's values over the inline limit are uploaded before it is measured, within the
1285
- // export's upload budget; a placeholder's never are.
1467
+ // A span's values over the inline limit are hashed and placed before it is measured, within
1468
+ // the export's hashing deadline; their uploads are queued, never awaited, and a placeholder's
1469
+ // values never upload.
1286
1470
  const upload = {
1287
1471
  deadline: Date.now() + uploadBudgetMillis(this.transport.options.timeoutMillis),
1288
- wait: async (millis) => {
1289
- if (hold.millis + millis > MAX_RATE_LIMIT_HOLD_MILLIS)
1290
- return false;
1291
- hold.millis += millis;
1292
- return this.transport.holdForRateLimit(millis);
1293
- },
1472
+ wait: async () => false,
1294
1473
  };
1295
1474
  const tally = newTally();
1475
+ // Each record's attachments to upload once it is fitted, with the queued record holding them.
1296
1476
  const helpers = new Map();
1297
1477
  const placed = [];
1298
1478
  for (const { record, markers } of this.ordered(records)) {
@@ -1341,8 +1521,8 @@ class ReportingExporter {
1341
1521
  offload.body ||
1342
1522
  offload.attachments?.length)
1343
1523
  ? this.signal === "traces"
1344
- ? this.transport.offloadSpan(redacted, offload, upload, tally)
1345
- : this.transport.offloadLog(redacted, offload, upload, tally)
1524
+ ? this.transport.offloadSpan(redacted, offload, upload, tally, late, record)
1525
+ : this.transport.offloadLog(redacted, offload, upload, tally, late, record)
1346
1526
  : undefined;
1347
1527
  placed.push({
1348
1528
  record,
@@ -1393,7 +1573,7 @@ class ReportingExporter {
1393
1573
  [BLOBS_KEY]: [...baseline, ...retained.map(mediaEntry)],
1394
1574
  },
1395
1575
  };
1396
- helpers.set(redacted, { files: retained, baseline });
1576
+ helpers.set(redacted, { files: retained, baseline, source: record });
1397
1577
  }
1398
1578
  }
1399
1579
  const bytes = 512 +
@@ -1595,7 +1775,11 @@ class ReportingExporter {
1595
1775
  else
1596
1776
  delete attributes[BLOBS_KEY];
1597
1777
  }
1598
- attachments = { record: { ...fittedRecord, attributes }, files };
1778
+ attachments = {
1779
+ record: { ...fittedRecord, attributes },
1780
+ files,
1781
+ source: helper.source,
1782
+ };
1599
1783
  remaining = [];
1600
1784
  }
1601
1785
  }
@@ -1613,15 +1797,17 @@ class ReportingExporter {
1613
1797
  }
1614
1798
  admitted.push({ fitted, attachments });
1615
1799
  }
1616
- // Decide every event/reference admission before starting helper uploads. The shared
1617
- // uploader's four workers place files across spans under the same upload deadline.
1800
+ // Decide every event/reference admission before queueing helper uploads: a file fitting
1801
+ // removed is never uploaded. Their entries are written again as the fitted record listed
1802
+ // them, and the files upload after the records are sent.
1618
1803
  const placed = await Promise.all(admitted.map(async ({ fitted, attachments }) => {
1619
1804
  if (!attachments)
1620
1805
  return fitted;
1621
1806
  try {
1622
- const uploaded = await this.transport.offloadSpan(attachments.record, { candidates: [], cut: [], attachments: attachments.files }, upload, tally);
1807
+ const uploaded = await this.transport.offloadSpan(attachments.record, { candidates: [], cut: [], attachments: attachments.files }, upload, tally, late, attachments.source);
1623
1808
  measured.delete(fitted.record);
1624
- // Actual references can differ when an existing object supplies its media type.
1809
+ // The entries name each file's own type, as the fitted record's did; a file refused
1810
+ // when queued leaves its entry out, so the record is measured again, cheaply.
1625
1811
  const final = await probe(uploaded);
1626
1812
  if (final?.request)
1627
1813
  measured.set(final.record, final.request);
@@ -1634,8 +1820,9 @@ class ReportingExporter {
1634
1820
  return undefined;
1635
1821
  }
1636
1822
  }));
1637
- // Preparation includes explicit file uploads. Report their outcome once, before delivery
1638
- // failures can consume the diagnostic callback's rate-limit slot. Placeholders never upload.
1823
+ // Values refused when their records were placed are reported once, before delivery failures
1824
+ // can consume the diagnostic callback's rate-limit slot; queued uploads are reported once
1825
+ // they settle. Placeholders never upload.
1639
1826
  if (!advisory)
1640
1827
  this.transport.reportOffload(tally, this.signal);
1641
1828
  let batch = [];