@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/CLI.md +17 -14
- package/ENVIRONMENTS.md +1 -1
- package/EVALUATIONS.md +19 -0
- package/README.md +35 -4
- package/dist/cli/eval.js +11 -8
- package/dist/cli/login.js +1 -1
- package/dist/client.js +3 -2
- package/dist/config.d.ts +3 -1
- package/dist/config.js +8 -1
- package/dist/evals/client.d.ts +98 -1
- package/dist/evals/client.js +195 -0
- package/dist/evals/types.d.ts +77 -0
- package/dist/live-spans.d.ts +25 -0
- package/dist/live-spans.js +115 -0
- package/dist/transport.d.ts +17 -0
- package/dist/transport.js +390 -61
- package/dist/types.d.ts +7 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/transport.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { gzip } from "node:zlib";
|
|
1
2
|
import { ExportResultCode } from "@opentelemetry/core";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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: () =>
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
404
|
-
failed = true;
|
|
561
|
+
await send(batch);
|
|
405
562
|
batch = [];
|
|
406
563
|
batchBytes = 0;
|
|
407
564
|
}
|
|
408
565
|
if (recordBytes > limit) {
|
|
409
|
-
|
|
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
|
|
417
|
-
|
|
572
|
+
if (batch.length)
|
|
573
|
+
await send(batch);
|
|
418
574
|
if (failed)
|
|
419
575
|
throw new Error("Hue telemetry export failed");
|
|
420
576
|
}
|
|
421
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
507
|
-
return false;
|
|
662
|
+
return lose("Hue telemetry request failed", status);
|
|
508
663
|
}
|
|
509
664
|
}
|
|
510
665
|
catch {
|
|
511
|
-
|
|
512
|
-
return false;
|
|
666
|
+
return lose("Hue telemetry request failed");
|
|
513
667
|
}
|
|
514
668
|
finally {
|
|
515
669
|
clearTimeout(timer);
|
|
516
|
-
|
|
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
|
|
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.
|
|
2
|
+
export declare const sdkVersion = "0.7.0";
|
package/dist/version.js
CHANGED