@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/CLI.md +21 -14
- package/EVALUATIONS.md +1 -1
- package/README.md +112 -19
- package/dist/blobs.d.ts +164 -0
- package/dist/blobs.js +703 -0
- package/dist/cli/eval.js +3 -3
- package/dist/client.d.ts +15 -5
- package/dist/client.js +63 -13
- package/dist/config.d.ts +20 -0
- package/dist/config.js +21 -1
- package/dist/environment/client.d.ts +1 -1
- package/dist/environment/client.js +3 -3
- package/dist/evals/client.d.ts +1 -1
- package/dist/evals/client.js +4 -4
- package/dist/evals/local-worker.d.ts +1 -1
- package/dist/evals/runner.d.ts +1 -1
- package/dist/evals/runner.js +2 -2
- package/dist/evals/simulation.d.ts +1 -1
- package/dist/evals/verdicts.d.ts +10 -7
- package/dist/evals/verdicts.js +52 -16
- package/dist/inline-files.d.ts +44 -1
- package/dist/inline-files.js +65 -6
- package/dist/limits.d.ts +32 -0
- package/dist/limits.js +54 -0
- package/dist/privacy.d.ts +27 -5
- package/dist/privacy.js +140 -39
- package/dist/provider-tools.js +1 -1
- package/dist/safety.d.ts +2 -0
- package/dist/safety.js +10 -4
- package/dist/snapshot.d.ts +30 -8
- package/dist/snapshot.js +280 -49
- package/dist/tool-definitions.d.ts +4 -0
- package/dist/tool-definitions.js +11 -0
- package/dist/transport.d.ts +26 -0
- package/dist/transport.js +579 -177
- package/dist/types.d.ts +8 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
319
|
-
* root has left). The count stays until
|
|
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
|
|
331
|
-
*
|
|
332
|
-
consumeDroppedRecords(
|
|
333
|
-
for (const traceId of
|
|
334
|
-
this.droppedByTrace.
|
|
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
|
-
|
|
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
|
-
/**
|
|
540
|
-
|
|
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
|
|
548
|
-
*
|
|
549
|
-
*
|
|
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
|
|
552
|
-
|
|
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 (
|
|
574
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
//
|
|
590
|
-
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
608
|
-
|
|
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:
|
|
615
|
-
|
|
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
|
-
|
|
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
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
|
|
1024
|
+
if (fitted.request)
|
|
1025
|
+
measured.set(fitted.record, fitted.request);
|
|
1026
|
+
return { record: fitted.record, bytes: fitted.bytes };
|
|
754
1027
|
};
|
|
755
|
-
/**
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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,
|
|
1123
|
+
invalid(advisory, OVER_REQUEST_LIMIT, record);
|
|
795
1124
|
continue;
|
|
796
1125
|
}
|
|
797
1126
|
prepared.push(fitted);
|
|
798
1127
|
}
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
|
|
824
|
-
|
|
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
|
|
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
|
|
845
|
-
* `records.length`.
|
|
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
|
|
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
|
-
|
|
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
|
|
1009
|
-
* export request: its connections are never reused, and
|
|
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
|
-
|
|
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
|
-
|
|
1034
|
-
|
|
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) {
|