@hue-run/sdk 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/transport.js CHANGED
@@ -5,12 +5,27 @@ import { createOtlpHttpExporterMetrics } from "@opentelemetry/otlp-exporter-base
5
5
  import { LogsExporterMetricsHelper, ProtobufLogsSerializer, ProtobufTraceSerializer, TraceExporterMetricsHelper, } from "@opentelemetry/otlp-transformer";
6
6
  import { BatchSpanProcessor, } from "@opentelemetry/sdk-trace";
7
7
  import { BatchLogRecordProcessor, } from "@opentelemetry/sdk-logs";
8
- import { isInsecureOrigin, MAX_BODY_BYTES, validateOptions } from "./config.js";
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";
10
+ import { advertisedLimits, BATCH_TARGET_BYTES, DEFAULT_RECEIVER_LIMITS, fitsWithoutCompression, } from "./limits.js";
9
11
  import { announcesLiveSpan, LIVE_SPAN_INTERVAL_MILLIS, MAX_LIVE_SPANS, pendingPlaceholder, PLACEHOLDERS_HEADER, withoutPlaceholderMarkers, } from "./live-spans.js";
10
12
  import { estimateRecordBytes } from "./safety.js";
11
13
  import { snapshotLog, snapshotSpan } from "./snapshot.js";
12
- import { DROPPED_RECORDS_KEY, isContentKey, redactLog, redactSpan, TRUNCATED_KEY, truncatedMarker, withTruncatedKeys, } from "./privacy.js";
14
+ import { DROPPED_RECORDS_KEY, isContentKey, isTruncatedMarker, redactLog, redactSpan, TRUNCATED_KEY, truncatedMarker, withTruncatedKeys, } from "./privacy.js";
13
15
  import { sdkVersion } from "./version.js";
16
+ /**
17
+ * How long one export holds its records for a receiver that refused them for their rate (HTTP 429)
18
+ * and asked to be retried later than a request's deadline allows: the records stay queued, counted
19
+ * against the queue's bounds, and are sent again once `Retry-After` has passed. A longer wait loses
20
+ * them, reported like any refused request.
21
+ */
22
+ export const MAX_RATE_LIMIT_HOLD_MILLIS = 60_000;
23
+ /** How long one export may take with requests of `timeoutMillis` each: its uploads of values over
24
+ * the inline limit, its requests, and a hold for a rate-limited receiver on top. Processors and
25
+ * providers wait this long for a drain. */
26
+ export function exportBudgetMillis(timeoutMillis) {
27
+ return uploadBudgetMillis(timeoutMillis) + timeoutMillis * 16 + 1000 + MAX_RATE_LIMIT_HOLD_MILLIS;
28
+ }
14
29
  /** Per-record allowance for protobuf length prefixes that grow when records are grouped. */
15
30
  const RECORD_FRAMING_BYTES = 64;
16
31
  function recordData(record, signal) {
@@ -101,13 +116,45 @@ export class HueTransport {
101
116
  // The running span each admitted placeholder announces, so one it has outlived is not sent.
102
117
  placeholderSources = new WeakMap();
103
118
  batchSpans;
119
+ // What the receiver accepts, as it last advertised; the limits of this release until it does.
120
+ limits = DEFAULT_RECEIVER_LIMITS;
121
+ /** The rate-limit holds in progress, each ended early by calling it. */
122
+ holds = new Set();
123
+ holdsReleased = false;
124
+ // The value strings each admitted record's snapshot cut, for its redaction.
125
+ admissionCuts = new WeakMap();
126
+ // The span's own messages whose large inline files each admitted span shrank to their digest.
127
+ admissionDigests = new WeakMap();
128
+ /** Uploads values over the inline limit apart from their spans; absent when disabled. */
129
+ uploader;
130
+ /** Bytes of values over the inline limit queued spans hold whole for upload, and each span's. */
131
+ heldBytes = 0;
132
+ held = new WeakMap();
133
+ uploaded = 0;
134
+ uploadFallbacks = 0;
104
135
  constructor(options) {
105
136
  this.options = validateOptions(options);
106
137
  Object.defineProperty(this, "options", { enumerable: false });
107
138
  this.liveSpans = this.options.enabled !== false && this.options.liveSpans;
108
139
  this.announcesLiveSpans = this.liveSpans;
109
- this.traceExporter = new ReportingExporter(this, "traces", ProtobufTraceSerializer, TraceExporterMetricsHelper, (span, cache) => redactSpan(span, this.options, cache));
110
- this.logExporter = new ReportingExporter(this, "logs", ProtobufLogsSerializer, LogsExporterMetricsHelper, (log, cache) => redactLog(log, this.options, cache));
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, {
147
+ valueBytes: this.limits.valueBytes,
148
+ cut: this.admissionCuts.get(log),
149
+ }));
150
+ if (this.options.enabled !== false)
151
+ this.uploader = new BlobUploader({
152
+ baseUrl: this.options.baseUrl,
153
+ apiKey: this.options.apiKey,
154
+ timeoutMillis: this.options.timeoutMillis,
155
+ allowInsecureHttp: this.options.allowInsecureHttp === true,
156
+ userAgent: `hue-sdk-typescript/${sdkVersion}`,
157
+ });
111
158
  if (this.options.enabled === false) {
112
159
  this.spanProcessor = { onStart() { }, onEnd() { }, async forceFlush() { }, async shutdown() { } };
113
160
  this.logRecordProcessor = { onEmit() { }, async forceFlush() { }, async shutdown() { } };
@@ -119,7 +166,8 @@ export class HueTransport {
119
166
  maxQueueSize: 2048,
120
167
  maxExportBatchSize: 128,
121
168
  scheduledDelayMillis: 1000,
122
- exportTimeoutMillis: this.options.timeoutMillis * 16 + 1000,
169
+ // An export may hold its records for a rate-limited receiver on top of its requests.
170
+ exportTimeoutMillis: exportBudgetMillis(this.options.timeoutMillis),
123
171
  };
124
172
  const spans = new BatchSpanProcessor({ exporter: this.traceExporter, ...batching });
125
173
  const logs = new BatchLogRecordProcessor({ exporter: this.logExporter, ...batching });
@@ -201,9 +249,29 @@ export class HueTransport {
201
249
  try {
202
250
  const remaining = (advisory ? this.options.maxQueueBytes / 4 : this.options.maxQueueBytes) -
203
251
  this.pendingBytes;
252
+ // A value far over the receiver's value cap is cut when it is queued, to the cap and some
253
+ // context for the redactor, rather than charged whole: export cuts it to the cap anyway,
254
+ // and its full size could drop the record before that cut ever ran. A span's own value
255
+ // that export may upload is held whole instead, within the held-value budget.
256
+ const valueUnits = this.limits.valueBytes + REDACTION_CONTEXT_UNITS;
257
+ const hold = signal === "traces" && !advisory && this.holdsValues()
258
+ ? {
259
+ valueBytes: this.limits.valueBytes,
260
+ budget: Math.max(0, MAX_HELD_BLOB_BYTES - this.heldBytes),
261
+ first: this.heldBytes === 0,
262
+ }
263
+ : undefined;
204
264
  const snapshot = signal === "traces"
205
- ? snapshotSpan(record, remaining, this.options.captureContent)
206
- : snapshotLog(record, remaining, this.options.captureContent);
265
+ ? snapshotSpan(record, remaining, this.options.captureContent, valueUnits, hold)
266
+ : snapshotLog(record, remaining, this.options.captureContent, valueUnits);
267
+ if (snapshot.cut.size)
268
+ this.admissionCuts.set(snapshot.record, snapshot.cut);
269
+ if (snapshot.digested.size)
270
+ this.admissionDigests.set(snapshot.record, snapshot.digested);
271
+ if (snapshot.held) {
272
+ this.heldBytes += snapshot.held;
273
+ this.held.set(snapshot.record, snapshot.held);
274
+ }
207
275
  this.pendingBytes += snapshot.bytes;
208
276
  if (signal === "traces")
209
277
  this.spans.set(snapshot.record, snapshot.bytes);
@@ -308,6 +376,63 @@ export class HueTransport {
308
376
  const pending = signal === "traces" ? this.spans : this.logs;
309
377
  this.pendingBytes -= pending.get(record) ?? 0;
310
378
  pending.delete(record);
379
+ const held = this.held.get(record);
380
+ if (held) {
381
+ this.heldBytes -= held;
382
+ this.held.delete(record);
383
+ }
384
+ }
385
+ }
386
+ /** @internal Whether spans' values over the inline limit are uploaded: content is captured and
387
+ * the key is a project key (setup credentials send metadata only). A value that then cannot be
388
+ * uploaded is cut, reported and counted. */
389
+ offloadsValues() {
390
+ return (this.uploader !== undefined &&
391
+ this.options.captureContent &&
392
+ !this.options.apiKey.startsWith("hue_setup_"));
393
+ }
394
+ /** @internal Whether values over the inline limit are worth holding whole for upload now: they
395
+ * are uploaded, and the receiver has not shown it lacks the upload route. */
396
+ holdsValues() {
397
+ return this.offloadsValues() && this.uploader?.available() === true;
398
+ }
399
+ /** @internal Exporter callback: a redacted span with the values its redaction left to the
400
+ * upload step placed: uploaded and listed under `hue.blobs`, or cut as before. Never rejects. */
401
+ 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
+ }
409
+ const valueBytes = this.limits.valueBytes;
410
+ try {
411
+ if (!this.uploader)
412
+ throw new Error("Uploads are unavailable");
413
+ const attributes = await offloadAttributes(span.attributes, collector, {
414
+ uploader: this.uploader,
415
+ traceId,
416
+ valueBytes,
417
+ upload,
418
+ tally,
419
+ });
420
+ return { ...span, attributes: attributes };
421
+ }
422
+ catch {
423
+ return {
424
+ ...span,
425
+ attributes: fallbackAttributes(span.attributes, collector, valueBytes, tally, traceId),
426
+ };
427
+ }
428
+ }
429
+ /** @internal Exporter callback: counts an export's uploads and reports each kind of fallback
430
+ * once, as a warning naming its traces: the values were exported, cut as before. */
431
+ reportOffload(tally) {
432
+ this.uploaded += tally.uploaded;
433
+ for (const [reason, { count, traceIds }] of tally.fallbacks) {
434
+ this.uploadFallbacks += count;
435
+ this.issue("traces", "warning", count, FALLBACK_MESSAGES[reason], undefined, traceIds.size && traceIds.size <= MAX_ISSUE_TRACE_IDS ? [...traceIds] : undefined);
311
436
  }
312
437
  }
313
438
  /** @internal Exporter callback: counts records the collector acknowledged. */
@@ -315,8 +440,8 @@ export class HueTransport {
315
440
  this.accepted[signal] += count;
316
441
  }
317
442
  /** @internal Exporter callback: a trace's root span carries the count of its records the SDK
318
- * never sent, read when the root is exported (a record lost after that is not counted: the
319
- * root has left). The count stays until the request carrying the root is acknowledged
443
+ * never sent, read when the request carrying the root is made (a record lost after that is
444
+ * not counted: the root has left). The count stays until that request is acknowledged
320
445
  * (`consumeDroppedRecords`): a root written again under retry says the same, and a root whose
321
446
  * request failed has not told Hue, so its count is not forgotten with it. */
322
447
  withDroppedRecords(span) {
@@ -327,11 +452,69 @@ export class HueTransport {
327
452
  return span;
328
453
  return { ...span, attributes: { ...span.attributes, [DROPPED_RECORDS_KEY]: count } };
329
454
  }
330
- /** @internal Exporter callback: these traces' counts reached Hue on their roots, so the map
331
- * holds nothing for a trace that has ended and told its losses. */
332
- consumeDroppedRecords(traceIds) {
333
- for (const traceId of traceIds)
334
- this.droppedByTrace.delete(traceId);
455
+ /** @internal Exporter callback: these counts reached Hue on their traces' roots. Only what a
456
+ * root carried is forgotten: a record lost while its request was in flight is still counted. */
457
+ consumeDroppedRecords(counts) {
458
+ for (const [traceId, count] of counts) {
459
+ const left = (this.droppedByTrace.get(traceId) ?? 0) - count;
460
+ if (left > 0)
461
+ this.droppedByTrace.set(traceId, left);
462
+ else
463
+ this.droppedByTrace.delete(traceId);
464
+ }
465
+ }
466
+ /** @internal Exporter callback: records Hue refused for their rate for longer than an export
467
+ * holds them. The receiver stored none of them, so each is counted on its trace's root, as a
468
+ * record never sent is. */
469
+ countUndelivered(records) {
470
+ for (const record of records) {
471
+ const traceId = traceIdsOf([record])?.[0];
472
+ if (traceId)
473
+ this.countDropped(traceId, 1);
474
+ }
475
+ }
476
+ /** @internal Exporter callback: waits `millis` for a receiver that limited the rate, unless
477
+ * the holds are released first; true when the wait ran its course. */
478
+ holdForRateLimit(millis) {
479
+ if (this.holdsReleased)
480
+ return Promise.resolve(false);
481
+ return new Promise((resolve) => {
482
+ const release = () => {
483
+ clearTimeout(timer);
484
+ this.holds.delete(release);
485
+ resolve(false);
486
+ };
487
+ const timer = setTimeout(() => {
488
+ this.holds.delete(release);
489
+ resolve(true);
490
+ }, millis);
491
+ this.holds.add(release);
492
+ });
493
+ }
494
+ /** @internal Ends every rate-limit hold now and refuses new ones, once a shutdown's caller has
495
+ * stopped waiting: the held records are reported lost to the rate limit, and no timer of theirs
496
+ * keeps the process running for the receiver's `Retry-After`. Uploads in flight end too, and no
497
+ * other starts: their values are exported cut, as before uploads existed. */
498
+ releaseRateLimitHolds() {
499
+ this.holdsReleased = true;
500
+ for (const release of [...this.holds])
501
+ release();
502
+ this.uploader?.close();
503
+ }
504
+ /** @internal Exporter callback: what the receiver accepts, as it last advertised. */
505
+ receiverLimits() {
506
+ return this.limits;
507
+ }
508
+ /** @internal Exporter callback: adopts the limits a response advertised, clamped to their
509
+ * ranges; later requests, admissions and value cuts follow them. */
510
+ adoptLimits(headers) {
511
+ this.limits = advertisedLimits(this.limits, headers);
512
+ }
513
+ /** Bounded: a trace whose root never arrives (an abandoned run) gives way to newer ones. */
514
+ countDropped(traceId, count) {
515
+ if (this.droppedByTrace.size >= 1024 && !this.droppedByTrace.has(traceId))
516
+ this.droppedByTrace.delete(this.droppedByTrace.keys().next().value);
517
+ this.droppedByTrace.set(traceId, (this.droppedByTrace.get(traceId) ?? 0) + count);
335
518
  }
336
519
  /** @internal Records a sanitized issue, updates counters and rate-limits the diagnostic callback. */
337
520
  issue(signal, kind, count, message, status, traceIds) {
@@ -343,13 +526,8 @@ export class HueTransport {
343
526
  this.failed[signal] += count;
344
527
  // A record lost before any request names its one trace; a failed batch is retried or
345
528
  // reported as delivery, not counted here.
346
- if ((kind === "dropped" || kind === "invalid") && count > 0 && traceIds?.length === 1) {
347
- const [traceId] = traceIds;
348
- // Bounded: a trace whose root never arrives (an abandoned run) gives way to newer ones.
349
- if (this.droppedByTrace.size >= 1024 && !this.droppedByTrace.has(traceId))
350
- this.droppedByTrace.delete(this.droppedByTrace.keys().next().value);
351
- this.droppedByTrace.set(traceId, (this.droppedByTrace.get(traceId) ?? 0) + count);
352
- }
529
+ if ((kind === "dropped" || kind === "invalid") && count > 0 && traceIds?.length === 1)
530
+ this.countDropped(traceIds[0], count);
353
531
  const issue = {
354
532
  sequence: ++this.sequence,
355
533
  signal,
@@ -407,6 +585,8 @@ export class HueTransport {
407
585
  droppedLogs: this.dropped.logs,
408
586
  pendingBytes: this.pendingBytes,
409
587
  instrumentationFailures: this.instrumentationFailures,
588
+ uploadedValues: this.uploaded,
589
+ uploadFallbacks: this.uploadFallbacks,
410
590
  };
411
591
  }
412
592
  /** Copies of the latest 128 sanitized issues, oldest first. */
@@ -536,83 +716,96 @@ function valueBytes(value) {
536
716
  }
537
717
  return 8;
538
718
  }
539
- /** Whether a value is already the receiver's marker for a value that was shed. */
540
- function isTruncatedMarker(value) {
541
- return (value !== null &&
542
- typeof value === "object" &&
543
- !Array.isArray(value) &&
544
- value[TRUNCATED_KEY] === true);
545
- }
719
+ /** A value smaller than this would grow its record if the receiver's marker replaced it. */
720
+ const MIN_SHED_BYTES = 64;
546
721
  /**
547
- * The record with its largest content value (a log's body before its attributes) replaced by
548
- * the receiver's marker and listed under `hue.truncated`; undefined when no content value is
549
- * left to shed. Metadata is never shed: a record too large without its content is lost whole.
722
+ * The content values a record over the receiver's limits may shed, in the order it sheds them: a
723
+ * log's body first, then the largest values first. Metadata is never shed: a record too large
724
+ * without its content is lost whole.
550
725
  */
551
- function shedLargestContent(record, signal) {
552
- if (signal === "logs") {
553
- const log = record;
554
- if (log.body !== undefined && !isTruncatedMarker(log.body)) {
555
- const bytes = valueBytes(log.body);
556
- const attributes = {
557
- ...log.attributes,
558
- [TRUNCATED_KEY]: withTruncatedKeys(log.attributes[TRUNCATED_KEY], ["body"]),
559
- };
560
- return { ...log, body: truncatedMarker(bytes), attributes };
561
- }
562
- }
563
- const { attributes } = record;
564
- // The record's own content values, and a span's events' (listed as the receiver lists an
565
- // event's cut: `event:<name>:<key>`).
566
- const events = signal === "traces" ? record.events : [];
567
- const links = signal === "traces" ? record.links : [];
568
- let largest;
726
+ function sheddableContent(record, signal) {
727
+ const found = [];
569
728
  const consider = (where, index, key, value) => {
570
729
  if (!isContentKey(key) || isTruncatedMarker(value))
571
730
  return;
572
731
  const bytes = valueBytes(value);
573
- if (!largest || bytes > largest.bytes)
574
- largest = { where, index, key, bytes };
732
+ if (bytes >= MIN_SHED_BYTES)
733
+ found.push({ where, index, key, bytes });
575
734
  };
576
- for (const [key, value] of Object.entries(attributes))
735
+ for (const [key, value] of Object.entries(record.attributes))
577
736
  consider("own", 0, key, value);
578
- events.forEach((event, index) => {
579
- for (const [key, value] of Object.entries(event.attributes ?? {}))
580
- consider("event", index, key, value);
581
- });
582
- links.forEach((link, index) => {
583
- for (const [key, value] of Object.entries(link.attributes ?? {}))
584
- consider("link", index, key, value);
585
- });
586
- if (!largest)
587
- return undefined;
588
- const found = largest;
589
- // Listed as the receiver lists a cut of the same place: the key alone, `event:<name>:<key>`,
590
- // or `link:<key>`.
591
- const listed = found.where === "own"
592
- ? found.key
593
- : found.where === "event"
594
- ? `event:${events[found.index].name}:${found.key}`
595
- : `link:${found.key}`;
596
- const shed = {
597
- ...attributes,
598
- ...(found.where === "own" ? { [found.key]: truncatedMarker(found.bytes) } : {}),
599
- [TRUNCATED_KEY]: withTruncatedKeys(attributes[TRUNCATED_KEY], [listed]),
600
- };
601
- const marker = { [found.key]: truncatedMarker(found.bytes) };
602
- if (found.where === "own")
603
- return { ...record, attributes: shed };
604
- if (found.where === "event")
737
+ if (signal === "traces") {
738
+ const span = record;
739
+ span.events.forEach((event, index) => {
740
+ for (const [key, value] of Object.entries(event.attributes ?? {}))
741
+ consider("event", index, key, value);
742
+ });
743
+ span.links.forEach((link, index) => {
744
+ for (const [key, value] of Object.entries(link.attributes ?? {}))
745
+ consider("link", index, key, value);
746
+ });
747
+ }
748
+ // Stable: of values of one size, the record's own come first, then its events' and links'.
749
+ found.sort((a, b) => b.bytes - a.bytes);
750
+ const log = record;
751
+ if (signal === "logs" && log.body !== undefined && !isTruncatedMarker(log.body))
752
+ found.unshift({ where: "body", index: 0, key: "body", bytes: valueBytes(log.body) });
753
+ return found;
754
+ }
755
+ /**
756
+ * The record with `shed` replaced by the receiver's marker, each listed under `hue.truncated` as
757
+ * the receiver lists a cut of the same place: the key alone, `body`, `event:<name>:<key>` or
758
+ * `link:<key>`.
759
+ */
760
+ function shedContent(record, signal, shed) {
761
+ const own = { ...record.attributes };
762
+ const events = new Map();
763
+ const links = new Map();
764
+ const span = record;
765
+ const listed = [];
766
+ let body;
767
+ for (const value of shed) {
768
+ const marker = truncatedMarker(value.bytes);
769
+ if (value.where === "body") {
770
+ body = marker;
771
+ listed.push("body");
772
+ }
773
+ else if (value.where === "own") {
774
+ own[value.key] = marker;
775
+ listed.push(value.key);
776
+ }
777
+ else {
778
+ const target = value.where === "event" ? events : links;
779
+ const markers = target.get(value.index) ?? {};
780
+ markers[value.key] = marker;
781
+ target.set(value.index, markers);
782
+ listed.push(value.where === "event"
783
+ ? `event:${span.events[value.index].name}:${value.key}`
784
+ : `link:${value.key}`);
785
+ }
786
+ }
787
+ own[TRUNCATED_KEY] = withTruncatedKeys(record.attributes[TRUNCATED_KEY], listed);
788
+ if (signal === "logs")
605
789
  return {
606
790
  ...record,
607
- attributes: shed,
608
- events: events.map((event, index) => index === found.index
609
- ? { ...event, attributes: { ...event.attributes, ...marker } }
610
- : event),
791
+ ...(body === undefined ? {} : { body }),
792
+ attributes: own,
611
793
  };
612
794
  return {
613
795
  ...record,
614
- attributes: shed,
615
- links: links.map((link, index) => index === found.index ? { ...link, attributes: { ...link.attributes, ...marker } } : link),
796
+ attributes: own,
797
+ events: events.size
798
+ ? span.events.map((event, index) => {
799
+ const markers = events.get(index);
800
+ return markers ? { ...event, attributes: { ...event.attributes, ...markers } } : event;
801
+ })
802
+ : span.events,
803
+ links: links.size
804
+ ? span.links.map((link, index) => {
805
+ const markers = links.get(index);
806
+ return markers ? { ...link, attributes: { ...link.attributes, ...markers } } : link;
807
+ })
808
+ : span.links,
616
809
  };
617
810
  }
618
811
  class ReportingExporter {
@@ -688,6 +881,22 @@ class ReportingExporter {
688
881
  }
689
882
  };
690
883
  const resourceDeadline = Date.now() + this.transport.options.timeoutMillis;
884
+ // How long this export has held its records for a rate-limited receiver, its value uploads'
885
+ // waits included.
886
+ const hold = { millis: 0 };
887
+ // A span's values over the inline limit are uploaded before it is measured, within the
888
+ // export's upload budget; a placeholder's never are.
889
+ const upload = {
890
+ deadline: Date.now() + uploadBudgetMillis(this.transport.options.timeoutMillis),
891
+ wait: async (millis) => {
892
+ if (hold.millis + millis > MAX_RATE_LIMIT_HOLD_MILLIS)
893
+ return false;
894
+ hold.millis += millis;
895
+ return this.transport.holdForRateLimit(millis);
896
+ },
897
+ };
898
+ const tally = newTally();
899
+ const placed = [];
691
900
  for (const { record, markers } of this.ordered(records)) {
692
901
  try {
693
902
  const ready = record.resource.waitForAsyncAttributes?.();
@@ -705,7 +914,25 @@ class ReportingExporter {
705
914
  clearTimeout(timer);
706
915
  }
707
916
  }
708
- let redacted = this.redact(record, cache);
917
+ const offload = !markers && this.signal === "traces" && this.transport.offloadsValues()
918
+ ? { candidates: [], cut: [] }
919
+ : undefined;
920
+ const redacted = this.redact(record, cache, offload);
921
+ placed.push({
922
+ record,
923
+ ...(markers ? { markers } : {}),
924
+ redacted: offload && (offload.candidates.length || offload.cut.length)
925
+ ? this.transport.offloadSpan(redacted, offload, upload, tally)
926
+ : redacted,
927
+ });
928
+ }
929
+ catch {
930
+ invalid(markers !== undefined, "Telemetry record could not be redacted or exceeds supported content limits", record);
931
+ }
932
+ }
933
+ for (const { record, markers, redacted: placing } of placed) {
934
+ try {
935
+ let redacted = await placing;
709
936
  // Added after redaction, so a redactor cannot alter the markers or the parent identity.
710
937
  if (markers)
711
938
  redacted = {
@@ -729,106 +956,191 @@ class ReportingExporter {
729
956
  invalid(markers !== undefined, "Telemetry record could not be redacted or exceeds supported content limits", record);
730
957
  }
731
958
  }
732
- // Each record is encoded once to measure it; a request is encoded once more when it is sent.
733
- // Records sharing a resource and scope are grouped on the wire, so the sum of the individual
734
- // encodings plus a fixed framing margin bounds the request size. Room is left for gzip
735
- // headers/blocks when otherwise incompressible data is near the wire cap.
736
- const limit = MAX_BODY_BYTES - 1024;
737
- const measure = (record) => this.serializer.serializeRequest([record])?.byteLength ?? Infinity;
738
- /** The record, shed until it fits the request limit, and its size; undefined when no content
739
- * is left to shed. */
740
- const fit = (record) => {
741
- let bytes = measure(record);
742
- // A record over the request limit sheds its content values, largest first, each
743
- // replaced by the receiver's marker and listed under `hue.truncated`, until it fits:
744
- // the span and what it still holds reach Hue, and a reader sees what was shed. Only a
745
- // record too large without any content value is lost, and counted on its root.
746
- while (bytes > limit) {
747
- const shed = shedLargestContent(record, this.signal);
748
- if (!shed)
749
- return undefined;
750
- record = shed;
751
- bytes = measure(record);
959
+ // Each record is encoded once to measure it, and compressed only when its encoding alone
960
+ // could be over the wire limit; a request is encoded and compressed once more to be sent, and
961
+ // that request is measured against the receiver's limits before it goes. Records sharing a
962
+ // resource and scope are grouped on the wire, so the sum of the individual encodings plus a
963
+ // fixed framing margin bounds a request's size before compression.
964
+ const encode = (batch) => {
965
+ const encoded = this.serializer.serializeRequest(batch);
966
+ if (!encoded)
967
+ throw new Error("Telemetry record could not be serialized");
968
+ return encoded;
969
+ };
970
+ // A record measured alone, compressed: reused when it is sent alone.
971
+ const measured = new Map();
972
+ /** The record as one request, when that request fits the receiver's limits: its encoding
973
+ * after decompression, and after gzip on the wire, compressed only when it could be over. */
974
+ const probe = async (record) => {
975
+ const data = encode([record]);
976
+ const limits = this.transport.receiverLimits();
977
+ if (data.byteLength > limits.decodedBytes)
978
+ return undefined;
979
+ if (fitsWithoutCompression(data.byteLength, limits.requestBytes))
980
+ return { record, bytes: data.byteLength };
981
+ const request = { data, compressed: await compress(data) };
982
+ // Read again: the other signal's export may have adopted lower limits meanwhile.
983
+ if (!fitsLimits(request, this.transport.receiverLimits()))
984
+ return undefined;
985
+ return { record, bytes: data.byteLength, request };
986
+ };
987
+ /** The record, shed until a request of it alone fits the receiver's limits, and its encoded
988
+ * size; undefined when it cannot fit even with every content value shed. */
989
+ const fit = async (record) => {
990
+ // A record over a limit sheds its content values, largest first, each replaced by the
991
+ // receiver's marker and listed under `hue.truncated`: the span and what it still holds
992
+ // reach Hue, and a reader sees what was shed. Only a record too large without any content
993
+ // value is lost, and counted on its root.
994
+ let fitted = await probe(record);
995
+ if (!fitted) {
996
+ const sheddable = sheddableContent(record, this.signal);
997
+ const shedding = (count) => probe(shedContent(record, this.signal, sheddable.slice(0, count)));
998
+ // The fewest values to shed are found by trying a doubling count of the largest, then
999
+ // halving the range between the last count that did not fit and the first that did:
1000
+ // encodings and compressions logarithmic in the values shed, where shedding one value
1001
+ // per encoding took time quadratic in them.
1002
+ let failed = 0;
1003
+ let fits = 0;
1004
+ for (let step = 1; !fitted; step *= 2) {
1005
+ if (failed >= sheddable.length)
1006
+ return undefined;
1007
+ fits = Math.min(sheddable.length, failed + step);
1008
+ fitted = await shedding(fits);
1009
+ if (!fitted)
1010
+ failed = fits;
1011
+ }
1012
+ while (fits - failed > 1) {
1013
+ const gap = fits - failed;
1014
+ const middle = failed + Math.floor(gap / 2);
1015
+ const result = await shedding(middle);
1016
+ if (result) {
1017
+ fits = middle;
1018
+ fitted = result;
1019
+ }
1020
+ else
1021
+ failed = middle;
1022
+ }
752
1023
  }
753
- return { record, bytes };
1024
+ if (fitted.request)
1025
+ measured.set(fitted.record, fitted.request);
1026
+ return { record: fitted.record, bytes: fitted.bytes };
754
1027
  };
755
- /** Sends one kind of record in requests up to the limit: completed records, whose roots carry
756
- * their traces' dropped-record counts, or placeholders alone (`advisory`). */
757
- const pack = async (accepted, advisory) => {
758
- let batch = [];
759
- let batchBytes = 0;
760
- // The traces whose root in the batch carries its dropped-record count, consumed once the
761
- // request that carries it is acknowledged; a failed request keeps the count.
762
- let counted = [];
763
- const send = async () => {
764
- const records = batch;
765
- const traces = counted;
766
- batch = [];
767
- batchBytes = 0;
768
- counted = [];
769
- // A request of placeholders never fails the export, and none follows an acknowledgement
770
- // that turned live spans off.
771
- if (advisory && !this.transport.sendsPlaceholders())
1028
+ /** A request of `batch`, encoded and compressed, when it fits the receiver's limits. */
1029
+ const request = async (batch) => {
1030
+ const known = batch.length === 1 ? measured.get(batch[0]) : undefined;
1031
+ const data = known?.data ?? encode(batch);
1032
+ if (data.byteLength > this.transport.receiverLimits().decodedBytes)
1033
+ return undefined;
1034
+ const body = { data, compressed: known?.compressed ?? (await compress(data)) };
1035
+ // Measured against the limits as they stand once it is compressed: the other signal's
1036
+ // export may have adopted lower ones meanwhile, and each attempt checks them again.
1037
+ return fitsLimits(body, this.transport.receiverLimits()) ? body : undefined;
1038
+ };
1039
+ this.transport.reportOffload(tally);
1040
+ /** Sends one kind of record (completed records, or placeholders alone: `advisory`) in one
1041
+ * request, or in halves when it is over the receiver's limits. A trace's root among completed
1042
+ * records carries the trace's dropped-record count as it stands when its request is made, so
1043
+ * it includes losses earlier requests of this export met; the acknowledgement consumes
1044
+ * exactly the counts the request carried, and a failed request keeps them. */
1045
+ const deliver = async (records, advisory, refitted = false) => {
1046
+ // A request of placeholders never fails the export, and none follows an acknowledgement
1047
+ // that turned live spans off.
1048
+ if (advisory && !this.transport.sendsPlaceholders())
1049
+ return;
1050
+ const carried = new Map();
1051
+ const batch = advisory || this.signal !== "traces"
1052
+ ? records
1053
+ : records.map((record) => {
1054
+ const counted = this.transport.withDroppedRecords(record);
1055
+ const count = counted.attributes[DROPPED_RECORDS_KEY];
1056
+ if (counted !== record && typeof count === "number")
1057
+ carried.set(counted.spanContext().traceId, count);
1058
+ return counted;
1059
+ });
1060
+ let body;
1061
+ try {
1062
+ body = await request(batch);
1063
+ }
1064
+ catch {
1065
+ for (const record of batch)
1066
+ invalid(advisory, "Telemetry record could not be serialized", record);
1067
+ return;
1068
+ }
1069
+ if (!body) {
1070
+ // Records that compress worse together than the batch target assumed travel in halves.
1071
+ if (batch.length > 1) {
1072
+ const half = Math.ceil(batch.length / 2);
1073
+ await deliver(batch.slice(0, half), advisory);
1074
+ await deliver(batch.slice(half), advisory);
1075
+ return;
1076
+ }
1077
+ // One record over limits the receiver lowered after it was measured: shed it again.
1078
+ const [record] = batch;
1079
+ let fitted;
1080
+ try {
1081
+ fitted = refitted ? undefined : await fit(record);
1082
+ }
1083
+ catch {
1084
+ invalid(advisory, "Telemetry record could not be serialized", record);
772
1085
  return;
773
- if (await this.send(records, advisory ? records.length : 0)) {
774
- this.transport.consumeDroppedRecords(traces);
775
1086
  }
776
- else if (!advisory) {
777
- failed = true;
778
- refused = true;
1087
+ if (!fitted) {
1088
+ invalid(advisory, OVER_REQUEST_LIMIT, record);
1089
+ return;
779
1090
  }
780
- };
1091
+ await deliver([fitted.record], advisory, true);
1092
+ return;
1093
+ }
1094
+ const sent = await this.send(batch, advisory ? batch.length : 0, body, hold);
1095
+ // The receiver lowered its limits below this request while it was sent or held: the
1096
+ // records go back to be split and shed under the new limits.
1097
+ if (sent === "refit")
1098
+ await deliver(batch, advisory);
1099
+ else if (sent)
1100
+ this.transport.consumeDroppedRecords(carried);
1101
+ else if (!advisory) {
1102
+ failed = true;
1103
+ refused = true;
1104
+ }
1105
+ };
1106
+ /** Sends one kind of record in requests: completed records, whose roots carry their traces'
1107
+ * dropped-record counts, or placeholders alone (`advisory`). Requests are batched to the
1108
+ * ordinary target size; a record larger than it travels alone, up to the receiver's limits. */
1109
+ const pack = async (accepted, advisory) => {
781
1110
  // Every record is measured, shed and, where it cannot be sent, counted lost before any root
782
1111
  // is written, so a root carries the losses of children that ended after it.
783
1112
  const prepared = [];
784
1113
  for (const record of accepted) {
785
1114
  let fitted;
786
1115
  try {
787
- fitted = fit(record);
1116
+ fitted = await fit(record);
788
1117
  }
789
1118
  catch {
790
1119
  invalid(advisory, "Telemetry record could not be serialized", record);
791
1120
  continue;
792
1121
  }
793
1122
  if (!fitted) {
794
- invalid(advisory, "Telemetry record exceeds the 1 MiB request limit", record);
1123
+ invalid(advisory, OVER_REQUEST_LIMIT, record);
795
1124
  continue;
796
1125
  }
797
1126
  prepared.push(fitted);
798
1127
  }
799
- for (const entry of prepared) {
800
- let { record, bytes: recordBytes } = entry;
801
- let carriesCount = false;
802
- if (this.signal === "traces" && !advisory) {
803
- const withCount = this.transport.withDroppedRecords(record);
804
- if (withCount !== record) {
805
- carriesCount = true;
806
- // The count may put a root shed to just under the limit over it again: shed on.
807
- let fitted;
808
- try {
809
- fitted = fit(withCount);
810
- }
811
- catch {
812
- invalid(false, "Telemetry record could not be serialized", withCount);
813
- continue;
814
- }
815
- if (!fitted) {
816
- invalid(false, "Telemetry record exceeds the 1 MiB request limit", withCount);
817
- continue;
818
- }
819
- ({ record, bytes: recordBytes } = fitted);
820
- }
821
- }
1128
+ let batch = [];
1129
+ let batchBytes = 0;
1130
+ for (const { record, bytes: recordBytes } of prepared) {
822
1131
  const framedBytes = recordBytes + RECORD_FRAMING_BYTES;
823
- if (batch.length && batchBytes + framedBytes > limit)
824
- await send();
1132
+ const target = Math.min(this.transport.receiverLimits().decodedBytes, BATCH_TARGET_BYTES);
1133
+ if (batch.length && batchBytes + framedBytes > target) {
1134
+ const full = batch;
1135
+ batch = [];
1136
+ batchBytes = 0;
1137
+ await deliver(full, advisory);
1138
+ }
825
1139
  batch.push(record);
826
1140
  batchBytes += framedBytes;
827
- if (carriesCount)
828
- counted.push(record.spanContext().traceId);
829
1141
  }
830
1142
  if (batch.length)
831
- await send();
1143
+ await deliver(batch, advisory);
832
1144
  };
833
1145
  await pack(real, false);
834
1146
  // None is sent once a receiver answered without placeholder support, which the completed
@@ -840,21 +1152,54 @@ class ReportingExporter {
840
1152
  if (failed)
841
1153
  throw new Error("Hue telemetry export failed");
842
1154
  }
1155
+ /** Records a lost request: completed records count as failed; placeholders, advisory, as a
1156
+ * warning. True when the request held placeholders only, whose loss never fails an export. */
1157
+ lost(records, placeholders, message, status) {
1158
+ const real = records.length - placeholders;
1159
+ this.transport.issue(this.signal, real ? "failed" : "warning", real || placeholders, real ? message : `${message} (in-progress span placeholders only)`, status, traceIdsOf(records));
1160
+ return !real;
1161
+ }
843
1162
  /**
844
- * Sends one request: of completed records, or of placeholders alone when `placeholders` is
845
- * `records.length`. Placeholders are advisory and never count as lost.
1163
+ * Sends one request, of completed records or of placeholders alone when `placeholders` is
1164
+ * `records.length`. A receiver that refuses completed records for their rate (HTTP 429) and
1165
+ * asks to be retried later than the request's deadline allows gets them again after its
1166
+ * `Retry-After`, while the export's hold stays within {@link MAX_RATE_LIMIT_HOLD_MILLIS}; the
1167
+ * records stay queued meanwhile. Placeholders are advisory: never held, never counted as lost.
1168
+ * `"refit"` when the receiver lowered its limits below the request before it was accepted.
846
1169
  */
847
- async send(records, placeholders = 0) {
1170
+ async send(records, placeholders, body, hold) {
1171
+ for (;;) {
1172
+ const outcome = await this.attempt(records, placeholders, body);
1173
+ if (typeof outcome === "boolean" || outcome === "refit")
1174
+ return outcome;
1175
+ const wait = Math.max(0, outcome.retryAfterMillis);
1176
+ if (placeholders || hold.millis + wait > MAX_RATE_LIMIT_HOLD_MILLIS) {
1177
+ // Hue stored none of them: each counts on its trace's root, as a record never sent does.
1178
+ if (!placeholders)
1179
+ this.transport.countUndelivered(records);
1180
+ return this.lost(records, placeholders, `Hue limited the telemetry rate for longer than an export holds its records (${MAX_RATE_LIMIT_HOLD_MILLIS / 1000} s)`, 429);
1181
+ }
1182
+ hold.millis += wait;
1183
+ if (!(await this.transport.holdForRateLimit(wait))) {
1184
+ this.transport.countUndelivered(records);
1185
+ return this.lost(records, 0, "Hue limited the telemetry rate until after the client was shut down", 429);
1186
+ }
1187
+ // A receiver may lower its limits while it holds the records off: a request now over them
1188
+ // is not sent again as it is.
1189
+ if (!fitsLimits(body, this.transport.receiverLimits()))
1190
+ return "refit";
1191
+ }
1192
+ }
1193
+ /** One request with its own deadline and retries: whether it was accepted (or held placeholders
1194
+ * only), or how long a receiver that refused it for its rate asked to wait past the deadline. */
1195
+ async attempt(records, placeholders, body) {
848
1196
  const options = this.transport.options;
849
1197
  const real = records.length - placeholders;
850
1198
  // The traces this batch carries, spans or the log records emitted in their spans, so a loss
851
1199
  // or rejection names whose telemetry it was.
852
1200
  const traceIds = traceIdsOf(records);
853
1201
  // Losing a request of placeholders is a warning; losing one of completed records counts them.
854
- const lose = (message, status) => {
855
- this.transport.issue(this.signal, real ? "failed" : "warning", real || placeholders, real ? message : `${message} (in-progress span placeholders only)`, status, traceIds);
856
- return !real;
857
- };
1202
+ const lose = (message, status) => this.lost(records, placeholders, message, status);
858
1203
  let rejected = 0;
859
1204
  let validResponse = true;
860
1205
  let receivedResponse = false;
@@ -863,7 +1208,8 @@ class ReportingExporter {
863
1208
  const deadline = Date.now() + options.timeoutMillis;
864
1209
  let timer;
865
1210
  const serializer = {
866
- serializeRequest: (data) => this.serializer.serializeRequest(data),
1211
+ // The request was encoded, and measured against the receiver's limits, before it was sent.
1212
+ serializeRequest: (data) => data === records ? body.data : this.serializer.serializeRequest(data),
867
1213
  deserializeResponse: (bytes) => {
868
1214
  if (expired)
869
1215
  return {};
@@ -919,7 +1265,9 @@ class ReportingExporter {
919
1265
  "User-Agent": `hue-sdk-typescript/${sdkVersion} ${OTEL_USER_AGENT}`,
920
1266
  }, (headers) => {
921
1267
  acceptsPlaceholders = headers[PLACEHOLDERS_HEADER] === "1";
922
- });
1268
+ },
1269
+ // Every response, whatever its status, advertises the receiver's limits.
1270
+ (headers) => this.transport.adoptLimits(headers), body, () => fitsLimits(body, this.transport.receiverLimits()));
923
1271
  const delegate = createOtlpNetworkExportDelegate({ timeoutMillis: options.timeoutMillis, concurrencyLimit: 1, compression: "gzip" }, serializer, createOtlpHttpExporterMetrics(this.signal === "traces" ? "otlp_http_span_exporter" : "otlp_http_log_exporter", this.metrics, endpoint, undefined), transport);
924
1272
  const exporter = new OTLPExporterBase(delegate);
925
1273
  try {
@@ -939,6 +1287,13 @@ class ReportingExporter {
939
1287
  return validResponse || !real;
940
1288
  }
941
1289
  else {
1290
+ // Not retried as it is: the receiver lowered its limits below this request.
1291
+ if (!expired && transport.overLimits)
1292
+ return "refit";
1293
+ // Refused for its rate, with a Retry-After past this request's deadline: the caller
1294
+ // decides whether to hold the records for it.
1295
+ if (!expired && transport.rateLimitedFor !== undefined)
1296
+ return { retryAfterMillis: transport.rateLimitedFor };
942
1297
  const status = result.error instanceof OTLPExporterError && Number.isInteger(result.error.code)
943
1298
  ? result.error.code
944
1299
  : undefined;
@@ -1002,53 +1357,93 @@ function networkFailure(error) {
1002
1357
  function compress(data) {
1003
1358
  return new Promise((resolve, reject) => gzip(data, (error, result) => (error ? reject(error) : resolve(result))));
1004
1359
  }
1360
+ /** Whether a request is within the receiver's limits after decompression and on the wire. */
1361
+ function fitsLimits(body, limits) {
1362
+ return (body.data.byteLength <= limits.decodedBytes && body.compressed.byteLength <= limits.requestBytes);
1363
+ }
1364
+ /** Why a record no larger than any content value it could shed still cannot be sent. */
1365
+ const OVER_REQUEST_LIMIT = "Telemetry record exceeds Hue's request limit without its content values";
1005
1366
  /**
1006
1367
  * Gzip-compressed OTLP/HTTP POSTs that never follow redirects, retried like OpenTelemetry's
1007
1368
  * exporter within the request budget. `onSuccess` receives the headers of the 2xx response whose
1008
- * body becomes the acknowledgement, before that body is decoded. One instance serves a single
1009
- * export request: its connections are never reused, and `abort()` closes them at the export
1010
- * deadline.
1369
+ * body becomes the acknowledgement, before that body is decoded; `onResponse` the headers of every
1370
+ * response. One instance serves a single export request: its connections are never reused, and
1371
+ * `abort()` closes them at the export deadline.
1011
1372
  */
1012
1373
  class OtlpHttpTransport {
1013
1374
  url;
1014
1375
  headers;
1015
1376
  onSuccess;
1377
+ onResponse;
1378
+ body;
1379
+ fits;
1016
1380
  // Requests in flight and how to settle each, so an abort never leaves an attempt pending.
1017
1381
  requests = new Map();
1018
1382
  agent;
1019
1383
  aborted = false;
1020
- constructor(url, headers, onSuccess) {
1384
+ /** The HTTP status of the latest response, undefined when the latest attempt had none. */
1385
+ lastStatus;
1386
+ /** Set when the receiver refused the request for its rate (HTTP 429) and asked to be retried
1387
+ * later than the request's deadline allows: how long it asked to wait, in milliseconds. */
1388
+ rateLimitedFor;
1389
+ /** Set when a retryable response advertised limits the request no longer fits: it is not
1390
+ * retried as it is. */
1391
+ overLimits = false;
1392
+ constructor(url, headers, onSuccess, onResponse = () => { }, body, fits = () => true) {
1021
1393
  this.url = url;
1022
1394
  this.headers = headers;
1023
1395
  this.onSuccess = onSuccess;
1396
+ this.onResponse = onResponse;
1397
+ this.body = body;
1398
+ this.fits = fits;
1024
1399
  }
1025
1400
  async send(data, timeoutMillis) {
1026
1401
  const deadline = Date.now() + timeoutMillis;
1027
1402
  let backoff = INITIAL_BACKOFF_MILLIS;
1403
+ // Limits another export adopted since the request was measured are honoured from the first
1404
+ // attempt: the request goes back to be split or shed instead.
1405
+ if (!this.fits()) {
1406
+ this.overLimits = true;
1407
+ return { status: "failure", error: new Error("Request exceeds the receiver's limits") };
1408
+ }
1028
1409
  let result = await this.attempt(data, timeoutMillis);
1029
1410
  for (let retries = MAX_RETRIES; result.status === "retryable" && retries > 0; retries--) {
1030
1411
  const jitter = Math.random() * 2 * JITTER - JITTER;
1031
1412
  const wait = result.retryInMillis ?? Math.max(Math.min(backoff * (1 + jitter), MAX_BACKOFF_MILLIS), 0);
1032
1413
  backoff *= BACKOFF_MULTIPLIER;
1033
- // Return when the next attempt would start after the export deadline.
1034
- if (this.aborted || wait > deadline - Date.now())
1414
+ if (this.aborted)
1415
+ return result;
1416
+ // Return when the next attempt would start after the export deadline; a rate limit says
1417
+ // when it may be retried, which the exporter can wait for with the records still queued.
1418
+ if (wait > deadline - Date.now()) {
1419
+ if (this.lastStatus === 429 && result.retryInMillis !== undefined)
1420
+ this.rateLimitedFor = wait;
1035
1421
  return result;
1422
+ }
1036
1423
  await new Promise((resolve) => setTimeout(resolve, Math.max(0, wait)));
1424
+ // A response that lowered the limits below this request: it is split or shed, not retried
1425
+ // as it is.
1426
+ if (!this.fits()) {
1427
+ this.overLimits = true;
1428
+ return result;
1429
+ }
1037
1430
  result = await this.attempt(data, Math.max(1, deadline - Date.now()));
1038
1431
  }
1039
1432
  return result;
1040
1433
  }
1041
1434
  async attempt(data, timeoutMillis) {
1435
+ this.lastStatus = undefined;
1042
1436
  try {
1043
1437
  if (this.aborted)
1044
1438
  throw new Error("Hue export deadline exceeded");
1045
1439
  const url = new URL(this.url);
1046
1440
  const protocol = url.protocol;
1047
1441
  // Loaded on first use, as OpenTelemetry's exporter does, so importing Hue never loads http
1048
- // before the application's http instrumentation can patch it.
1442
+ // before the application's http instrumentation can patch it. A request measured before it
1443
+ // was sent is not compressed again.
1049
1444
  const [{ Agent: ConnectionAgent, request }, body] = await Promise.all([
1050
1445
  import(protocol === "https:" ? "node:https" : "node:http"),
1051
- compress(data),
1446
+ this.body && data === this.body.data ? this.body.compressed : compress(data),
1052
1447
  ]);
1053
1448
  if (this.aborted)
1054
1449
  throw new Error("Hue export deadline exceeded");
@@ -1068,6 +1463,13 @@ class OtlpHttpTransport {
1068
1463
  let size = 0;
1069
1464
  const status = res.statusCode ?? 0;
1070
1465
  const success = status >= 200 && status <= 299;
1466
+ this.lastStatus = status;
1467
+ try {
1468
+ this.onResponse(res.headers);
1469
+ }
1470
+ catch {
1471
+ // Reading advertised limits never fails the request.
1472
+ }
1071
1473
  res.on("data", (chunk) => {
1072
1474
  size += chunk.length;
1073
1475
  if (size > MAX_RESPONSE_BYTES) {