@hue-run/sdk 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/transport.js CHANGED
@@ -1,10 +1,12 @@
1
+ import { gzip } from "node:zlib";
1
2
  import { ExportResultCode } from "@opentelemetry/core";
2
- import { CompressionAlgorithm, OTLPExporterBase, OTLPExporterError, } from "@opentelemetry/otlp-exporter-base";
3
- import { createOtlpHttpExportDelegate } from "@opentelemetry/otlp-exporter-base/node-http";
3
+ import { createOtlpNetworkExportDelegate, OTLPExporterBase, OTLPExporterError, } from "@opentelemetry/otlp-exporter-base";
4
+ import { createOtlpHttpExporterMetrics } from "@opentelemetry/otlp-exporter-base/node-http";
4
5
  import { LogsExporterMetricsHelper, ProtobufLogsSerializer, ProtobufTraceSerializer, TraceExporterMetricsHelper, } from "@opentelemetry/otlp-transformer";
5
6
  import { BatchSpanProcessor, } from "@opentelemetry/sdk-trace";
6
7
  import { BatchLogRecordProcessor, } from "@opentelemetry/sdk-logs";
7
8
  import { isInsecureOrigin, MAX_BODY_BYTES, validateOptions } from "./config.js";
9
+ import { announcesLiveSpan, LIVE_SPAN_INTERVAL_MILLIS, MAX_LIVE_SPANS, pendingPlaceholder, PLACEHOLDERS_HEADER, withoutPlaceholderMarkers, } from "./live-spans.js";
8
10
  import { estimateRecordBytes } from "./safety.js";
9
11
  import { snapshotLog, snapshotSpan } from "./snapshot.js";
10
12
  import { redactLog, redactSpan } from "./privacy.js";
@@ -83,9 +85,20 @@ export class HueTransport {
83
85
  closed = false;
84
86
  shutdownPromise;
85
87
  flushPromise;
88
+ // Open spans not yet announced. Each is considered once, at the next tick after it starts.
89
+ live = new Set();
90
+ liveTimer;
91
+ liveSpans;
92
+ placeholdersRejected = false;
93
+ // Admitted placeholder snapshots and the marker attributes added after redaction.
94
+ placeholders = new WeakMap();
95
+ // The running span each admitted placeholder announces, so one it has outlived is not sent.
96
+ placeholderSources = new WeakMap();
97
+ batchSpans;
86
98
  constructor(options) {
87
99
  this.options = validateOptions(options);
88
100
  Object.defineProperty(this, "options", { enumerable: false });
101
+ this.liveSpans = this.options.enabled !== false && this.options.liveSpans;
89
102
  this.traceExporter = new ReportingExporter(this, "traces", ProtobufTraceSerializer, TraceExporterMetricsHelper, (span, cache) => redactSpan(span, this.options, cache));
90
103
  this.logExporter = new ReportingExporter(this, "logs", ProtobufLogsSerializer, LogsExporterMetricsHelper, (log, cache) => redactLog(log, this.options, cache));
91
104
  if (this.options.enabled === false) {
@@ -103,6 +116,7 @@ export class HueTransport {
103
116
  };
104
117
  const spans = new BatchSpanProcessor({ exporter: this.traceExporter, ...batching });
105
118
  const logs = new BatchLogRecordProcessor({ exporter: this.logExporter, ...batching });
119
+ this.batchSpans = spans;
106
120
  this.spanProcessor = {
107
121
  onStart: (span, parent) => {
108
122
  try {
@@ -111,8 +125,11 @@ export class HueTransport {
111
125
  catch {
112
126
  this.instrumentationFailure();
113
127
  }
128
+ this.track(span);
114
129
  },
115
130
  onEnd: (span) => {
131
+ if (this.live.delete(span) && !this.live.size)
132
+ this.stopLiveTimer();
116
133
  let admitted;
117
134
  try {
118
135
  if (!(span.spanContext().traceFlags & 1))
@@ -130,7 +147,10 @@ export class HueTransport {
130
147
  }
131
148
  },
132
149
  forceFlush: () => spans.forceFlush(),
133
- shutdown: () => spans.shutdown(),
150
+ shutdown: () => {
151
+ this.stopLiveSpans();
152
+ return spans.shutdown();
153
+ },
134
154
  };
135
155
  this.logRecordProcessor = {
136
156
  onEmit: (log) => {
@@ -152,8 +172,15 @@ export class HueTransport {
152
172
  shutdown: () => logs.shutdown(),
153
173
  };
154
174
  }
155
- enqueue(signal, record) {
175
+ /**
176
+ * Advisory records (placeholders) are admitted only while the queue is under a quarter of its
177
+ * record and byte budgets, so they never take more than a quarter from real records. They are
178
+ * skipped silently.
179
+ */
180
+ enqueue(signal, record, advisory = false) {
156
181
  const pending = signal === "traces" ? this.spans : this.logs;
182
+ if (advisory && (this.closed || pending.size >= 2048 / 4))
183
+ return undefined;
157
184
  if (this.closed || pending.size >= 2048) {
158
185
  this.issue(signal, "dropped", 1, this.closed
159
186
  ? "Telemetry emitted after transport shutdown"
@@ -161,7 +188,8 @@ export class HueTransport {
161
188
  return undefined;
162
189
  }
163
190
  try {
164
- const remaining = this.options.maxQueueBytes - this.pendingBytes;
191
+ const remaining = (advisory ? this.options.maxQueueBytes / 4 : this.options.maxQueueBytes) -
192
+ this.pendingBytes;
165
193
  const snapshot = signal === "traces"
166
194
  ? snapshotSpan(record, remaining)
167
195
  : snapshotLog(record, remaining);
@@ -170,15 +198,94 @@ export class HueTransport {
170
198
  this.spans.set(snapshot.record, snapshot.bytes);
171
199
  else
172
200
  this.logs.set(snapshot.record, snapshot.bytes);
173
- if (snapshot.unresolvedResource)
201
+ if (snapshot.unresolvedResource && !advisory)
174
202
  this.issue(signal, "warning", 0, "Unresolved resource attributes omitted from the telemetry snapshot");
175
203
  return snapshot.record;
176
204
  }
177
205
  catch {
206
+ if (advisory)
207
+ return undefined;
178
208
  this.issue(signal, "dropped", 1, "Telemetry snapshot exceeded its byte or complexity budget or contained unsupported data");
179
209
  return undefined;
180
210
  }
181
211
  }
212
+ track(span) {
213
+ try {
214
+ if (!this.liveSpans || this.live.size >= MAX_LIVE_SPANS)
215
+ return;
216
+ if (!(span.spanContext().traceFlags & 1) || !span.isRecording() || !announcesLiveSpan(span))
217
+ return;
218
+ this.live.add(span);
219
+ if (!this.liveTimer) {
220
+ const timer = setInterval(() => this.announceLiveSpans(), LIVE_SPAN_INTERVAL_MILLIS);
221
+ timer.unref();
222
+ this.liveTimer = timer;
223
+ }
224
+ }
225
+ catch {
226
+ // Live announcements are advisory. They never report failures or affect the span.
227
+ }
228
+ }
229
+ /** Placeholders are built lazily, so input set right after a span starts is included. */
230
+ announceLiveSpans() {
231
+ for (const span of this.live) {
232
+ this.live.delete(span);
233
+ try {
234
+ // Ended without reaching onEnd: a wrapping processor filtered it, so no real span follows.
235
+ if (!this.liveSpans || span.ended || !this.batchSpans)
236
+ continue;
237
+ const { record, markers } = pendingPlaceholder(span);
238
+ const admitted = this.enqueue("traces", record, true);
239
+ if (!admitted)
240
+ continue;
241
+ this.placeholders.set(admitted, markers);
242
+ this.placeholderSources.set(admitted, span);
243
+ try {
244
+ this.batchSpans.onEnd(admitted);
245
+ }
246
+ catch {
247
+ this.finish("traces", [admitted]);
248
+ }
249
+ }
250
+ catch {
251
+ // Skipped silently, like any placeholder the queue has no room for.
252
+ }
253
+ }
254
+ if (!this.live.size)
255
+ this.stopLiveTimer();
256
+ }
257
+ stopLiveTimer() {
258
+ clearInterval(this.liveTimer);
259
+ this.liveTimer = undefined;
260
+ }
261
+ stopLiveSpans() {
262
+ this.liveSpans = false;
263
+ this.live.clear();
264
+ this.stopLiveTimer();
265
+ }
266
+ /** @internal Exporter callback: marker attributes when `record` is an admitted placeholder. */
267
+ placeholderMarkers(record) {
268
+ return this.placeholders.get(record);
269
+ }
270
+ /** @internal Exporter callback: whether a placeholder's span has ended, so it announces nothing. */
271
+ placeholderSettled(record) {
272
+ return this.placeholderSources.get(record)?.ended === true;
273
+ }
274
+ /** @internal Exporter callback: false once the receiver refused placeholders; queued ones are then dropped. */
275
+ sendsPlaceholders() {
276
+ return !this.placeholdersRejected;
277
+ }
278
+ /**
279
+ * @internal Exporter callback: a receiver without placeholder support rejects each by its zero
280
+ * end time. Stops announcing for this transport and records one warning.
281
+ */
282
+ rejectPlaceholders(count) {
283
+ this.stopLiveSpans();
284
+ if (this.placeholdersRejected)
285
+ return;
286
+ this.placeholdersRejected = true;
287
+ this.issue("traces", "warning", count, "This Hue server does not accept in-progress span placeholders; live spans are disabled");
288
+ }
182
289
  /** @internal Exporter callback: releases queued records after an export attempt settles. */
183
290
  finish(signal, records) {
184
291
  for (const record of records) {
@@ -345,13 +452,52 @@ class ReportingExporter {
345
452
  .catch(() => { });
346
453
  this.pending.add(work);
347
454
  }
455
+ /** Real records first, then placeholders whose real span is not already in this batch. */
456
+ ordered(records) {
457
+ if (this.signal !== "traces")
458
+ return records.map((record) => ({ record }));
459
+ const real = [];
460
+ const placeholders = [];
461
+ for (const record of records) {
462
+ const markers = this.transport.placeholderMarkers(record);
463
+ if (markers)
464
+ placeholders.push({ record, markers });
465
+ else
466
+ real.push({ record });
467
+ }
468
+ if (!placeholders.length || !this.transport.sendsPlaceholders())
469
+ return real;
470
+ const ended = new Set(real.map(({ record }) => record.spanContext().spanId));
471
+ // A span that already ended, in this batch or not (even one a wrapping processor filtered),
472
+ // is no longer running, so its placeholder is not sent.
473
+ for (const placeholder of placeholders)
474
+ if (!ended.has(placeholder.record.parentSpanContext?.spanId ?? "") &&
475
+ !this.transport.placeholderSettled(placeholder.record))
476
+ real.push(placeholder);
477
+ return real;
478
+ }
348
479
  async exportRecords(records) {
349
480
  const accepted = [];
481
+ // Placeholders are advisory: losing one is a warning, never an export failure.
482
+ const placeholders = new Set();
350
483
  const cache = new WeakMap();
351
484
  let failed = false;
352
485
  let redactedBytes = 0;
486
+ const invalid = (placeholder, message) => {
487
+ if (placeholder)
488
+ this.transport.issue(this.signal, "warning", 1, `${message} (in-progress span placeholder)`);
489
+ else {
490
+ failed = true;
491
+ this.transport.issue(this.signal, "invalid", 1, message);
492
+ }
493
+ };
494
+ const send = async (batch) => {
495
+ const count = batch.filter((record) => placeholders.has(record)).length;
496
+ if (!(await this.send(batch, count)))
497
+ failed = true;
498
+ };
353
499
  const resourceDeadline = Date.now() + this.transport.options.timeoutMillis;
354
- for (const record of records) {
500
+ for (const { record, markers } of this.ordered(records)) {
355
501
  try {
356
502
  const ready = record.resource.waitForAsyncAttributes?.();
357
503
  if (ready) {
@@ -368,17 +514,30 @@ class ReportingExporter {
368
514
  clearTimeout(timer);
369
515
  }
370
516
  }
371
- const redacted = this.redact(record, cache);
517
+ let redacted = this.redact(record, cache);
518
+ // Added after redaction, so a redactor cannot alter the markers or the parent identity.
519
+ if (markers)
520
+ redacted = {
521
+ ...redacted,
522
+ attributes: { ...redacted.attributes, ...markers },
523
+ };
524
+ else if (this.signal === "traces") {
525
+ const attributes = redacted.attributes;
526
+ const kept = withoutPlaceholderMarkers(attributes);
527
+ if (kept !== attributes)
528
+ redacted = { ...redacted, attributes: kept };
529
+ }
372
530
  const bytes = 512 +
373
531
  estimateRecordBytes(recordData(redacted, this.signal), this.transport.options.maxQueueBytes - redactedBytes);
374
532
  if (redactedBytes + bytes > this.transport.options.maxQueueBytes)
375
533
  throw new RangeError("Redacted batch exceeds byte budget");
376
534
  redactedBytes += bytes;
377
535
  accepted.push(redacted);
536
+ if (markers)
537
+ placeholders.add(redacted);
378
538
  }
379
539
  catch {
380
- failed = true;
381
- this.transport.issue(this.signal, "invalid", 1, "Telemetry record could not be redacted or exceeds supported content limits");
540
+ invalid(markers !== undefined, "Telemetry record could not be redacted or exceeds supported content limits");
382
541
  }
383
542
  }
384
543
  // Each record is encoded once to measure it; a request is encoded once more when it is sent.
@@ -394,37 +553,41 @@ class ReportingExporter {
394
553
  recordBytes = this.serializer.serializeRequest([record])?.byteLength ?? Infinity;
395
554
  }
396
555
  catch {
397
- failed = true;
398
- this.transport.issue(this.signal, "invalid", 1, "Telemetry record could not be serialized");
556
+ invalid(placeholders.has(record), "Telemetry record could not be serialized");
399
557
  continue;
400
558
  }
401
559
  const framedBytes = recordBytes + RECORD_FRAMING_BYTES;
402
560
  if (batch.length && batchBytes + framedBytes > limit) {
403
- if (!(await this.send(batch)))
404
- failed = true;
561
+ await send(batch);
405
562
  batch = [];
406
563
  batchBytes = 0;
407
564
  }
408
565
  if (recordBytes > limit) {
409
- failed = true;
410
- this.transport.issue(this.signal, "invalid", 1, "Telemetry record exceeds the 1 MiB request limit");
566
+ invalid(placeholders.has(record), "Telemetry record exceeds the 1 MiB request limit");
411
567
  continue;
412
568
  }
413
569
  batch.push(record);
414
570
  batchBytes += framedBytes;
415
571
  }
416
- if (batch.length && !(await this.send(batch)))
417
- failed = true;
572
+ if (batch.length)
573
+ await send(batch);
418
574
  if (failed)
419
575
  throw new Error("Hue telemetry export failed");
420
576
  }
421
- async send(records) {
577
+ /** Sends one request; `placeholders` of `records` are advisory and never count as lost. */
578
+ async send(records, placeholders = 0) {
422
579
  const options = this.transport.options;
580
+ const real = records.length - placeholders;
581
+ // A loss involving only placeholders is a warning. Mixed losses count only real records.
582
+ const lose = (message, status) => {
583
+ this.transport.issue(this.signal, real ? "failed" : "warning", real || placeholders, real ? message : `${message} (in-progress span placeholders only)`, status);
584
+ return !real;
585
+ };
423
586
  let rejected = 0;
424
587
  let validResponse = true;
425
588
  let receivedResponse = false;
589
+ let acceptsPlaceholders = false;
426
590
  let expired = false;
427
- const agents = new Set();
428
591
  const deadline = Date.now() + options.timeoutMillis;
429
592
  let timer;
430
593
  const serializer = {
@@ -439,17 +602,27 @@ class ReportingExporter {
439
602
  const count = Number(partial?.[this.signal === "traces" ? "rejectedSpans" : "rejectedLogRecords"] ?? 0);
440
603
  if (!Number.isSafeInteger(count) || count < 0 || count > records.length)
441
604
  throw new Error("Invalid rejection count");
442
- rejected = count;
443
- if (count || partial?.errorMessage)
444
- this.transport.issue(this.signal, count ? "rejected" : "warning", count, count
605
+ // A receiver that accepts placeholders never rejects them for being placeholders, so its
606
+ // rejections count as before. One without the header predates them and rejects each.
607
+ const downgrade = placeholders > 0 && !acceptsPlaceholders;
608
+ const placeholderRejections = downgrade ? Math.min(count, placeholders) : 0;
609
+ if (downgrade)
610
+ this.transport.rejectPlaceholders(placeholderRejections);
611
+ const remaining = count - placeholderRejections;
612
+ // Rejections are not matched to records. Attribute them to real records first.
613
+ rejected = Math.min(remaining, real);
614
+ if (remaining || (partial?.errorMessage && !downgrade))
615
+ this.transport.issue(this.signal, rejected ? "rejected" : "warning", rejected || remaining, rejected
445
616
  ? "Hue rejected telemetry records; inspect the project ingestion settings and supported limits"
446
- : "Hue returned an ingestion warning");
617
+ : remaining
618
+ ? "Hue rejected in-progress span placeholders"
619
+ : "Hue returned an ingestion warning");
447
620
  // Do not pass backend error text or raw response bytes into the global OTel diagnostic logger.
448
621
  return {};
449
622
  }
450
623
  catch {
451
624
  validResponse = false;
452
- this.transport.issue(this.signal, "failed", records.length, "Hue returned an invalid OTLP acknowledgement; acceptance is uncertain");
625
+ lose("Hue returned an invalid OTLP acknowledgement; acceptance is uncertain");
453
626
  return {};
454
627
  }
455
628
  },
@@ -457,64 +630,44 @@ class ReportingExporter {
457
630
  const endpoint = `${options.baseUrl}/api/v1/otlp/v1/${this.signal}`;
458
631
  // Explicit configuration only. OTEL_EXPORTER_OTLP_* environment variables are meant
459
632
  // for generic exporters; merging them here could send another vendor's headers to Hue.
460
- const delegate = createOtlpHttpExportDelegate({
461
- url: endpoint,
462
- headers: async () => ({
463
- "Content-Type": "application/x-protobuf",
464
- Authorization: `Bearer ${options.apiKey}`,
465
- }),
466
- // The transport prefixes this to OpenTelemetry's own User-Agent token.
467
- userAgent: `hue-sdk-typescript/${sdkVersion}`,
468
- timeoutMillis: options.timeoutMillis,
469
- concurrencyLimit: 1,
470
- compression: CompressionAlgorithm.GZIP,
471
- agentFactory: async (protocol) => {
472
- if (expired || Date.now() >= deadline)
473
- throw new Error("Hue export deadline exceeded");
474
- const { Agent } = await import(protocol === "https:" ? "node:https" : "node:http");
475
- const agent = new Agent({ keepAlive: false });
476
- agents.add(agent);
477
- if (expired)
478
- agent.destroy();
479
- return agent;
480
- },
481
- }, serializer, this.signal === "traces" ? "otlp_http_span_exporter" : "otlp_http_log_exporter", this.metrics, undefined);
633
+ const transport = new OtlpHttpTransport(endpoint, {
634
+ "Content-Type": "application/x-protobuf",
635
+ Authorization: `Bearer ${options.apiKey}`,
636
+ "User-Agent": `hue-sdk-typescript/${sdkVersion} ${OTEL_USER_AGENT}`,
637
+ }, (headers) => {
638
+ acceptsPlaceholders = headers[PLACEHOLDERS_HEADER] === "1";
639
+ });
640
+ 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);
482
641
  const exporter = new OTLPExporterBase(delegate);
483
642
  try {
484
643
  const result = await new Promise((resolve) => {
485
644
  timer = setTimeout(() => {
486
645
  expired = true;
487
- for (const agent of agents)
488
- agent.destroy();
646
+ transport.abort();
489
647
  resolve({ code: ExportResultCode.FAILED });
490
648
  }, Math.max(1, deadline - Date.now()));
491
649
  exporter.export(records, resolve);
492
650
  });
493
651
  if (result.code === ExportResultCode.SUCCESS) {
494
- if (!receivedResponse) {
495
- this.transport.issue(this.signal, "failed", records.length, "Hue response ended without a complete OTLP acknowledgement; acceptance is uncertain");
496
- return false;
497
- }
652
+ if (!receivedResponse)
653
+ return lose("Hue response ended without a complete OTLP acknowledgement; acceptance is uncertain");
498
654
  if (validResponse)
499
- this.transport.acceptedRecords(this.signal, records.length - rejected);
500
- return validResponse;
655
+ this.transport.acceptedRecords(this.signal, real - rejected);
656
+ return validResponse || !real;
501
657
  }
502
658
  else {
503
659
  const status = result.error instanceof OTLPExporterError && Number.isInteger(result.error.code)
504
660
  ? result.error.code
505
661
  : undefined;
506
- this.transport.issue(this.signal, "failed", records.length, "Hue telemetry request failed", status);
507
- return false;
662
+ return lose("Hue telemetry request failed", status);
508
663
  }
509
664
  }
510
665
  catch {
511
- this.transport.issue(this.signal, "failed", records.length, "Hue telemetry request failed");
512
- return false;
666
+ return lose("Hue telemetry request failed");
513
667
  }
514
668
  finally {
515
669
  clearTimeout(timer);
516
- for (const agent of agents)
517
- agent.destroy();
670
+ transport.abort();
518
671
  // Delegate cleanup cannot extend the hard request wait. Sockets are closed
519
672
  // and the cleanup promise is always observed, even after a caller timeout.
520
673
  void exporter.shutdown().catch(() => { });
@@ -527,6 +680,182 @@ class ReportingExporter {
527
680
  await this.forceFlush();
528
681
  }
529
682
  }
683
+ // OpenTelemetry's OTLP/HTTP transport rules (otlp-exporter-base 0.222), restated because that
684
+ // transport discards response headers, which Hue uses to announce features.
685
+ const MAX_RETRIES = 5;
686
+ const INITIAL_BACKOFF_MILLIS = 1000;
687
+ const MAX_BACKOFF_MILLIS = 5000;
688
+ const BACKOFF_MULTIPLIER = 1.5;
689
+ const JITTER = 0.2;
690
+ const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
691
+ const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
692
+ const RETRYABLE_NETWORK_ERRORS = new Set([
693
+ "ECONNRESET",
694
+ "ECONNREFUSED",
695
+ "EPIPE",
696
+ "ETIMEDOUT",
697
+ "EAI_AGAIN",
698
+ "ENOTFOUND",
699
+ "ENETUNREACH",
700
+ "EHOSTUNREACH",
701
+ ]);
702
+ /** OpenTelemetry's exporter token, which follows Hue's in the User-Agent as it always has. */
703
+ const OTEL_USER_AGENT = "OTel-OTLP-Exporter-JavaScript/0.222.0";
704
+ function retryAfterMillis(value) {
705
+ if (value == null)
706
+ return undefined;
707
+ const seconds = Number.parseInt(value, 10);
708
+ if (Number.isInteger(seconds))
709
+ return seconds > 0 ? seconds * 1000 : -1;
710
+ const delay = new Date(value).getTime() - Date.now();
711
+ return delay >= 0 ? delay : 0;
712
+ }
713
+ function networkFailure(error) {
714
+ const code = error.code;
715
+ return typeof code === "string" && RETRYABLE_NETWORK_ERRORS.has(code)
716
+ ? { status: "retryable", error }
717
+ : { status: "failure", error };
718
+ }
719
+ function compress(data) {
720
+ return new Promise((resolve, reject) => gzip(data, (error, result) => (error ? reject(error) : resolve(result))));
721
+ }
722
+ /**
723
+ * Gzip-compressed OTLP/HTTP POSTs that never follow redirects, retried like OpenTelemetry's
724
+ * exporter within the request budget. `onSuccess` receives the headers of the 2xx response whose
725
+ * body becomes the acknowledgement, before that body is decoded. One instance serves a single
726
+ * export request: its connections are never reused, and `abort()` closes them at the export
727
+ * deadline.
728
+ */
729
+ class OtlpHttpTransport {
730
+ url;
731
+ headers;
732
+ onSuccess;
733
+ // Requests in flight and how to settle each, so an abort never leaves an attempt pending.
734
+ requests = new Map();
735
+ agent;
736
+ aborted = false;
737
+ constructor(url, headers, onSuccess) {
738
+ this.url = url;
739
+ this.headers = headers;
740
+ this.onSuccess = onSuccess;
741
+ }
742
+ async send(data, timeoutMillis) {
743
+ const deadline = Date.now() + timeoutMillis;
744
+ let backoff = INITIAL_BACKOFF_MILLIS;
745
+ let result = await this.attempt(data, timeoutMillis);
746
+ for (let retries = MAX_RETRIES; result.status === "retryable" && retries > 0; retries--) {
747
+ const jitter = Math.random() * 2 * JITTER - JITTER;
748
+ const wait = result.retryInMillis ?? Math.max(Math.min(backoff * (1 + jitter), MAX_BACKOFF_MILLIS), 0);
749
+ backoff *= BACKOFF_MULTIPLIER;
750
+ // Return when the next attempt would start after the export deadline.
751
+ if (this.aborted || wait > deadline - Date.now())
752
+ return result;
753
+ await new Promise((resolve) => setTimeout(resolve, Math.max(0, wait)));
754
+ result = await this.attempt(data, Math.max(1, deadline - Date.now()));
755
+ }
756
+ return result;
757
+ }
758
+ async attempt(data, timeoutMillis) {
759
+ try {
760
+ if (this.aborted)
761
+ throw new Error("Hue export deadline exceeded");
762
+ const url = new URL(this.url);
763
+ const protocol = url.protocol;
764
+ // Loaded on first use, as OpenTelemetry's exporter does, so importing Hue never loads http
765
+ // before the application's http instrumentation can patch it.
766
+ const [{ Agent: ConnectionAgent, request }, body] = await Promise.all([
767
+ import(protocol === "https:" ? "node:https" : "node:http"),
768
+ compress(data),
769
+ ]);
770
+ if (this.aborted)
771
+ throw new Error("Hue export deadline exceeded");
772
+ // Never kept alive: each attempt's socket closes with its response or at the deadline.
773
+ this.agent ??= new ConnectionAgent({ keepAlive: false });
774
+ return await new Promise((resolve) => {
775
+ const req = request(url, {
776
+ method: "POST",
777
+ agent: this.agent,
778
+ headers: {
779
+ ...this.headers,
780
+ "Content-Encoding": "gzip",
781
+ "Content-Length": body.byteLength,
782
+ },
783
+ }, (res) => {
784
+ const chunks = [];
785
+ let size = 0;
786
+ const status = res.statusCode ?? 0;
787
+ const success = status >= 200 && status <= 299;
788
+ res.on("data", (chunk) => {
789
+ size += chunk.length;
790
+ if (size > MAX_RESPONSE_BYTES) {
791
+ // Oversized responses fail regardless of status; resolve before tearing down.
792
+ resolve({ status: "failure", error: new Error("OTLP response exceeded 4 MiB") });
793
+ res.destroy();
794
+ return;
795
+ }
796
+ chunks.push(chunk);
797
+ });
798
+ res.on("end", () => {
799
+ if (success) {
800
+ this.onSuccess(res.headers);
801
+ resolve({ status: "success", data: Buffer.concat(chunks) });
802
+ }
803
+ else if (RETRYABLE_STATUS.has(status))
804
+ resolve({
805
+ status: "retryable",
806
+ retryInMillis: retryAfterMillis(res.headers["retry-after"]),
807
+ });
808
+ else
809
+ resolve({
810
+ status: "failure",
811
+ error: new OTLPExporterError(res.statusMessage, status, Buffer.concat(chunks).toString()),
812
+ });
813
+ });
814
+ res.on("error", (error) => {
815
+ // Sent, but the acknowledgement was not read: success without a body to decode.
816
+ if (success)
817
+ resolve({ status: "success" });
818
+ else if (RETRYABLE_STATUS.has(status))
819
+ resolve({
820
+ status: "retryable",
821
+ error,
822
+ retryInMillis: retryAfterMillis(res.headers["retry-after"]),
823
+ });
824
+ else
825
+ resolve({ status: "failure", error });
826
+ });
827
+ });
828
+ this.requests.set(req, resolve);
829
+ req.on("close", () => this.requests.delete(req));
830
+ req.setTimeout(timeoutMillis, () => {
831
+ req.destroy();
832
+ resolve({ status: "retryable", error: new Error("Request timed out") });
833
+ });
834
+ req.on("error", (error) => resolve(networkFailure(error)));
835
+ req.end(body);
836
+ });
837
+ }
838
+ catch (error) {
839
+ return {
840
+ status: "failure",
841
+ error: error instanceof Error ? error : new Error(String(error)),
842
+ };
843
+ }
844
+ }
845
+ /** Fails and destroys requests in flight and their sockets, and every later attempt. */
846
+ abort() {
847
+ this.aborted = true;
848
+ for (const [req, settle] of this.requests) {
849
+ settle({ status: "failure", error: new Error("Hue export deadline exceeded") });
850
+ req.destroy();
851
+ }
852
+ this.requests.clear();
853
+ this.agent?.destroy();
854
+ }
855
+ shutdown() {
856
+ this.abort();
857
+ }
858
+ }
530
859
  /**
531
860
  * Creates the export pipeline for attach mode; pass it with the application's providers to
532
861
  * {@link createHue}. Validates options like an owned client.
package/dist/types.d.ts CHANGED
@@ -43,6 +43,12 @@ export interface SharedHueOptions {
43
43
  timeoutMillis?: number;
44
44
  /** Aggregate estimated retained telemetry bytes across both signals, including in-flight work. Default 8 MiB. */
45
45
  maxQueueBytes?: number;
46
+ /**
47
+ * Announces AI and Hue spans that are still running with in-progress placeholder spans, so Hue
48
+ * shows a trace while it runs. Default `true`; always off for setup credentials (`hue_setup_…`),
49
+ * and turned off for the client when its receiver does not accept placeholders.
50
+ */
51
+ liveSpans?: boolean;
46
52
  }
47
53
  /**
48
54
  * Options for a client that owns its OpenTelemetry providers. An enabled client needs a project
@@ -75,7 +81,7 @@ export interface ExportIssue {
75
81
  signal: Signal;
76
82
  /** `rejected` by Hue, `failed` to deliver, `dropped` from the queue, `invalid` record or capture, or a non-failing `warning`. */
77
83
  kind: "rejected" | "failed" | "dropped" | "invalid" | "warning";
78
- /** Records affected; zero for warnings and capture failures. */
84
+ /** Records affected; zero for capture failures and for warnings other than lost in-progress span placeholders. */
79
85
  count: number;
80
86
  /** HTTP status when the issue came from a response. */
81
87
  status?: number;
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.5.1";
2
+ export declare const sdkVersion = "0.7.0";
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.5.1";
3
+ export const sdkVersion = "0.7.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hue-run/sdk",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "publishConfig": {