@hue-run/sdk 0.12.1 → 0.13.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
@@ -9,7 +9,7 @@ import { isInsecureOrigin, MAX_BODY_BYTES, validateOptions } from "./config.js";
9
9
  import { announcesLiveSpan, LIVE_SPAN_INTERVAL_MILLIS, MAX_LIVE_SPANS, pendingPlaceholder, PLACEHOLDERS_HEADER, withoutPlaceholderMarkers, } from "./live-spans.js";
10
10
  import { estimateRecordBytes } from "./safety.js";
11
11
  import { snapshotLog, snapshotSpan } from "./snapshot.js";
12
- import { redactLog, redactSpan } from "./privacy.js";
12
+ import { DROPPED_RECORDS_KEY, isContentKey, redactLog, redactSpan, TRUNCATED_KEY, truncatedMarker, withTruncatedKeys, } from "./privacy.js";
13
13
  import { sdkVersion } from "./version.js";
14
14
  /** Per-record allowance for protobuf length prefixes that grow when records are grouped. */
15
15
  const RECORD_FRAMING_BYTES = 64;
@@ -77,6 +77,10 @@ export class HueTransport {
77
77
  logs = new Map();
78
78
  pendingBytes = 0;
79
79
  dropped = { traces: 0, logs: 0 };
80
+ /** Records of each trace the SDK never sent (dropped from the queue, refused as invalid or
81
+ * over the request limit), reported on the trace's root span as `hue.sdk.dropped_records` when
82
+ * the root is exported, so Hue reads the trace as incomplete by that many records. */
83
+ droppedByTrace = new Map();
80
84
  instrumentationFailures = 0;
81
85
  diagnosticPending = false;
82
86
  lastDiagnosticAt = -Infinity;
@@ -89,6 +93,8 @@ export class HueTransport {
89
93
  live = new Set();
90
94
  liveTimer;
91
95
  liveSpans;
96
+ // Whether this client announces live spans at all; `liveSpans` also goes off at shutdown.
97
+ announcesLiveSpans;
92
98
  placeholdersRejected = false;
93
99
  // Admitted placeholder snapshots and the marker attributes added after redaction.
94
100
  placeholders = new WeakMap();
@@ -99,6 +105,7 @@ export class HueTransport {
99
105
  this.options = validateOptions(options);
100
106
  Object.defineProperty(this, "options", { enumerable: false });
101
107
  this.liveSpans = this.options.enabled !== false && this.options.liveSpans;
108
+ this.announcesLiveSpans = this.liveSpans;
102
109
  this.traceExporter = new ReportingExporter(this, "traces", ProtobufTraceSerializer, TraceExporterMetricsHelper, (span, cache) => redactSpan(span, this.options, cache));
103
110
  this.logExporter = new ReportingExporter(this, "logs", ProtobufLogsSerializer, LogsExporterMetricsHelper, (log, cache) => redactLog(log, this.options, cache));
104
111
  if (this.options.enabled === false) {
@@ -280,13 +287,18 @@ export class HueTransport {
280
287
  return !this.placeholdersRejected;
281
288
  }
282
289
  /**
283
- * @internal Exporter callback: a receiver without placeholder support rejects each by its zero
284
- * end time. Stops announcing for this transport and records one warning.
290
+ * @internal Exporter callback: a trace acknowledgement lacked the placeholder-support header,
291
+ * so the receiver predates placeholders and rejects each by its zero end time; `count` is how
292
+ * many it rejected in that request, zero when the request carried none. Stops announcing for
293
+ * this transport and records one warning.
285
294
  */
286
295
  rejectPlaceholders(count) {
287
- this.stopLiveSpans();
288
- if (this.placeholdersRejected)
296
+ // A client that never announces live spans (by option or setup credential) records no
297
+ // warning about placeholders; one that does records it once, even when the acknowledgement
298
+ // arrives during its final flush, after shutdown has already stopped announcing.
299
+ if (!this.announcesLiveSpans || this.placeholdersRejected)
289
300
  return;
301
+ this.stopLiveSpans();
290
302
  this.placeholdersRejected = true;
291
303
  this.issue("traces", "warning", count, "This Hue server does not accept in-progress span placeholders; live spans are disabled");
292
304
  }
@@ -302,6 +314,25 @@ export class HueTransport {
302
314
  acceptedRecords(signal, count) {
303
315
  this.accepted[signal] += count;
304
316
  }
317
+ /** @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
320
+ * (`consumeDroppedRecords`): a root written again under retry says the same, and a root whose
321
+ * request failed has not told Hue, so its count is not forgotten with it. */
322
+ withDroppedRecords(span) {
323
+ if (span.parentSpanContext)
324
+ return span;
325
+ const count = this.droppedByTrace.get(span.spanContext().traceId);
326
+ if (!count)
327
+ return span;
328
+ return { ...span, attributes: { ...span.attributes, [DROPPED_RECORDS_KEY]: count } };
329
+ }
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);
335
+ }
305
336
  /** @internal Records a sanitized issue, updates counters and rate-limits the diagnostic callback. */
306
337
  issue(signal, kind, count, message, status, traceIds) {
307
338
  if (kind === "dropped")
@@ -310,6 +341,15 @@ export class HueTransport {
310
341
  this.rejected[signal] += count;
311
342
  else if (kind !== "warning")
312
343
  this.failed[signal] += count;
344
+ // A record lost before any request names its one trace; a failed batch is retried or
345
+ // 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
+ }
313
353
  const issue = {
314
354
  sequence: ++this.sequence,
315
355
  signal,
@@ -480,6 +520,101 @@ function traceIdsOf(records) {
480
520
  // concern any trace in flight.
481
521
  return ids.size ? [...ids] : undefined;
482
522
  }
523
+ /** The serialized size of one attribute value, for choosing what to shed first. */
524
+ function valueBytes(value) {
525
+ if (typeof value === "string")
526
+ return Buffer.byteLength(value);
527
+ if (value instanceof Uint8Array)
528
+ return value.byteLength;
529
+ if (value !== null && typeof value === "object") {
530
+ try {
531
+ return Buffer.byteLength(JSON.stringify(value));
532
+ }
533
+ catch {
534
+ return 0;
535
+ }
536
+ }
537
+ return 8;
538
+ }
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
+ }
546
+ /**
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.
550
+ */
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;
569
+ const consider = (where, index, key, value) => {
570
+ if (!isContentKey(key) || isTruncatedMarker(value))
571
+ return;
572
+ const bytes = valueBytes(value);
573
+ if (!largest || bytes > largest.bytes)
574
+ largest = { where, index, key, bytes };
575
+ };
576
+ for (const [key, value] of Object.entries(attributes))
577
+ 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")
605
+ return {
606
+ ...record,
607
+ attributes: shed,
608
+ events: events.map((event, index) => index === found.index
609
+ ? { ...event, attributes: { ...event.attributes, ...marker } }
610
+ : event),
611
+ };
612
+ return {
613
+ ...record,
614
+ attributes: shed,
615
+ links: links.map((link, index) => index === found.index ? { ...link, attributes: { ...link.attributes, ...marker } } : link),
616
+ };
617
+ }
483
618
  class ReportingExporter {
484
619
  transport;
485
620
  signal;
@@ -532,11 +667,17 @@ class ReportingExporter {
532
667
  return real;
533
668
  }
534
669
  async exportRecords(records) {
535
- const accepted = [];
536
- // Placeholders are advisory: losing one is a warning, never an export failure.
537
- const placeholders = new Set();
670
+ // Completed records and placeholders travel in separate requests, completed records first,
671
+ // so a rejection count is always one kind of record's. Placeholders are advisory: losing one
672
+ // is a warning, never an export failure.
673
+ const real = [];
674
+ const pending = [];
538
675
  const cache = new WeakMap();
539
676
  let failed = false;
677
+ // A request of completed records the receiver refused or did not answer, as opposed to a
678
+ // record this side could not redact or encode: placeholders are advisory, so such a receiver
679
+ // gets no second request.
680
+ let refused = false;
540
681
  let redactedBytes = 0;
541
682
  const invalid = (placeholder, message, record) => {
542
683
  if (placeholder)
@@ -546,11 +687,6 @@ class ReportingExporter {
546
687
  this.transport.issue(this.signal, "invalid", 1, message, undefined, record ? traceIdsOf([record]) : undefined);
547
688
  }
548
689
  };
549
- const send = async (batch) => {
550
- const count = batch.filter((record) => placeholders.has(record)).length;
551
- if (!(await this.send(batch, count)))
552
- failed = true;
553
- };
554
690
  const resourceDeadline = Date.now() + this.transport.options.timeoutMillis;
555
691
  for (const { record, markers } of this.ordered(records)) {
556
692
  try {
@@ -587,9 +723,7 @@ class ReportingExporter {
587
723
  if (redactedBytes + bytes > this.transport.options.maxQueueBytes)
588
724
  throw new RangeError("Redacted batch exceeds byte budget");
589
725
  redactedBytes += bytes;
590
- accepted.push(redacted);
591
- if (markers)
592
- placeholders.add(redacted);
726
+ (markers ? pending : real).push(redacted);
593
727
  }
594
728
  catch {
595
729
  invalid(markers !== undefined, "Telemetry record could not be redacted or exceeds supported content limits", record);
@@ -600,43 +734,123 @@ class ReportingExporter {
600
734
  // encodings plus a fixed framing margin bounds the request size. Room is left for gzip
601
735
  // headers/blocks when otherwise incompressible data is near the wire cap.
602
736
  const limit = MAX_BODY_BYTES - 1024;
603
- let batch = [];
604
- let batchBytes = 0;
605
- for (const record of accepted) {
606
- let recordBytes;
607
- try {
608
- recordBytes = this.serializer.serializeRequest([record])?.byteLength ?? Infinity;
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);
609
752
  }
610
- catch {
611
- invalid(placeholders.has(record), "Telemetry record could not be serialized", record);
612
- continue;
613
- }
614
- const framedBytes = recordBytes + RECORD_FRAMING_BYTES;
615
- if (batch.length && batchBytes + framedBytes > limit) {
616
- await send(batch);
753
+ return { record, bytes };
754
+ };
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;
617
766
  batch = [];
618
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())
772
+ return;
773
+ if (await this.send(records, advisory ? records.length : 0)) {
774
+ this.transport.consumeDroppedRecords(traces);
775
+ }
776
+ else if (!advisory) {
777
+ failed = true;
778
+ refused = true;
779
+ }
780
+ };
781
+ // Every record is measured, shed and, where it cannot be sent, counted lost before any root
782
+ // is written, so a root carries the losses of children that ended after it.
783
+ const prepared = [];
784
+ for (const record of accepted) {
785
+ let fitted;
786
+ try {
787
+ fitted = fit(record);
788
+ }
789
+ catch {
790
+ invalid(advisory, "Telemetry record could not be serialized", record);
791
+ continue;
792
+ }
793
+ if (!fitted) {
794
+ invalid(advisory, "Telemetry record exceeds the 1 MiB request limit", record);
795
+ continue;
796
+ }
797
+ prepared.push(fitted);
619
798
  }
620
- if (recordBytes > limit) {
621
- invalid(placeholders.has(record), "Telemetry record exceeds the 1 MiB request limit", record);
622
- continue;
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
+ }
822
+ const framedBytes = recordBytes + RECORD_FRAMING_BYTES;
823
+ if (batch.length && batchBytes + framedBytes > limit)
824
+ await send();
825
+ batch.push(record);
826
+ batchBytes += framedBytes;
827
+ if (carriesCount)
828
+ counted.push(record.spanContext().traceId);
623
829
  }
624
- batch.push(record);
625
- batchBytes += framedBytes;
626
- }
627
- if (batch.length)
628
- await send(batch);
830
+ if (batch.length)
831
+ await send();
832
+ };
833
+ await pack(real, false);
834
+ // None is sent once a receiver answered without placeholder support, which the completed
835
+ // records' acknowledgements may have just shown, nor after this export's completed records were
836
+ // refused or unanswered: a receiver that just refused or timed out gets no second request with
837
+ // its own retries. A record this side could not send keeps the placeholders going out.
838
+ if (pending.length && !refused && this.transport.sendsPlaceholders())
839
+ await pack(pending, true);
629
840
  if (failed)
630
841
  throw new Error("Hue telemetry export failed");
631
842
  }
632
- /** Sends one request; `placeholders` of `records` are advisory and never count as lost. */
843
+ /**
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.
846
+ */
633
847
  async send(records, placeholders = 0) {
634
848
  const options = this.transport.options;
635
849
  const real = records.length - placeholders;
636
850
  // The traces this batch carries, spans or the log records emitted in their spans, so a loss
637
851
  // or rejection names whose telemetry it was.
638
852
  const traceIds = traceIdsOf(records);
639
- // A loss involving only placeholders is a warning. Mixed losses count only real records.
853
+ // Losing a request of placeholders is a warning; losing one of completed records counts them.
640
854
  const lose = (message, status) => {
641
855
  this.transport.issue(this.signal, real ? "failed" : "warning", real || placeholders, real ? message : `${message} (in-progress span placeholders only)`, status, traceIds);
642
856
  return !real;
@@ -660,26 +874,32 @@ class ReportingExporter {
660
874
  const count = Number(partial?.[this.signal === "traces" ? "rejectedSpans" : "rejectedLogRecords"] ?? 0);
661
875
  if (!Number.isSafeInteger(count) || count < 0 || count > records.length)
662
876
  throw new Error("Invalid rejection count");
663
- // A receiver that accepts placeholders never rejects them for being placeholders, so its
664
- // rejections count as before. One without the header predates them and rejects each.
665
- const downgrade = placeholders > 0 && !acceptsPlaceholders;
666
- const placeholderRejections = downgrade ? Math.min(count, placeholders) : 0;
667
- if (downgrade)
668
- this.transport.rejectPlaceholders(placeholderRejections);
669
- const remaining = count - placeholderRejections;
670
- // Rejections are not matched to records. Attribute them to real records first. A
671
- // rejection of spans in a batch of one trace is that trace's; in a batch of several it
672
- // names none, since the innocent traces' spans may have been accepted, and each case's
673
- // receipt decides. A rejection of log records names every trace in the batch: the
674
- // receipt counts spans, not logs, so it could not tell whose logs were lost.
675
- rejected = Math.min(remaining, real);
877
+ // A trace acknowledgement without the header comes from a receiver that predates
878
+ // placeholders, whatever the request carried: live spans stop, before any placeholder
879
+ // reaches a receiver that acknowledged completed spans first.
880
+ if (this.signal === "traces" && !acceptsPlaceholders)
881
+ this.transport.rejectPlaceholders(placeholders ? count : 0);
882
+ if (placeholders) {
883
+ // Placeholders travel alone, so the count is theirs: a warning, never a loss. A
884
+ // receiver with the header never rejects a placeholder for being one.
885
+ if (acceptsPlaceholders && (count || partial?.errorMessage))
886
+ this.transport.issue(this.signal, "warning", count, count
887
+ ? "Hue rejected in-progress span placeholders"
888
+ : "Hue returned an ingestion warning");
889
+ return {};
890
+ }
891
+ // Completed records travel alone too, so the count is theirs in full. Rejections are
892
+ // not matched to records: a rejection of spans in a batch of one trace is that trace's;
893
+ // in a batch of several it names none, since the innocent traces' spans may have been
894
+ // accepted, and each case's receipt decides. A rejection of log records names every
895
+ // trace in the batch: the receipt counts spans, not logs, so it could not tell whose
896
+ // logs were lost.
897
+ rejected = count;
676
898
  const rejectedTraceIds = this.signal === "logs" || (traceIds && traceIds.length === 1) ? traceIds : undefined;
677
- if (remaining || (partial?.errorMessage && !downgrade))
678
- this.transport.issue(this.signal, rejected ? "rejected" : "warning", rejected || remaining, rejected
899
+ if (count || partial?.errorMessage)
900
+ this.transport.issue(this.signal, count ? "rejected" : "warning", count, count
679
901
  ? "Hue rejected telemetry records; inspect the project ingestion settings and supported limits"
680
- : remaining
681
- ? "Hue rejected in-progress span placeholders"
682
- : "Hue returned an ingestion warning", undefined, rejectedTraceIds);
902
+ : "Hue returned an ingestion warning", undefined, rejectedTraceIds);
683
903
  // Do not pass backend error text or raw response bytes into the global OTel diagnostic logger.
684
904
  return {};
685
905
  }
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Package version shared by the instrumentation scope and the export User-Agent. */
2
- export declare const sdkVersion = "0.12.1";
2
+ export declare const sdkVersion = "0.13.1";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated by scripts/write-version.mjs from package.json; do not edit by hand.
2
2
  /** Package version shared by the instrumentation scope and the export User-Agent. */
3
- export const sdkVersion = "0.12.1";
3
+ export const sdkVersion = "0.13.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hue-run/sdk",
3
- "version": "0.12.1",
3
+ "version": "0.13.1",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "publishConfig": {