@hue-run/sdk 0.16.0 → 0.18.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
@@ -6,13 +6,101 @@ import { LogsExporterMetricsHelper, ProtobufLogsSerializer, ProtobufTraceSeriali
6
6
  import { BatchSpanProcessor, } from "@opentelemetry/sdk-trace";
7
7
  import { BatchLogRecordProcessor, } from "@opentelemetry/sdk-logs";
8
8
  import { isInsecureOrigin, REDACTION_CONTEXT_UNITS, validateOptions } from "./config.js";
9
- import { BlobUploader, FALLBACK_MESSAGES, fallbackAttributes, MAX_HELD_BLOB_BYTES, newTally, offloadAttributes, uploadBudgetMillis, } from "./blobs.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
10
  import { advertisedLimits, BATCH_TARGET_BYTES, DEFAULT_RECEIVER_LIMITS, fitsWithoutCompression, } from "./limits.js";
11
11
  import { announcesLiveSpan, LIVE_SPAN_INTERVAL_MILLIS, MAX_LIVE_SPANS, pendingPlaceholder, PLACEHOLDERS_HEADER, withoutPlaceholderMarkers, } from "./live-spans.js";
12
- import { estimateRecordBytes } from "./safety.js";
12
+ import { estimateRecordBytes, spanSource } from "./safety.js";
13
+ import { MediaDigestMemo, isMediaName } from "./inline-files.js";
14
+ import { locateParts } from "./attachment.js";
13
15
  import { snapshotLog, snapshotSpan } from "./snapshot.js";
14
16
  import { DROPPED_RECORDS_KEY, isContentKey, isTruncatedMarker, redactLog, redactSpan, TRUNCATED_KEY, truncatedMarker, withTruncatedKeys, } from "./privacy.js";
15
17
  import { sdkVersion } from "./version.js";
18
+ /** A record's trace: a span's context, a log record's span identifiers; "" when unreadable. */
19
+ function recordTraceId(record) {
20
+ try {
21
+ return "spanContext" in record && typeof record.spanContext === "function"
22
+ ? record.spanContext().traceId
23
+ : (record.spanContext?.traceId ?? "");
24
+ }
25
+ catch {
26
+ return "";
27
+ }
28
+ }
29
+ /** The value of a record's attribute, or a log record's body, that holds placed attachments. */
30
+ function placedValue(record, attribute) {
31
+ if (attribute === "body" &&
32
+ !("spanContext" in record && typeof record.spanContext === "function"))
33
+ return record.body;
34
+ return record.attributes[attribute];
35
+ }
36
+ /**
37
+ * The staged files a record still authorizes. Each surviving `hue.file` event authorizes one
38
+ * staged request, including repeated digests. An attachment placed in a value is authorized by
39
+ * its own part in the value as exported: after redaction (`before` absent) each part is found in
40
+ * its value, and keyed at its pointer there, a part the redactor removed taking its upload with
41
+ * it; after fitting (`before`, the record as it was fitted) a part survives while fitting left its
42
+ * value as it was.
43
+ */
44
+ function survivingMedia(files, record, tally, before) {
45
+ const consumed = new Set();
46
+ const traceId = recordTraceId(record);
47
+ const events = "events" in record ? record.events : [];
48
+ const located = new Map();
49
+ const locate = (attribute) => {
50
+ let parts = located.get(attribute);
51
+ if (parts)
52
+ return [];
53
+ let value = placedValue(record, attribute);
54
+ try {
55
+ if (typeof value === "string")
56
+ value = JSON.parse(value);
57
+ }
58
+ catch {
59
+ value = undefined;
60
+ }
61
+ parts = locateParts(value, files.filter((file) => file.attribute === attribute)).map(({ file, pointer, name }) => {
62
+ const { name: _staged, ...rest } = file;
63
+ return { ...rest, key: `${attribute}#${pointer}`, ...(name ? { name } : {}) };
64
+ });
65
+ located.set(attribute, parts);
66
+ return parts;
67
+ };
68
+ return files.flatMap((file) => {
69
+ if (file.attribute !== undefined) {
70
+ if (!before)
71
+ return locate(file.attribute);
72
+ if (placedValue(record, file.attribute) === placedValue(before, file.attribute))
73
+ return [file];
74
+ countFallback(tally, "budget", traceId);
75
+ return [];
76
+ }
77
+ const index = events.findIndex((event, index) => !consumed.has(index) &&
78
+ event.name === "hue.file" &&
79
+ (!file.eventTime ||
80
+ (event.time[0] === file.eventTime[0] && event.time[1] === file.eventTime[1])) &&
81
+ event.attributes?.["hue.file.sha256"] === file.digest.sha256 &&
82
+ event.attributes?.["hue.file.size"] === file.digest.size &&
83
+ event.attributes?.["hue.file.media_type"] === file.contentType);
84
+ if (index < 0) {
85
+ countFallback(tally, "budget", traceId);
86
+ return [];
87
+ }
88
+ consumed.add(index);
89
+ const name = events[index].attributes?.["hue.file.name"];
90
+ return [
91
+ {
92
+ bytes: file.bytes,
93
+ digest: file.digest,
94
+ contentType: file.contentType,
95
+ ...(file.eventTime ? { eventTime: file.eventTime } : {}),
96
+ ...(isMediaName(name) ? { name } : {}),
97
+ },
98
+ ];
99
+ });
100
+ }
101
+ function mediaEntry(file) {
102
+ return blobEntry(mediaKey(file), file.digest.sha256, file.digest.size, file.contentType, file.name);
103
+ }
16
104
  /**
17
105
  * How long one export holds its records for a receiver that refused them for their rate (HTTP 429)
18
106
  * and asked to be retried later than a request's deadline allows: the records stay queued, counted
@@ -125,11 +213,27 @@ export class HueTransport {
125
213
  admissionCuts = new WeakMap();
126
214
  // The span's own messages whose large inline files each admitted span shrank to their digest.
127
215
  admissionDigests = new WeakMap();
216
+ // The span's own messages each admitted span inspected, or left to export to inspect.
217
+ admissionInspected = new WeakMap();
128
218
  /** Uploads values over the inline limit apart from their spans; absent when disabled. */
129
219
  uploader;
130
220
  /** Bytes of values over the inline limit queued spans hold whole for upload, and each span's. */
131
221
  heldBytes = 0;
132
222
  held = new WeakMap();
223
+ mediaMemo = new MediaDigestMemo();
224
+ // Context identity also matches safe API wrappers to the provider's ended span. Abandoned
225
+ // spans retain no span objects, and share the 2048-span / 128 MiB holding bounds.
226
+ media = new Map();
227
+ // Attachments placed in a log record's body, from `emit` until the record is admitted, and
228
+ // the admitted records' own.
229
+ logParts = new Map();
230
+ logMedia = new WeakMap();
231
+ mediaOwners = new Map();
232
+ mediaTimer;
233
+ mediaFinalizer = new FinalizationRegistry(({ identity, token }) => {
234
+ if (this.mediaOwners.get(identity)?.token === token)
235
+ this.releaseMediaIdentity(identity);
236
+ });
133
237
  uploaded = 0;
134
238
  uploadFallbacks = 0;
135
239
  constructor(options) {
@@ -137,15 +241,21 @@ export class HueTransport {
137
241
  Object.defineProperty(this, "options", { enumerable: false });
138
242
  this.liveSpans = this.options.enabled !== false && this.options.liveSpans;
139
243
  this.announcesLiveSpans = this.liveSpans;
140
- this.traceExporter = new ReportingExporter(this, "traces", ProtobufTraceSerializer, TraceExporterMetricsHelper, (span, cache, offload) => redactSpan(span, this.options, cache, {
141
- valueBytes: this.limits.valueBytes,
142
- cut: this.admissionCuts.get(span),
143
- digested: this.admissionDigests.get(span),
144
- ...(offload ? { offload } : {}),
145
- }));
146
- this.logExporter = new ReportingExporter(this, "logs", ProtobufLogsSerializer, LogsExporterMetricsHelper, (log, cache) => redactLog(log, this.options, cache, {
244
+ this.traceExporter = new ReportingExporter(this, "traces", ProtobufTraceSerializer, TraceExporterMetricsHelper, (span, cache, offload) => {
245
+ // Export inspects only the messages admission inspected within the record's allowance.
246
+ if (offload)
247
+ offload.inspected = this.admissionInspected.get(span);
248
+ return redactSpan(span, this.options, cache, {
249
+ valueBytes: this.limits.valueBytes,
250
+ cut: this.admissionCuts.get(span),
251
+ digested: this.admissionDigests.get(span),
252
+ ...(offload ? { offload } : {}),
253
+ });
254
+ });
255
+ this.logExporter = new ReportingExporter(this, "logs", ProtobufLogsSerializer, LogsExporterMetricsHelper, (log, cache, offload) => redactLog(log, this.options, cache, {
147
256
  valueBytes: this.limits.valueBytes,
148
257
  cut: this.admissionCuts.get(log),
258
+ ...(offload ? { offload } : {}),
149
259
  }));
150
260
  if (this.options.enabled !== false)
151
261
  this.uploader = new BlobUploader({
@@ -187,18 +297,30 @@ export class HueTransport {
187
297
  this.stopLiveTimer();
188
298
  let admitted;
189
299
  try {
190
- if (!(span.spanContext().traceFlags & 1))
300
+ if (!(span.spanContext().traceFlags & 1)) {
301
+ this.releaseMedia(span);
191
302
  return;
303
+ }
192
304
  const queued = this.enqueue("traces", span);
193
- if (!queued)
305
+ if (!queued) {
306
+ const tally = newTally();
307
+ for (const file of this.mediaAttachments(span))
308
+ if (file.bytes.byteLength)
309
+ countFallback(tally, "budget", span.spanContext().traceId);
310
+ this.reportOffload(tally);
311
+ this.releaseMedia(span);
194
312
  return;
313
+ }
195
314
  admitted = queued;
315
+ this.protectMedia(admitted);
196
316
  this.countEnded(admitted);
197
317
  spans.onEnd(admitted);
198
318
  }
199
319
  catch {
200
320
  if (admitted)
201
321
  this.finish("traces", [admitted]);
322
+ else
323
+ this.releaseMedia(span);
202
324
  this.issue("traces", "invalid", 1, "Telemetry processor could not accept a record", undefined, traceIdsOf([span]));
203
325
  }
204
326
  },
@@ -213,9 +335,21 @@ export class HueTransport {
213
335
  let admitted;
214
336
  try {
215
337
  const queued = this.enqueue("logs", log);
216
- if (!queued)
338
+ if (!queued) {
339
+ const pending = log.body !== null && typeof log.body === "object"
340
+ ? this.logParts.get(log.body)
341
+ : undefined;
342
+ if (pending) {
343
+ const tally = newTally();
344
+ for (const _file of pending.files)
345
+ countFallback(tally, "budget", pending.traceId);
346
+ this.reportOffload(tally, "logs");
347
+ this.logParts.delete(log.body);
348
+ }
217
349
  return;
350
+ }
218
351
  admitted = queued;
352
+ this.admitLogParts(log, queued);
219
353
  logs.onEmit(queued);
220
354
  }
221
355
  catch {
@@ -254,7 +388,7 @@ export class HueTransport {
254
388
  // and its full size could drop the record before that cut ever ran. A span's own value
255
389
  // that export may upload is held whole instead, within the held-value budget.
256
390
  const valueUnits = this.limits.valueBytes + REDACTION_CONTEXT_UNITS;
257
- const hold = signal === "traces" && !advisory && this.holdsValues()
391
+ const hold = !advisory && this.holdsValues()
258
392
  ? {
259
393
  valueBytes: this.limits.valueBytes,
260
394
  budget: Math.max(0, MAX_HELD_BLOB_BYTES - this.heldBytes),
@@ -262,12 +396,14 @@ export class HueTransport {
262
396
  }
263
397
  : undefined;
264
398
  const snapshot = signal === "traces"
265
- ? snapshotSpan(record, remaining, this.options.captureContent, valueUnits, hold)
266
- : snapshotLog(record, remaining, this.options.captureContent, valueUnits);
399
+ ? snapshotSpan(record, remaining, this.options.captureContent, valueUnits, hold, this.mediaMemo)
400
+ : snapshotLog(record, remaining, this.options.captureContent, valueUnits, hold, this.mediaMemo);
267
401
  if (snapshot.cut.size)
268
402
  this.admissionCuts.set(snapshot.record, snapshot.cut);
269
403
  if (snapshot.digested.size)
270
404
  this.admissionDigests.set(snapshot.record, snapshot.digested);
405
+ if (signal === "traces")
406
+ this.admissionInspected.set(snapshot.record, snapshot.inspected);
271
407
  if (snapshot.held) {
272
408
  this.heldBytes += snapshot.held;
273
409
  this.held.set(snapshot.record, snapshot.held);
@@ -311,7 +447,11 @@ export class HueTransport {
311
447
  this.live.delete(span);
312
448
  try {
313
449
  // Ended without reaching onEnd: a wrapping processor filtered it, so no real span follows.
314
- if (!this.liveSpans || span.ended || !this.batchSpans)
450
+ if (span.ended) {
451
+ this.releaseMedia(span);
452
+ continue;
453
+ }
454
+ if (!this.liveSpans || !this.batchSpans)
315
455
  continue;
316
456
  const { record, markers } = pendingPlaceholder(span);
317
457
  const admitted = this.enqueue("traces", record, true);
@@ -373,6 +513,8 @@ export class HueTransport {
373
513
  /** @internal Exporter callback: releases queued records after an export attempt settles. */
374
514
  finish(signal, records) {
375
515
  for (const record of records) {
516
+ this.releaseMedia(record);
517
+ this.logMedia.delete(record);
376
518
  const pending = signal === "traces" ? this.spans : this.logs;
377
519
  this.pendingBytes -= pending.get(record) ?? 0;
378
520
  pending.delete(record);
@@ -383,7 +525,218 @@ export class HueTransport {
383
525
  }
384
526
  }
385
527
  }
386
- /** @internal Whether spans' values over the inline limit are uploaded: content is captured and
528
+ /** @internal The same bounded digest memo is used by explicit and inline media. */
529
+ digestMedia(bytes, traceId) {
530
+ return this.mediaMemo.bytes(bytes, traceId);
531
+ }
532
+ /** @internal Retain a defensive copy within the existing held-value budget. */
533
+ stageMedia(span, file) {
534
+ if (this.closed || !this.offloadsValues() || file?.bytes.byteLength === 0)
535
+ return true;
536
+ if (!file) {
537
+ const tally = newTally();
538
+ countFallback(tally, "budget", span.spanContext().traceId);
539
+ this.reportOffload(tally);
540
+ return false;
541
+ }
542
+ if (!this.holdsValues()) {
543
+ this.uploadFallbacks++;
544
+ this.issue("traces", "warning", 1, "File bytes could not be uploaded while the upload service was unavailable; their hue.file metadata was recorded", undefined, [span.spanContext().traceId]);
545
+ return false;
546
+ }
547
+ const identity = this.mediaIdentity(span);
548
+ this.sweepMedia();
549
+ const files = this.media.get(identity) ?? [];
550
+ if (files.length >= 64 ||
551
+ (!this.media.has(identity) && this.media.size >= 2048) ||
552
+ this.heldBytes + file.bytes.byteLength > MAX_HELD_BLOB_BYTES) {
553
+ this.uploadFallbacks++;
554
+ 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]);
555
+ return false;
556
+ }
557
+ const bytes = Uint8Array.from(file.bytes);
558
+ files.push({ ...file, bytes });
559
+ this.media.set(identity, files);
560
+ this.ownMedia(span, identity);
561
+ this.heldBytes += bytes.byteLength;
562
+ return true;
563
+ }
564
+ /**
565
+ * @internal Stages the attachments placed in a span's `attribute`, replacing those an earlier
566
+ * value of the attribute placed: each is uploaded at export when its part survives there. A
567
+ * file that cannot be held is counted as not uploaded; it keeps its digest part.
568
+ */
569
+ stageParts(span, attribute, files) {
570
+ if (this.closed || !this.offloadsValues())
571
+ return;
572
+ const traceId = span.spanContext().traceId;
573
+ const identity = this.mediaIdentity(span);
574
+ const previous = this.media.get(identity);
575
+ if (previous?.some((file) => file.attribute === attribute)) {
576
+ const kept = previous.filter((file) => {
577
+ if (file.attribute !== attribute)
578
+ return true;
579
+ this.heldBytes -= file.bytes.byteLength;
580
+ return false;
581
+ });
582
+ if (kept.length)
583
+ this.media.set(identity, kept);
584
+ else
585
+ this.media.delete(identity);
586
+ }
587
+ const tally = newTally();
588
+ for (const file of files) {
589
+ if (!file.bytes.byteLength)
590
+ continue;
591
+ const refused = this.uploader?.refusal();
592
+ if (!this.holdsValues() || refused) {
593
+ countFallback(tally, refused ?? "unsupported", traceId);
594
+ continue;
595
+ }
596
+ this.sweepMedia();
597
+ const staged = this.media.get(identity) ?? [];
598
+ if (staged.length >= MAX_BLOB_ENTRIES ||
599
+ (!this.media.has(identity) && this.media.size >= 2048) ||
600
+ this.heldBytes + file.bytes.byteLength > MAX_HELD_BLOB_BYTES) {
601
+ countFallback(tally, "budget", traceId);
602
+ continue;
603
+ }
604
+ // Attachment bytes are the attachment's own copy: held as they are.
605
+ staged.push({ ...file, attribute });
606
+ this.media.set(identity, staged);
607
+ this.heldBytes += file.bytes.byteLength;
608
+ this.ownMedia(span, identity);
609
+ }
610
+ if (tally.fallbacks.size)
611
+ this.reportOffload(tally);
612
+ }
613
+ /**
614
+ * @internal Stages the attachments placed in a log record's body until the record is emitted
615
+ * (`body` is the object passed to `emit`); {@link unstageLogParts} drops what no processor of
616
+ * this transport admitted.
617
+ */
618
+ stageLogParts(body, traceId, files) {
619
+ if (this.closed || !this.offloadsValues())
620
+ return;
621
+ const tally = newTally();
622
+ const staged = [];
623
+ for (const file of files) {
624
+ if (!file.bytes.byteLength)
625
+ continue;
626
+ const refused = this.uploader?.refusal();
627
+ if (!this.holdsValues() || refused)
628
+ countFallback(tally, refused ?? "unsupported", traceId);
629
+ else if (staged.length >= MAX_BLOB_ENTRIES)
630
+ countFallback(tally, "budget", traceId);
631
+ else
632
+ staged.push({ ...file, attribute: "body" });
633
+ }
634
+ if (tally.fallbacks.size)
635
+ this.reportOffload(tally, "logs");
636
+ if (staged.length)
637
+ this.logParts.set(body, { traceId, files: staged });
638
+ }
639
+ /** @internal Drops a log record's staged attachments once it was emitted. */
640
+ unstageLogParts(body) {
641
+ this.logParts.delete(body);
642
+ }
643
+ /** Holds the staged attachments of a log record being admitted with the queued record, within
644
+ * the held-value budget; those the budget refuses are counted as not uploaded. */
645
+ admitLogParts(log, queued) {
646
+ const body = log.body;
647
+ const pending = body !== null && typeof body === "object" ? this.logParts.get(body) : undefined;
648
+ if (!pending)
649
+ return;
650
+ this.logParts.delete(body);
651
+ const size = pending.files.reduce((total, file) => total + file.bytes.byteLength, 0);
652
+ if (this.heldBytes + size > MAX_HELD_BLOB_BYTES) {
653
+ const tally = newTally();
654
+ for (const _file of pending.files)
655
+ countFallback(tally, "budget", pending.traceId);
656
+ this.reportOffload(tally, "logs");
657
+ return;
658
+ }
659
+ this.logMedia.set(queued, pending.files);
660
+ this.heldBytes += size;
661
+ this.held.set(queued, (this.held.get(queued) ?? 0) + size);
662
+ }
663
+ /** Registers a span whose staged files are released if it is never exported. */
664
+ ownMedia(span, identity) {
665
+ if (this.mediaOwners.has(identity))
666
+ return;
667
+ const source = spanSource(span);
668
+ const token = {};
669
+ this.mediaOwners.set(identity, { source: new WeakRef(source), token });
670
+ this.mediaFinalizer.register(source, { identity, token }, token);
671
+ if (!this.mediaTimer) {
672
+ this.mediaTimer = setInterval(() => this.sweepMedia(), LIVE_SPAN_INTERVAL_MILLIS);
673
+ this.mediaTimer.unref();
674
+ }
675
+ }
676
+ mediaIdentity(record) {
677
+ if (!("spanContext" in record) || typeof record.spanContext !== "function")
678
+ return undefined;
679
+ const { traceId, spanId } = record.spanContext();
680
+ return `${traceId}:${spanId}`;
681
+ }
682
+ releaseMedia(record) {
683
+ const identity = this.mediaIdentity(record);
684
+ if (!identity)
685
+ return;
686
+ this.releaseMediaIdentity(identity);
687
+ }
688
+ protectMedia(record) {
689
+ const identity = this.mediaIdentity(record);
690
+ if (!identity)
691
+ return;
692
+ const owner = this.mediaOwners.get(identity);
693
+ if (owner)
694
+ this.mediaFinalizer.unregister(owner.token);
695
+ this.mediaOwners.delete(identity);
696
+ if (!this.mediaOwners.size) {
697
+ clearInterval(this.mediaTimer);
698
+ this.mediaTimer = undefined;
699
+ }
700
+ }
701
+ releaseMediaIdentity(identity) {
702
+ const owner = this.mediaOwners.get(identity);
703
+ if (owner)
704
+ this.mediaFinalizer.unregister(owner.token);
705
+ this.mediaOwners.delete(identity);
706
+ if (!this.mediaOwners.size) {
707
+ clearInterval(this.mediaTimer);
708
+ this.mediaTimer = undefined;
709
+ }
710
+ const files = this.media.get(identity);
711
+ if (!files)
712
+ return;
713
+ for (const file of files)
714
+ this.heldBytes -= file.bytes.byteLength;
715
+ this.media.delete(identity);
716
+ }
717
+ sweepMedia() {
718
+ for (const [identity, owner] of this.mediaOwners) {
719
+ try {
720
+ const source = owner.source.deref();
721
+ if (!source ||
722
+ source.ended === true ||
723
+ source.isRecording?.() === false)
724
+ this.releaseMediaIdentity(identity);
725
+ }
726
+ catch {
727
+ continue;
728
+ }
729
+ }
730
+ }
731
+ /** @internal Staged requests are matched against surviving events and parts before upload. */
732
+ mediaAttachments(record) {
733
+ const logged = this.logMedia.get(record);
734
+ if (logged)
735
+ return logged;
736
+ const identity = this.mediaIdentity(record);
737
+ return identity ? (this.media.get(identity) ?? []) : [];
738
+ }
739
+ /** @internal Whether spans' content and media may be uploaded: content is captured and
387
740
  * the key is a project key (setup credentials send metadata only). A value that then cannot be
388
741
  * uploaded is cut, reported and counted. */
389
742
  offloadsValues() {
@@ -391,7 +744,7 @@ export class HueTransport {
391
744
  this.options.captureContent &&
392
745
  !this.options.apiKey.startsWith("hue_setup_"));
393
746
  }
394
- /** @internal Whether values over the inline limit are worth holding whole for upload now: they
747
+ /** @internal Whether content and media are worth holding whole for upload now: they
395
748
  * are uploaded, and the receiver has not shown it lacks the upload route. */
396
749
  holdsValues() {
397
750
  return this.offloadsValues() && this.uploader?.available() === true;
@@ -399,13 +752,8 @@ export class HueTransport {
399
752
  /** @internal Exporter callback: a redacted span with the values its redaction left to the
400
753
  * upload step placed: uploaded and listed under `hue.blobs`, or cut as before. Never rejects. */
401
754
  async offloadSpan(span, collector, upload, tally) {
402
- let traceId = "";
403
- try {
404
- traceId = span.spanContext().traceId;
405
- }
406
- catch {
407
- // A span without a readable context cannot name its trace: its values are cut.
408
- }
755
+ // A record without a readable context cannot name its trace: its values are cut.
756
+ const traceId = recordTraceId(span);
409
757
  const valueBytes = this.limits.valueBytes;
410
758
  try {
411
759
  if (!this.uploader)
@@ -416,23 +764,52 @@ export class HueTransport {
416
764
  valueBytes,
417
765
  upload,
418
766
  tally,
767
+ memo: this.mediaMemo,
419
768
  });
420
769
  return { ...span, attributes: attributes };
421
770
  }
422
771
  catch {
423
772
  return {
424
773
  ...span,
425
- attributes: fallbackAttributes(span.attributes, collector, valueBytes, tally, traceId),
774
+ attributes: fallbackAttributes(span.attributes, collector, valueBytes, tally, traceId, this.mediaMemo),
426
775
  };
427
776
  }
428
777
  }
778
+ /** @internal Exporter callback: a redacted log record with the media its redaction left to
779
+ * the upload step placed: its attributes' and its body's files uploaded and listed under its own
780
+ * `hue.blobs` (keys `<attribute>#<pointer>`, `body#<pointer>`), under the log record's trace;
781
+ * a file not uploaded keeps its bytes inline, and the record is cut as before. Never rejects. */
782
+ async offloadLog(log, collector, upload, tally) {
783
+ const traceId = log.spanContext?.traceId ?? "";
784
+ const valueBytes = this.limits.valueBytes;
785
+ const placed = (result) => ({
786
+ ...log,
787
+ attributes: result.attributes,
788
+ ...(collector.body ? { body: result.body } : {}),
789
+ });
790
+ try {
791
+ if (!this.uploader)
792
+ throw new Error("Uploads are unavailable");
793
+ return placed(await offloadRecord(log.attributes, collector, {
794
+ uploader: this.uploader,
795
+ traceId,
796
+ valueBytes,
797
+ upload,
798
+ tally,
799
+ memo: this.mediaMemo,
800
+ }));
801
+ }
802
+ catch {
803
+ return placed(fallbackRecord(log.attributes, collector, valueBytes, tally, traceId, this.mediaMemo));
804
+ }
805
+ }
429
806
  /** @internal Exporter callback: counts an export's uploads and reports each kind of fallback
430
807
  * once, as a warning naming its traces: the values were exported, cut as before. */
431
- reportOffload(tally) {
808
+ reportOffload(tally, signal = "traces") {
432
809
  this.uploaded += tally.uploaded;
433
810
  for (const [reason, { count, traceIds }] of tally.fallbacks) {
434
811
  this.uploadFallbacks += count;
435
- this.issue("traces", "warning", count, FALLBACK_MESSAGES[reason], undefined, traceIds.size && traceIds.size <= MAX_ISSUE_TRACE_IDS ? [...traceIds] : undefined);
812
+ this.issue(signal, "warning", count, FALLBACK_MESSAGES[reason], undefined, traceIds.size && traceIds.size <= MAX_ISSUE_TRACE_IDS ? [...traceIds] : undefined);
436
813
  }
437
814
  }
438
815
  /** @internal Exporter callback: counts records the collector acknowledged. */
@@ -669,6 +1046,12 @@ export class HueTransport {
669
1046
  this.spanProcessor.shutdown(),
670
1047
  this.logRecordProcessor.shutdown(),
671
1048
  ]);
1049
+ await Promise.allSettled([this.traceExporter.forceFlush(), this.logExporter.forceFlush()]);
1050
+ this.uploader?.close();
1051
+ for (const identity of this.media.keys())
1052
+ this.releaseMediaIdentity(identity);
1053
+ this.logParts.clear();
1054
+ this.mediaMemo.clear();
672
1055
  }
673
1056
  })();
674
1057
  return this.shutdownPromise;
@@ -808,6 +1191,10 @@ function shedContent(record, signal, shed) {
808
1191
  : span.links,
809
1192
  };
810
1193
  }
1194
+ /** Whether a trace id can name an upload's trace: 32 lowercase hex digits, not all zero. */
1195
+ function isUploadTraceId(traceId) {
1196
+ return typeof traceId === "string" && /^[0-9a-f]{32}$/.test(traceId) && !/^0+$/.test(traceId);
1197
+ }
811
1198
  class ReportingExporter {
812
1199
  transport;
813
1200
  signal;
@@ -896,6 +1283,7 @@ class ReportingExporter {
896
1283
  },
897
1284
  };
898
1285
  const tally = newTally();
1286
+ const helpers = new Map();
899
1287
  const placed = [];
900
1288
  for (const { record, markers } of this.ordered(records)) {
901
1289
  try {
@@ -914,23 +1302,56 @@ class ReportingExporter {
914
1302
  clearTimeout(timer);
915
1303
  }
916
1304
  }
917
- const offload = !markers && this.signal === "traces" && this.transport.offloadsValues()
918
- ? { candidates: [], cut: [] }
1305
+ const uploads = !markers && this.transport.offloadsValues();
1306
+ // A log record's media upload under its own trace: one without a trace is not offered.
1307
+ const offload = uploads &&
1308
+ (this.signal === "traces" ||
1309
+ isUploadTraceId(record.spanContext?.traceId))
1310
+ ? this.signal === "traces"
1311
+ ? { candidates: [], cut: [] }
1312
+ : { candidates: [], cut: [], log: true }
919
1313
  : undefined;
920
1314
  const redacted = this.redact(record, cache, offload);
1315
+ let files = uploads
1316
+ ? survivingMedia(this.transport.mediaAttachments(record), redacted, tally)
1317
+ : [];
1318
+ if (this.signal === "logs" && files.length) {
1319
+ // A log record's attachments upload with the media its body holds, in the one step
1320
+ // that places a log record's files; a record without a trace uploads none.
1321
+ if (offload)
1322
+ offload.attachments = files;
1323
+ else
1324
+ for (let index = 0; index < files.length; index++)
1325
+ countFallback(tally, "budget", recordTraceId(record));
1326
+ files = [];
1327
+ }
1328
+ const offloaded = offload &&
1329
+ (offload.candidates.length ||
1330
+ offload.cut.length ||
1331
+ offload.body ||
1332
+ offload.attachments?.length)
1333
+ ? this.signal === "traces"
1334
+ ? this.transport.offloadSpan(redacted, offload, upload, tally)
1335
+ : this.transport.offloadLog(redacted, offload, upload, tally)
1336
+ : undefined;
921
1337
  placed.push({
922
1338
  record,
1339
+ files,
923
1340
  ...(markers ? { markers } : {}),
924
- redacted: offload && (offload.candidates.length || offload.cut.length)
925
- ? this.transport.offloadSpan(redacted, offload, upload, tally)
926
- : redacted,
1341
+ redacted: offloaded ?? redacted,
927
1342
  });
928
1343
  }
929
1344
  catch {
1345
+ if (!markers && this.transport.offloadsValues()) {
1346
+ const files = this.transport.mediaAttachments(record);
1347
+ for (let index = 0; index < files.length; index++)
1348
+ countFallback(tally, "budget", recordTraceId(record));
1349
+ }
930
1350
  invalid(markers !== undefined, "Telemetry record could not be redacted or exceeds supported content limits", record);
931
1351
  }
932
1352
  }
933
- for (const { record, markers, redacted: placing } of placed) {
1353
+ for (const { record, markers, files, redacted: placing } of placed) {
1354
+ let retained = files;
934
1355
  try {
935
1356
  let redacted = await placing;
936
1357
  // Added after redaction, so a redactor cannot alter the markers or the parent identity.
@@ -945,6 +1366,26 @@ class ReportingExporter {
945
1366
  if (kept !== attributes)
946
1367
  redacted = { ...redacted, attributes: kept };
947
1368
  }
1369
+ if (files.length) {
1370
+ const listed = redacted.attributes[BLOBS_KEY];
1371
+ const baseline = Array.isArray(listed)
1372
+ ? listed.filter((entry) => typeof entry === "string")
1373
+ : [];
1374
+ retained = files.slice(0, Math.max(0, MAX_BLOB_ENTRIES - baseline.length));
1375
+ for (let index = retained.length; index < files.length; index++)
1376
+ countFallback(tally, "budget", recordTraceId(redacted));
1377
+ if (retained.length) {
1378
+ // Fit the event or part and its reference together before sending customer bytes.
1379
+ redacted = {
1380
+ ...redacted,
1381
+ attributes: {
1382
+ ...redacted.attributes,
1383
+ [BLOBS_KEY]: [...baseline, ...retained.map(mediaEntry)],
1384
+ },
1385
+ };
1386
+ helpers.set(redacted, { files: retained, baseline });
1387
+ }
1388
+ }
948
1389
  const bytes = 512 +
949
1390
  estimateRecordBytes(recordData(redacted, this.signal), this.transport.options.maxQueueBytes - redactedBytes);
950
1391
  if (redactedBytes + bytes > this.transport.options.maxQueueBytes)
@@ -953,6 +1394,8 @@ class ReportingExporter {
953
1394
  (markers ? pending : real).push(redacted);
954
1395
  }
955
1396
  catch {
1397
+ for (let index = 0; index < retained.length; index++)
1398
+ countFallback(tally, "budget", recordTraceId(record));
956
1399
  invalid(markers !== undefined, "Telemetry record could not be redacted or exceeds supported content limits", record);
957
1400
  }
958
1401
  }
@@ -1036,7 +1479,6 @@ class ReportingExporter {
1036
1479
  // export may have adopted lower ones meanwhile, and each attempt checks them again.
1037
1480
  return fitsLimits(body, this.transport.receiverLimits()) ? body : undefined;
1038
1481
  };
1039
- this.transport.reportOffload(tally);
1040
1482
  /** Sends one kind of record (completed records, or placeholders alone: `advisory`) in one
1041
1483
  * request, or in halves when it is over the receiver's limits. A trace's root among completed
1042
1484
  * records carries the trace's dropped-record count as it stands when its request is made, so
@@ -1109,25 +1551,89 @@ class ReportingExporter {
1109
1551
  const pack = async (accepted, advisory) => {
1110
1552
  // Every record is measured, shed and, where it cannot be sent, counted lost before any root
1111
1553
  // is written, so a root carries the losses of children that ended after it.
1112
- const prepared = [];
1554
+ const admitted = [];
1113
1555
  for (const record of accepted) {
1114
1556
  let fitted;
1557
+ let attachments;
1558
+ const helper = helpers.get(record);
1559
+ let remaining = helper?.files ?? [];
1115
1560
  try {
1116
1561
  fitted = await fit(record);
1562
+ if (fitted && helper) {
1563
+ const fittedRecord = fitted.record;
1564
+ const traceId = recordTraceId(fittedRecord);
1565
+ const matched = survivingMedia(remaining, fittedRecord, tally, record);
1566
+ remaining = matched;
1567
+ const listed = fittedRecord.attributes[BLOBS_KEY];
1568
+ const entries = Array.isArray(listed) ? [...listed] : [];
1569
+ const files = matched.filter((file) => {
1570
+ const index = entries.indexOf(mediaEntry(file), helper.baseline.length);
1571
+ if (index < 0) {
1572
+ countFallback(tally, "budget", traceId);
1573
+ return false;
1574
+ }
1575
+ entries.splice(index, 1);
1576
+ return true;
1577
+ });
1578
+ remaining = files;
1579
+ // Drop provisional entries, retaining only the original references after fitting.
1580
+ const attributes = { ...fittedRecord.attributes };
1581
+ if (Array.isArray(listed)) {
1582
+ const baseline = listed.slice(0, helper.baseline.length);
1583
+ if (baseline.length)
1584
+ attributes[BLOBS_KEY] = baseline;
1585
+ else
1586
+ delete attributes[BLOBS_KEY];
1587
+ }
1588
+ attachments = { record: { ...fittedRecord, attributes }, files };
1589
+ remaining = [];
1590
+ }
1117
1591
  }
1118
1592
  catch {
1593
+ for (let index = 0; index < remaining.length; index++)
1594
+ countFallback(tally, "budget", recordTraceId(record));
1119
1595
  invalid(advisory, "Telemetry record could not be serialized", record);
1120
1596
  continue;
1121
1597
  }
1122
1598
  if (!fitted) {
1599
+ for (let index = 0; index < remaining.length; index++)
1600
+ countFallback(tally, "budget", recordTraceId(record));
1123
1601
  invalid(advisory, OVER_REQUEST_LIMIT, record);
1124
1602
  continue;
1125
1603
  }
1126
- prepared.push(fitted);
1604
+ admitted.push({ fitted, attachments });
1127
1605
  }
1606
+ // Decide every event/reference admission before starting helper uploads. The shared
1607
+ // uploader's four workers place files across spans under the same upload deadline.
1608
+ const placed = await Promise.all(admitted.map(async ({ fitted, attachments }) => {
1609
+ if (!attachments)
1610
+ return fitted;
1611
+ try {
1612
+ const uploaded = await this.transport.offloadSpan(attachments.record, { candidates: [], cut: [], attachments: attachments.files }, upload, tally);
1613
+ measured.delete(fitted.record);
1614
+ // Actual references can differ when an existing object supplies its media type.
1615
+ const final = await probe(uploaded);
1616
+ if (final?.request)
1617
+ measured.set(final.record, final.request);
1618
+ if (!final)
1619
+ invalid(advisory, OVER_REQUEST_LIMIT, fitted.record);
1620
+ return final;
1621
+ }
1622
+ catch {
1623
+ invalid(advisory, "Telemetry record could not be serialized", fitted.record);
1624
+ return undefined;
1625
+ }
1626
+ }));
1627
+ // Preparation includes explicit file uploads. Report their outcome once, before delivery
1628
+ // failures can consume the diagnostic callback's rate-limit slot. Placeholders never upload.
1629
+ if (!advisory)
1630
+ this.transport.reportOffload(tally, this.signal);
1128
1631
  let batch = [];
1129
1632
  let batchBytes = 0;
1130
- for (const { record, bytes: recordBytes } of prepared) {
1633
+ for (const fitted of placed) {
1634
+ if (!fitted)
1635
+ continue;
1636
+ const { record, bytes: recordBytes } = fitted;
1131
1637
  const framedBytes = recordBytes + RECORD_FRAMING_BYTES;
1132
1638
  const target = Math.min(this.transport.receiverLimits().decodedBytes, BATCH_TARGET_BYTES);
1133
1639
  if (batch.length && batchBytes + framedBytes > target) {