@hue-run/sdk 0.18.2 → 0.19.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 +16 -12
- package/EVALUATIONS.md +1 -1
- package/README.md +100 -66
- package/dist/blobs.d.ts +114 -35
- package/dist/blobs.js +354 -159
- package/dist/cli/langfuse.d.ts +21 -5
- package/dist/cli/langfuse.js +123 -16
- package/dist/client.d.ts +11 -7
- package/dist/client.js +35 -26
- package/dist/large-media.d.ts +13 -1
- package/dist/large-media.js +20 -12
- package/dist/transport.d.ts +38 -7
- package/dist/transport.js +240 -63
- package/dist/types.d.ts +5 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/blobs.js
CHANGED
|
@@ -8,9 +8,11 @@ import { capLogBody, TRUNCATED_KEY, truncatedMarker, withTruncatedKeys } from ".
|
|
|
8
8
|
import { truncateUtf8 } from "./safety.js";
|
|
9
9
|
/**
|
|
10
10
|
* Large values and binary media travel apart from their span through reservation, presigned PUT,
|
|
11
|
-
* and completion.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* and completion. Export hashes each value, writes its reference (text keeps a 16 KiB prefix,
|
|
12
|
+
* binary media digest metadata on its part) and its `hue.blobs` entry, and sends the record; the
|
|
13
|
+
* upload runs afterwards in the background, as Langfuse uploads media. A value refused when its
|
|
14
|
+
* record is exported keeps its inline fallback or file metadata; an upload that fails after export
|
|
15
|
+
* leaves its entry, which Hue reads as missing. Both are reported as warnings and counted.
|
|
14
16
|
*/
|
|
15
17
|
/** The span attribute listing the values the SDK uploaded apart from the span: one compact JSON
|
|
16
18
|
* object per value, `{"key", "sha256", "size", "content_type"}`, with an optional `name`. */
|
|
@@ -23,30 +25,42 @@ export const BLOB_PREFIX_BYTES = 16 * 1024;
|
|
|
23
25
|
export const MAX_BLOB_ENTRIES = 64;
|
|
24
26
|
const MAX_BLOB_KEY_LENGTH = 256;
|
|
25
27
|
/**
|
|
26
|
-
* The values over the inline limit that queued
|
|
27
|
-
* apart from `maxQueueBytes` (a string as two bytes a code unit, bytes as
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* The values over the inline limit that queued records and unsettled uploads may hold whole at
|
|
29
|
+
* once, charged apart from `maxQueueBytes` (a string as two bytes a code unit, bytes as
|
|
30
|
+
* themselves): each is held until its record is exported and its upload settles. A value the
|
|
31
|
+
* budget cannot hold is cut when it is queued, as before uploads existed; a single value larger
|
|
32
|
+
* than the whole budget is held while nothing else is.
|
|
30
33
|
*/
|
|
31
34
|
export const MAX_HELD_BLOB_BYTES = 128 * 1024 * 1024;
|
|
32
35
|
/** Uploads in flight at once, across both signals' exports. */
|
|
33
36
|
const UPLOAD_CONCURRENCY = 4;
|
|
34
|
-
/** How long a receiver without the upload route is believed to stay without it
|
|
37
|
+
/** How long a receiver without the upload route is believed to stay without it: an answer misread
|
|
38
|
+
* as one (a proxy's page during a deploy) turns uploads off for this long, never for good. */
|
|
35
39
|
const UNSUPPORTED_PAUSE_MILLIS = 10 * 60_000;
|
|
36
|
-
/**
|
|
37
|
-
*
|
|
38
|
-
const
|
|
40
|
+
/** Values hashed at once for export, apart from the uploads' places: export never waits for an
|
|
41
|
+
* upload to hash. */
|
|
42
|
+
const HASH_CONCURRENCY = 4;
|
|
43
|
+
/** Uploads queued and not yet settled; past it a value keeps its fallback. */
|
|
44
|
+
const MAX_QUEUED_UPLOADS = 4096;
|
|
45
|
+
/** How many times a reservation or a PUT is tried. */
|
|
46
|
+
const UPLOAD_ATTEMPTS = 3;
|
|
39
47
|
const MAX_JSON_RESPONSE_BYTES = 64 * 1024;
|
|
40
48
|
const MAX_ERROR_RESPONSE_BYTES = 4096;
|
|
41
49
|
/** Text is encoded to UTF-8 a slice at a time to hash and upload it: no full copy is held. */
|
|
42
50
|
const TEXT_CHUNK_UNITS = 1 << 20;
|
|
43
51
|
const RETRY_DELAY_MILLIS = 1000;
|
|
44
|
-
/** The longest
|
|
45
|
-
const
|
|
46
|
-
/** How long one export may spend
|
|
52
|
+
/** The longest wait before an upload's next attempt, a `Retry-After` included. */
|
|
53
|
+
export const MAX_RETRY_AFTER_MILLIS = 60_000;
|
|
54
|
+
/** How long one export may spend hashing its values: past it a value keeps its fallback. Also
|
|
55
|
+
* the import's least upload deadline, and part of an upload's lifetime. */
|
|
47
56
|
export function uploadBudgetMillis(timeoutMillis) {
|
|
48
57
|
return timeoutMillis * 6;
|
|
49
58
|
}
|
|
59
|
+
/** How long one upload may take from when its record's export queued it: six export timeouts for
|
|
60
|
+
* its requests and up to a minute of Retry-After of its own. */
|
|
61
|
+
export function uploadLifetimeMillis(timeoutMillis) {
|
|
62
|
+
return uploadBudgetMillis(timeoutMillis) + MAX_RETRY_AFTER_MILLIS;
|
|
63
|
+
}
|
|
50
64
|
/** The fixed description of each fallback, as an export issue reports it. */
|
|
51
65
|
export const FALLBACK_MESSAGES = {
|
|
52
66
|
unsupported: "This Hue server does not accept uploaded values; large values were cut inline and media kept digest metadata",
|
|
@@ -54,6 +68,14 @@ export const FALLBACK_MESSAGES = {
|
|
|
54
68
|
tooLarge: "Content over its upload limit kept inline fallback or file metadata",
|
|
55
69
|
budget: "Content did not fit the SDK's upload budget; large values were cut inline and media kept digest metadata",
|
|
56
70
|
};
|
|
71
|
+
/** The fixed description of each failure of an upload that ran after its record was exported:
|
|
72
|
+
* the record lists the value under `hue.blobs`, and Hue reads it as missing. */
|
|
73
|
+
export const LATE_FALLBACK_MESSAGES = {
|
|
74
|
+
unsupported: "This Hue server does not accept uploaded values; values exported before it answered list hue.blobs entries that it does not store",
|
|
75
|
+
failed: "Content could not be uploaded after its record was exported; its hue.blobs entry names a value Hue does not store",
|
|
76
|
+
tooLarge: "Content over its upload limit could not be uploaded after its record was exported; its hue.blobs entry names a value Hue does not store",
|
|
77
|
+
budget: "Content did not fit the SDK's upload budget after its record was exported; its hue.blobs entry names a value Hue does not store",
|
|
78
|
+
};
|
|
57
79
|
/** The `hue.blobs` key of a staged file: its part's for an attachment placed in a value, else
|
|
58
80
|
* its `hue.file` event's. */
|
|
59
81
|
export function mediaKey(file) {
|
|
@@ -68,6 +90,32 @@ export function countFallback(tally, reason, traceId) {
|
|
|
68
90
|
entry.traceIds.add(traceId);
|
|
69
91
|
tally.fallbacks.set(reason, entry);
|
|
70
92
|
}
|
|
93
|
+
/** Adds one tally's outcomes to another's. */
|
|
94
|
+
export function mergeTally(into, from) {
|
|
95
|
+
into.uploaded += from.uploaded;
|
|
96
|
+
for (const [reason, { count, traceIds }] of from.fallbacks) {
|
|
97
|
+
const entry = into.fallbacks.get(reason) ?? { count: 0, traceIds: new Set() };
|
|
98
|
+
entry.count += count;
|
|
99
|
+
for (const traceId of traceIds)
|
|
100
|
+
entry.traceIds.add(traceId);
|
|
101
|
+
into.fallbacks.set(reason, entry);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Adds `from` to `into` once `settled` all settle. Built apart from the step that queued the
|
|
105
|
+
* uploads, so a pending upload keeps alive the two tallies alone, never that step's record. */
|
|
106
|
+
export function mergeWhenSettled(into, from, settled) {
|
|
107
|
+
return Promise.all(settled).then(() => mergeTally(into, from));
|
|
108
|
+
}
|
|
109
|
+
/** Counts an upload's outcome in `tally` once it settles. Built apart from the placement that
|
|
110
|
+
* queued it, so the upload keeps alive its own bytes alone, never the record placed around it. */
|
|
111
|
+
function countWhenSettled(done, tally, traceId) {
|
|
112
|
+
return done.then((outcome) => {
|
|
113
|
+
if (outcome.ok)
|
|
114
|
+
tally.uploaded++;
|
|
115
|
+
else
|
|
116
|
+
countFallback(tally, outcome.reason, traceId);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
71
119
|
/** A media type accepted by the upload contract. */
|
|
72
120
|
export function isBlobContentType(value) {
|
|
73
121
|
return isMediaContentType(value);
|
|
@@ -109,6 +157,13 @@ export function sizeOf(value) {
|
|
|
109
157
|
export function heldBytesOf(value) {
|
|
110
158
|
return isByteSource(value) ? 0 : value.byteLength;
|
|
111
159
|
}
|
|
160
|
+
/** The memory a queued upload keeps alive until it settles: a string as two bytes a code unit,
|
|
161
|
+
* bytes as themselves, a source the strings it reads from, each whole. */
|
|
162
|
+
function heldBy(value) {
|
|
163
|
+
if (isByteSource(value))
|
|
164
|
+
return value.held ?? [];
|
|
165
|
+
return [{ bytes: typeof value === "string" ? value.length * 2 : value.byteLength }];
|
|
166
|
+
}
|
|
112
167
|
/** A string's UTF-8 encoding a slice at a time, never splitting a surrogate pair. */
|
|
113
168
|
function* utf8Chunks(text) {
|
|
114
169
|
// One buffer, reused: a reader is done with each slice before it asks for the next.
|
|
@@ -180,26 +235,111 @@ function parseJson(body) {
|
|
|
180
235
|
function isObject(value) {
|
|
181
236
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
182
237
|
}
|
|
238
|
+
/** Places for work of one kind: a finishing run hands its place to the next one waiting, so none
|
|
239
|
+
* is ever taken twice. */
|
|
240
|
+
class Slots {
|
|
241
|
+
size;
|
|
242
|
+
running = 0;
|
|
243
|
+
waiting = [];
|
|
244
|
+
released = false;
|
|
245
|
+
constructor(size) {
|
|
246
|
+
this.size = size;
|
|
247
|
+
}
|
|
248
|
+
/** Runs `work` once fewer than `size` runs are in progress. */
|
|
249
|
+
async run(work) {
|
|
250
|
+
await this.take();
|
|
251
|
+
try {
|
|
252
|
+
return await work();
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
this.give();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/** Runs `work` (a wait) from within a run without its place, and takes a place again after:
|
|
259
|
+
* a run that waits never holds the place another could use. Once `deadline` passes it stops
|
|
260
|
+
* waiting for a place and goes on with one over the size, to end without more work. */
|
|
261
|
+
async away(work, deadline = Infinity) {
|
|
262
|
+
this.give();
|
|
263
|
+
try {
|
|
264
|
+
return await work();
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
await this.take(deadline);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async take(deadline = Infinity) {
|
|
271
|
+
if (this.running < this.size || this.released) {
|
|
272
|
+
this.running++;
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
await new Promise((resolve) => {
|
|
276
|
+
const start = () => {
|
|
277
|
+
clearTimeout(timer);
|
|
278
|
+
resolve();
|
|
279
|
+
};
|
|
280
|
+
const timer = deadline < Infinity
|
|
281
|
+
? setTimeout(() => {
|
|
282
|
+
const at = this.waiting.indexOf(start);
|
|
283
|
+
if (at >= 0)
|
|
284
|
+
this.waiting.splice(at, 1);
|
|
285
|
+
this.running++;
|
|
286
|
+
resolve();
|
|
287
|
+
}, deadline - Date.now())
|
|
288
|
+
: undefined;
|
|
289
|
+
this.waiting.push(start);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
give() {
|
|
293
|
+
// A place taken over the size, past a deadline, is given back rather than handed on.
|
|
294
|
+
const next = this.running > this.size ? undefined : this.waiting.shift();
|
|
295
|
+
if (next)
|
|
296
|
+
next();
|
|
297
|
+
else
|
|
298
|
+
this.running--;
|
|
299
|
+
}
|
|
300
|
+
/** Starts every waiting run now; none waits again. */
|
|
301
|
+
release() {
|
|
302
|
+
this.released = true;
|
|
303
|
+
for (const run of this.waiting.splice(0)) {
|
|
304
|
+
this.running++;
|
|
305
|
+
run();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
183
309
|
/**
|
|
184
310
|
* Uploads values to Hue: reserves each with `POST /api/v1/otlp/blobs`, PUTs its bytes to the
|
|
185
|
-
* presigned URL Hue answers (skipped when Hue already stores the value), and completes it.
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* {@link
|
|
311
|
+
* presigned URL Hue answers (skipped when Hue already stores the value), and completes it. An
|
|
312
|
+
* export queues its values ({@link enqueue}) and sends its records without waiting for them; the
|
|
313
|
+
* import stores each value before it sends its span ({@link store}). At most
|
|
314
|
+
* {@link UPLOAD_CONCURRENCY} uploads run at once, and {@link HASH_CONCURRENCY} hashes apart from
|
|
315
|
+
* them. A reservation or PUT that gets no answer, a 408, a 429 or a 5xx is tried
|
|
316
|
+
* {@link UPLOAD_ATTEMPTS} times; every request has a deadline and none follows a redirect. A
|
|
317
|
+
* receiver without the route (a 404 without Hue's JSON error, a 405 or 501, or a 200 that is no
|
|
318
|
+
* reservation) is remembered for {@link UNSUPPORTED_PAUSE_MILLIS}: values exported meanwhile keep
|
|
319
|
+
* their fallback, never an entry the receiver cannot resolve.
|
|
191
320
|
*/
|
|
192
321
|
export class BlobUploader {
|
|
193
322
|
options;
|
|
323
|
+
/** Until when the receiver is taken to lack the upload route. */
|
|
194
324
|
unsupportedUntil = 0;
|
|
195
|
-
pausedUntil = 0;
|
|
196
325
|
closed = false;
|
|
197
|
-
|
|
198
|
-
|
|
326
|
+
uploads = new Slots(UPLOAD_CONCURRENCY);
|
|
327
|
+
hashes = new Slots(HASH_CONCURRENCY);
|
|
328
|
+
queued = 0;
|
|
329
|
+
queuedBytes = 0;
|
|
330
|
+
/** What queued uploads keep alive, each charged once, from the first upload holding it until
|
|
331
|
+
* the last settles: two files read in place from one recorded string charge it once, whole. */
|
|
332
|
+
holding = new Map();
|
|
333
|
+
sleeps = new Set();
|
|
199
334
|
requests = new Set();
|
|
200
335
|
outcomes = new Map();
|
|
336
|
+
/** The first wait before another attempt, doubled each time; tests shorten it. */
|
|
337
|
+
retryDelayMillis = RETRY_DELAY_MILLIS;
|
|
338
|
+
/** How long a queued upload may take; tests shorten it. */
|
|
339
|
+
lifetimeMillis;
|
|
201
340
|
constructor(options) {
|
|
202
341
|
this.options = options;
|
|
342
|
+
this.lifetimeMillis = uploadLifetimeMillis(options.timeoutMillis);
|
|
203
343
|
}
|
|
204
344
|
/** Whether a value over the inline limit is worth holding whole for upload now. */
|
|
205
345
|
available() {
|
|
@@ -209,35 +349,31 @@ export class BlobUploader {
|
|
|
209
349
|
unsupported() {
|
|
210
350
|
return Date.now() < this.unsupportedUntil;
|
|
211
351
|
}
|
|
212
|
-
/** Ends uploads in flight and refuses new ones:
|
|
352
|
+
/** Ends uploads in flight, queued uploads and their waits, and refuses new ones: values not
|
|
353
|
+
* yet exported fall back to the inline cut, and uploads queued by exported records fail. */
|
|
213
354
|
close() {
|
|
214
355
|
this.closed = true;
|
|
215
356
|
for (const request of this.requests)
|
|
216
357
|
request.destroy();
|
|
217
358
|
this.requests.clear();
|
|
218
359
|
this.outcomes.clear();
|
|
219
|
-
for (const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
360
|
+
for (const cancel of [...this.sleeps])
|
|
361
|
+
cancel();
|
|
362
|
+
this.uploads.release();
|
|
363
|
+
this.hashes.release();
|
|
223
364
|
}
|
|
224
365
|
/** Runs `work` once fewer than {@link UPLOAD_CONCURRENCY} uploads are in flight. */
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (next)
|
|
237
|
-
next();
|
|
238
|
-
else
|
|
239
|
-
this.running--;
|
|
240
|
-
}
|
|
366
|
+
limit(work) {
|
|
367
|
+
return this.uploads.run(work);
|
|
368
|
+
}
|
|
369
|
+
/** The SHA-256 and size of `value` (see {@link digestOf}), hashed in a place of its own: an
|
|
370
|
+
* export never waits for an upload to hash its values. */
|
|
371
|
+
digest(value, deadline) {
|
|
372
|
+
return this.hashes.run(() => digestOf(value, deadline));
|
|
373
|
+
}
|
|
374
|
+
/** The bytes queued uploads keep alive until they settle. */
|
|
375
|
+
pendingBytes() {
|
|
376
|
+
return this.queuedBytes;
|
|
241
377
|
}
|
|
242
378
|
/** Why a value cannot be uploaded right now without trying, if it cannot. */
|
|
243
379
|
refusal() {
|
|
@@ -245,21 +381,10 @@ export class BlobUploader {
|
|
|
245
381
|
return "failed";
|
|
246
382
|
if (this.unsupported())
|
|
247
383
|
return "unsupported";
|
|
248
|
-
if (Date.now() < this.pausedUntil)
|
|
249
|
-
return "failed";
|
|
250
384
|
return undefined;
|
|
251
385
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
/**
|
|
256
|
-
* Stores `value` under `key` of the trace: reserved, uploaded unless Hue already holds it, and
|
|
257
|
-
* completed. Only a fresh upload takes a {@link limit} slot: another reference to bytes the
|
|
258
|
-
* trace is already storing shares that upload's outcome without one, so repeated files never
|
|
259
|
-
* hold the slots new files need. The caller must not hold a slot.
|
|
260
|
-
*/
|
|
261
|
-
async store(traceId, key, value, contentType, digest, upload) {
|
|
262
|
-
// Every occurrence is validated even when a previous occurrence already uploaded the bytes.
|
|
386
|
+
/** Why the value cannot be uploaded at all, whatever Hue would answer. */
|
|
387
|
+
invalid(traceId, key, value, contentType, digest) {
|
|
263
388
|
if (!isBlobKey(key) ||
|
|
264
389
|
!isBlobContentType(contentType) ||
|
|
265
390
|
!/^[0-9a-f]{32}$/.test(traceId) ||
|
|
@@ -268,9 +393,95 @@ export class BlobUploader {
|
|
|
268
393
|
!Number.isSafeInteger(digest.size) ||
|
|
269
394
|
digest.size <= 0 ||
|
|
270
395
|
digest.size !== sizeOf(value))
|
|
271
|
-
return
|
|
396
|
+
return "failed";
|
|
272
397
|
if (digest.size > MAX_BLOB_BYTES)
|
|
273
|
-
return
|
|
398
|
+
return "tooLarge";
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Queues `value`'s upload for an exported record, deciding now what the record lists: a value
|
|
403
|
+
* refused without trying (see {@link refusal}), invalid, or past the queue's
|
|
404
|
+
* {@link MAX_QUEUED_UPLOADS} uploads or {@link MAX_HELD_BLOB_BYTES} keeps its fallback. A queued
|
|
405
|
+
* upload runs within {@link uploadLifetimeMillis} of now, holding `value` until it settles;
|
|
406
|
+
* `done` never rejects. `bytes` is what it adds to {@link pendingBytes}: the strings it keeps
|
|
407
|
+
* alive that no other queued upload holds, charged until `released`, once the last upload
|
|
408
|
+
* holding them settles.
|
|
409
|
+
*/
|
|
410
|
+
enqueue(traceId, key, value, contentType, digest) {
|
|
411
|
+
const refused = this.refusal() ?? this.invalid(traceId, key, value, contentType, digest);
|
|
412
|
+
if (refused)
|
|
413
|
+
return { ok: false, reason: refused };
|
|
414
|
+
const held = heldBy(value);
|
|
415
|
+
const fresh = held.filter((text) => !this.holding.has(text));
|
|
416
|
+
const bytes = fresh.reduce((total, text) => total + text.bytes, 0);
|
|
417
|
+
// An upload whose strings are all held already adds no memory, so only the job cap applies.
|
|
418
|
+
if (this.queued >= MAX_QUEUED_UPLOADS ||
|
|
419
|
+
(bytes > 0 && this.queuedBytes > 0 && this.queuedBytes + bytes > MAX_HELD_BLOB_BYTES))
|
|
420
|
+
return { ok: false, reason: "budget" };
|
|
421
|
+
this.queued++;
|
|
422
|
+
this.queuedBytes += bytes;
|
|
423
|
+
const released = fresh.map((text) => new Promise((release) => {
|
|
424
|
+
this.holding.set(text, { uploads: 0, release });
|
|
425
|
+
}));
|
|
426
|
+
for (const text of held)
|
|
427
|
+
this.holding.get(text).uploads++;
|
|
428
|
+
const upload = {
|
|
429
|
+
deadline: Date.now() + this.lifetimeMillis,
|
|
430
|
+
wait: (millis) => this.sleep(millis),
|
|
431
|
+
};
|
|
432
|
+
// store() never rejects; the job holds `value` only until it settles.
|
|
433
|
+
const done = this.store(traceId, key, value, contentType, digest, upload).finally(() => {
|
|
434
|
+
this.queued--;
|
|
435
|
+
for (const text of held) {
|
|
436
|
+
const holding = this.holding.get(text);
|
|
437
|
+
if (--holding.uploads > 0)
|
|
438
|
+
continue;
|
|
439
|
+
this.holding.delete(text);
|
|
440
|
+
this.queuedBytes -= text.bytes;
|
|
441
|
+
holding.release();
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
return { ok: true, done, bytes, released: Promise.all(released).then(() => { }) };
|
|
445
|
+
}
|
|
446
|
+
/** Waits `millis`; false when {@link close} ended the wait first. The timer keeps the process
|
|
447
|
+
* running, as an export in progress does, within the upload's lifetime. */
|
|
448
|
+
sleep(millis) {
|
|
449
|
+
if (this.closed)
|
|
450
|
+
return Promise.resolve(false);
|
|
451
|
+
return new Promise((resolve) => {
|
|
452
|
+
const done = (ran) => {
|
|
453
|
+
clearTimeout(timer);
|
|
454
|
+
this.sleeps.delete(cancel);
|
|
455
|
+
resolve(ran && !this.closed);
|
|
456
|
+
};
|
|
457
|
+
const cancel = () => done(false);
|
|
458
|
+
const timer = setTimeout(() => done(true), millis);
|
|
459
|
+
this.sleeps.add(cancel);
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
/** Waits before attempt `attempt + 1`: `Retry-After` when the answer gave one, otherwise a
|
|
463
|
+
* second doubled each attempt with jitter, at most a minute. The upload's place is given up
|
|
464
|
+
* meanwhile, so uploads queued behind it run. False when the wait would pass the upload's
|
|
465
|
+
* deadline, was ended, or no place came free again before the deadline. */
|
|
466
|
+
async retryWait(attempt, retryAfter, upload) {
|
|
467
|
+
const millis = Math.min(retryAfter ?? this.retryDelayMillis * (2 ** attempt + Math.random()), MAX_RETRY_AFTER_MILLIS);
|
|
468
|
+
if (this.closed || Date.now() + millis >= upload.deadline)
|
|
469
|
+
return false;
|
|
470
|
+
return ((await this.uploads.away(() => upload.wait(millis), upload.deadline)) &&
|
|
471
|
+
!this.closed &&
|
|
472
|
+
Date.now() < upload.deadline);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Stores `value` under `key` of the trace: reserved, uploaded unless Hue already holds it, and
|
|
476
|
+
* completed. Only a fresh upload takes a {@link limit} slot: another reference to bytes the
|
|
477
|
+
* trace is already storing shares that upload's outcome without one, so repeated files never
|
|
478
|
+
* hold the slots new files need. The caller must not hold a slot. Never rejects.
|
|
479
|
+
*/
|
|
480
|
+
async store(traceId, key, value, contentType, digest, upload) {
|
|
481
|
+
// Every occurrence is validated even when a previous occurrence already uploaded the bytes.
|
|
482
|
+
const invalid = this.invalid(traceId, key, value, contentType, digest);
|
|
483
|
+
if (invalid)
|
|
484
|
+
return { ok: false, reason: invalid };
|
|
274
485
|
const identity = `${traceId}:${digest.sha256}`;
|
|
275
486
|
for (let known = this.outcomes.get(identity); known; known = this.outcomes.get(identity)) {
|
|
276
487
|
const shared = await known.promise;
|
|
@@ -303,6 +514,9 @@ export class BlobUploader {
|
|
|
303
514
|
const refused = this.refusal();
|
|
304
515
|
if (refused)
|
|
305
516
|
return { ok: false, reason: refused };
|
|
517
|
+
// Queued past its lifetime: not tried. Failed, not budget: the import holds its span back.
|
|
518
|
+
if (Date.now() >= upload.deadline)
|
|
519
|
+
return { ok: false, reason: "failed" };
|
|
306
520
|
if (digest.size > MAX_BLOB_BYTES)
|
|
307
521
|
return { ok: false, reason: "tooLarge" };
|
|
308
522
|
if (!isBlobKey(key) ||
|
|
@@ -319,8 +533,7 @@ export class BlobUploader {
|
|
|
319
533
|
});
|
|
320
534
|
let reservation = await this.reserve(body, digest.size, upload);
|
|
321
535
|
let presigns = 1;
|
|
322
|
-
let
|
|
323
|
-
while (!("fallback" in reservation) && !reservation.exists) {
|
|
536
|
+
for (let attempt = 0; !("fallback" in reservation) && !reservation.exists;) {
|
|
324
537
|
const { grant } = reservation;
|
|
325
538
|
const put = await this.put(grant, value, digest.size, upload.deadline);
|
|
326
539
|
if (put === "stored")
|
|
@@ -329,19 +542,13 @@ export class BlobUploader {
|
|
|
329
542
|
return { ok: false, reason: "failed", changed: true };
|
|
330
543
|
if (this.closed)
|
|
331
544
|
return { ok: false, reason: "failed" };
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
Date.now() + RETRY_DELAY_MILLIS < upload.deadline) {
|
|
336
|
-
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MILLIS));
|
|
545
|
+
if (put === "transient") {
|
|
546
|
+
if (++attempt >= UPLOAD_ATTEMPTS || !(await this.retryWait(attempt - 1, undefined, upload)))
|
|
547
|
+
return { ok: false, reason: "failed" };
|
|
337
548
|
if (Date.now() < grant.expiresAt)
|
|
338
549
|
continue;
|
|
339
550
|
}
|
|
340
|
-
else if (put
|
|
341
|
-
this.pause();
|
|
342
|
-
return { ok: false, reason: "failed" };
|
|
343
|
-
}
|
|
344
|
-
else if (!expired)
|
|
551
|
+
else if (put !== "expired" && Date.now() < grant.expiresAt)
|
|
345
552
|
return { ok: false, reason: "failed" };
|
|
346
553
|
// The URL expired: reserved again once.
|
|
347
554
|
if (presigns++ >= 2)
|
|
@@ -350,13 +557,10 @@ export class BlobUploader {
|
|
|
350
557
|
}
|
|
351
558
|
if ("fallback" in reservation)
|
|
352
559
|
return { ok: false, reason: reservation.fallback };
|
|
353
|
-
if (!reservation.exists)
|
|
354
|
-
// For Hue's accounting only: a value is read whether or not it was completed,
|
|
355
|
-
// completion is not
|
|
356
|
-
|
|
357
|
-
if (!completion || completion.status >= 500 || completion.status === 429)
|
|
358
|
-
this.pause();
|
|
359
|
-
}
|
|
560
|
+
if (!reservation.exists)
|
|
561
|
+
// For Hue's accounting only: a value is read whether or not it was completed, and Hue
|
|
562
|
+
// stamps a missed completion on the next reservation, so its outcome is not read.
|
|
563
|
+
await this.request("POST", new URL("/api/v1/otlp/blobs/complete", this.options.baseUrl), this.jsonHeaders(), JSON.stringify({ traceId, sha256: digest.sha256 }), Math.min(upload.deadline, Date.now() + this.options.timeoutMillis), MAX_JSON_RESPONSE_BYTES);
|
|
360
564
|
return { ok: true, contentType: reservation.contentType };
|
|
361
565
|
}
|
|
362
566
|
jsonHeaders() {
|
|
@@ -366,71 +570,46 @@ export class BlobUploader {
|
|
|
366
570
|
"User-Agent": this.options.userAgent,
|
|
367
571
|
};
|
|
368
572
|
}
|
|
369
|
-
/** Reserves the value: Hue answers that it holds it already, or a presigned PUT.
|
|
370
|
-
*
|
|
573
|
+
/** Reserves the value: Hue answers that it holds it already, or a presigned PUT. No answer, a
|
|
574
|
+
* 408, a 429 or a 5xx other than 501 is tried again, after its `Retry-After` when it gave one,
|
|
575
|
+
* within the upload's deadline. */
|
|
371
576
|
async reserve(body, size, upload) {
|
|
372
577
|
const url = new URL("/api/v1/otlp/blobs", this.options.baseUrl);
|
|
373
|
-
for (let attempt = 0
|
|
578
|
+
for (let attempt = 0;; attempt++) {
|
|
374
579
|
if (this.closed)
|
|
375
580
|
return { fallback: "failed" };
|
|
376
581
|
const reply = await this.request("POST", url, this.jsonHeaders(), body, Math.min(upload.deadline, Date.now() + this.options.timeoutMillis), MAX_JSON_RESPONSE_BYTES);
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
return this.reservation(answer, size);
|
|
405
|
-
if (status === 413)
|
|
406
|
-
return { fallback: "tooLarge" };
|
|
407
|
-
if (status === 429) {
|
|
408
|
-
const wait = retryAfterMillis(reply.headers) ?? RETRY_DELAY_MILLIS;
|
|
409
|
-
if (Date.now() + wait < upload.deadline && (await upload.wait(wait)))
|
|
410
|
-
continue;
|
|
411
|
-
this.pause();
|
|
412
|
-
return { fallback: "failed" };
|
|
582
|
+
let retryAfter;
|
|
583
|
+
if (reply) {
|
|
584
|
+
const { status } = reply;
|
|
585
|
+
const answer = status === 200 || status === 404 || status === 409 ? parseJson(reply.body) : undefined;
|
|
586
|
+
// A receiver without the route answers its framework's not-found (Hue's own 404, an
|
|
587
|
+
// archived project, is JSON with an `error`), refuses the method, or answers something
|
|
588
|
+
// that is no reservation at all: it is not asked again for a while, since the values
|
|
589
|
+
// exported while it answers are listed for bytes it cannot store.
|
|
590
|
+
if (status === 405 ||
|
|
591
|
+
status === 501 ||
|
|
592
|
+
(status === 404 && !(isObject(answer) && typeof answer.error === "string")) ||
|
|
593
|
+
(status === 200 &&
|
|
594
|
+
!(isObject(answer) && (answer.status === "exists" || answer.status === "upload")))) {
|
|
595
|
+
this.unsupportedUntil = Date.now() + UNSUPPORTED_PAUSE_MILLIS;
|
|
596
|
+
return { fallback: "unsupported" };
|
|
597
|
+
}
|
|
598
|
+
// A reservation that cannot be used fails its value.
|
|
599
|
+
if (status === 200)
|
|
600
|
+
return this.reservation(answer, size);
|
|
601
|
+
if (status === 409 && isObject(answer) && answer.status === "exists")
|
|
602
|
+
return this.reservation(answer, size);
|
|
603
|
+
if (status === 413)
|
|
604
|
+
return { fallback: "tooLarge" };
|
|
605
|
+
// Refusals of the value or the key (400, 401, 403, Hue's own 404, a conflict) are final.
|
|
606
|
+
if (status !== 408 && status !== 429 && status < 500)
|
|
607
|
+
return { fallback: "failed" };
|
|
608
|
+
retryAfter = retryAfterMillis(reply.headers);
|
|
413
609
|
}
|
|
414
|
-
if (
|
|
415
|
-
const wait = retryAfterMillis(reply.headers) ?? RETRY_DELAY_MILLIS;
|
|
416
|
-
if (attempt === 0 && wait <= MAX_TRANSIENT_WAIT_MILLIS && (await this.delay(wait, upload)))
|
|
417
|
-
continue;
|
|
418
|
-
this.pause();
|
|
610
|
+
if (attempt + 1 >= UPLOAD_ATTEMPTS || !(await this.retryWait(attempt, retryAfter, upload)))
|
|
419
611
|
return { fallback: "failed" };
|
|
420
|
-
}
|
|
421
|
-
// Bad values and conflicts refuse only this value. Transient service failures pause uploads.
|
|
422
|
-
if (status !== 400 && status !== 409)
|
|
423
|
-
this.pause();
|
|
424
|
-
return { fallback: "failed" };
|
|
425
612
|
}
|
|
426
|
-
this.pause();
|
|
427
|
-
return { fallback: "failed" };
|
|
428
|
-
}
|
|
429
|
-
async delay(millis, upload) {
|
|
430
|
-
if (Date.now() + millis >= upload.deadline)
|
|
431
|
-
return false;
|
|
432
|
-
await new Promise((resolve) => setTimeout(resolve, millis));
|
|
433
|
-
return !this.closed;
|
|
434
613
|
}
|
|
435
614
|
/** A 200 answer read: `exists`, or an upload whose URL and headers are usable as given. */
|
|
436
615
|
reservation(answer, size) {
|
|
@@ -626,9 +805,9 @@ export class BlobUploader {
|
|
|
626
805
|
}
|
|
627
806
|
/**
|
|
628
807
|
* A span's own attributes with each candidate placed: binary media becomes a digest, while
|
|
629
|
-
* text media over 64 KiB retains a 16 KiB prefix
|
|
630
|
-
* A value still over the inline limit is uploaded and replaced by its first
|
|
631
|
-
*
|
|
808
|
+
* text media over 64 KiB retains a 16 KiB prefix once uploaded or queued, or a digest when
|
|
809
|
+
* refused. A value still over the inline limit is uploaded and replaced by its first 16 KiB (or
|
|
810
|
+
* cut to the limit when refused), each upload listed under `hue.blobs`, each whole value cut or
|
|
632
811
|
* uploaded listed under `hue.truncated`. Every fallback is counted in the tally.
|
|
633
812
|
*/
|
|
634
813
|
export async function offloadAttributes(attributes, collector, options) {
|
|
@@ -636,12 +815,12 @@ export async function offloadAttributes(attributes, collector, options) {
|
|
|
636
815
|
}
|
|
637
816
|
/**
|
|
638
817
|
* A record's own attributes, and a log record's body, with each candidate placed (see
|
|
639
|
-
* {@link offloadAttributes}). A file is uploaded
|
|
640
|
-
* provider part keeps a 16 KiB prefix), its `hue.blobs` entry keyed
|
|
641
|
-
* a log record's body, `body#<pointer>`. A file
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
* receiver's marker, as before.
|
|
818
|
+
* {@link offloadAttributes}). A file is uploaded (or, for an export, queued) and placed in its
|
|
819
|
+
* digest form (a text file in a provider part keeps a 16 KiB prefix), its `hue.blobs` entry keyed
|
|
820
|
+
* `<attribute>#<pointer>` or, in a log record's body, `body#<pointer>`. A file refused or not
|
|
821
|
+
* uploaded before it is placed keeps its bytes inline, except in a span's own recorded messages,
|
|
822
|
+
* where it keeps its digest as before. A log record's values over the inline limit are cut, never
|
|
823
|
+
* uploaded whole, and a body still over it is replaced by the receiver's marker, as before.
|
|
645
824
|
*/
|
|
646
825
|
export async function offloadRecord(attributes, collector, options) {
|
|
647
826
|
const { uploader, traceId, valueBytes, upload, tally } = options;
|
|
@@ -655,14 +834,33 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
655
834
|
const entries = [];
|
|
656
835
|
const listed = [];
|
|
657
836
|
const fallback = (reason) => countFallback(tally, reason, traceId);
|
|
837
|
+
/** Stores a value (the import), or queues its upload (an export): what the record lists. A
|
|
838
|
+
* queued value's entry names the type its own reference declared. `shared` when the value is
|
|
839
|
+
* memory its record holds (not decoded or rewritten from it). */
|
|
840
|
+
const submit = async (key, value, contentType, digest, shared) => {
|
|
841
|
+
const late = options.background;
|
|
842
|
+
if (!late) {
|
|
843
|
+
const outcome = await uploader.store(traceId, key, value, contentType, digest, upload);
|
|
844
|
+
if (outcome.ok)
|
|
845
|
+
tally.uploaded++;
|
|
846
|
+
return outcome;
|
|
847
|
+
}
|
|
848
|
+
const queued = uploader.enqueue(traceId, key, value, contentType, digest);
|
|
849
|
+
if (!queued.ok)
|
|
850
|
+
return queued;
|
|
851
|
+
const settled = countWhenSettled(queued.done, late.tally, traceId);
|
|
852
|
+
late.settled.push(settled);
|
|
853
|
+
late.queued?.(settled, shared ? queued.bytes : 0, queued.released);
|
|
854
|
+
return { ok: true, contentType };
|
|
855
|
+
};
|
|
658
856
|
// A value the queue cut when it was admitted was never held whole to upload.
|
|
659
857
|
for (let index = 0; index < collector.cut.length; index++)
|
|
660
858
|
fallback(uploader.unsupported() ? "unsupported" : "budget");
|
|
661
859
|
let inspected = 0;
|
|
662
|
-
/** Uploads each file of a value and places it; whether any was placed. A file
|
|
663
|
-
* uploaded keeps its bytes inline, except in a recorded message: a span's own
|
|
664
|
-
* digest, and a log record's keeps the digest of a file over the inline limit,
|
|
665
|
-
* messages kept them before log records uploaded. */
|
|
860
|
+
/** Uploads (or queues) each file of a value and places it; whether any was placed. A file
|
|
861
|
+
* refused or not uploaded keeps its bytes inline, except in a recorded message: a span's own
|
|
862
|
+
* message keeps its digest, and a log record's keeps the digest of a file over the inline limit,
|
|
863
|
+
* as nested messages kept them before log records uploaded. */
|
|
666
864
|
const placeFiles = async (index, key, files, long) => {
|
|
667
865
|
const message = isMessageKey(key);
|
|
668
866
|
let changed = false;
|
|
@@ -683,12 +881,12 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
683
881
|
const partKey = `${key}#${prefix ? file.pointer : file.partPointer}`;
|
|
684
882
|
const slot = slots > 0 && isBlobKey(partKey) ? (slots--, true) : false;
|
|
685
883
|
return (async () => {
|
|
686
|
-
//
|
|
687
|
-
//
|
|
884
|
+
// A long file is hashed in a hashing place, as a large value is; one the export's
|
|
885
|
+
// hashing deadline cannot hash stays as recorded and is not uploaded.
|
|
688
886
|
let digest;
|
|
689
887
|
try {
|
|
690
888
|
digest = isByteSource(bytes)
|
|
691
|
-
? await uploader.
|
|
889
|
+
? await uploader.digest(bytes, upload.deadline)
|
|
692
890
|
: options.memo
|
|
693
891
|
? options.memo.digest(file, decoded, traceId)
|
|
694
892
|
: await digestOf(bytes);
|
|
@@ -698,7 +896,7 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
698
896
|
return;
|
|
699
897
|
}
|
|
700
898
|
const outcome = slot
|
|
701
|
-
? await
|
|
899
|
+
? await submit(partKey, bytes, contentType, digest, isByteSource(bytes))
|
|
702
900
|
: { ok: false, reason: "budget" };
|
|
703
901
|
if (outcome.ok) {
|
|
704
902
|
if (prefix)
|
|
@@ -710,7 +908,6 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
710
908
|
order: [index, position],
|
|
711
909
|
entry: blobEntry(partKey, digest.sha256, digest.size, outcome.contentType, file.name),
|
|
712
910
|
});
|
|
713
|
-
tally.uploaded++;
|
|
714
911
|
}
|
|
715
912
|
else {
|
|
716
913
|
if (message && (!collector.log || size > INLINE_FILE_LIMIT)) {
|
|
@@ -833,10 +1030,10 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
833
1030
|
const stored = await (async () => {
|
|
834
1031
|
if (size > MAX_BLOB_BYTES)
|
|
835
1032
|
return { ok: false, reason: "tooLarge" };
|
|
836
|
-
//
|
|
1033
|
+
// A large value is hashed in a hashing place; its upload takes an upload place.
|
|
837
1034
|
let digest;
|
|
838
1035
|
try {
|
|
839
|
-
digest = await uploader.
|
|
1036
|
+
digest = await uploader.digest(placed, upload.deadline);
|
|
840
1037
|
}
|
|
841
1038
|
catch {
|
|
842
1039
|
return { ok: false, reason: "failed" };
|
|
@@ -846,7 +1043,7 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
846
1043
|
: typeof placed === "string"
|
|
847
1044
|
? textContentType(placed)
|
|
848
1045
|
: "application/octet-stream";
|
|
849
|
-
const outcome = await
|
|
1046
|
+
const outcome = await submit(key, placed, contentType, digest, !!joined || read === value);
|
|
850
1047
|
return outcome.ok ? { ...outcome, digest } : outcome;
|
|
851
1048
|
})();
|
|
852
1049
|
if (stored.ok) {
|
|
@@ -859,7 +1056,6 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
859
1056
|
order: [index, Infinity],
|
|
860
1057
|
entry: blobEntry(key, stored.digest.sha256, stored.digest.size, stored.contentType),
|
|
861
1058
|
});
|
|
862
|
-
tally.uploaded++;
|
|
863
1059
|
}
|
|
864
1060
|
else {
|
|
865
1061
|
result[key] = cut();
|
|
@@ -875,14 +1071,13 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
875
1071
|
return (async () => {
|
|
876
1072
|
const key = mediaKey(file);
|
|
877
1073
|
const outcome = slot
|
|
878
|
-
? await
|
|
1074
|
+
? await submit(key, file.bytes, file.contentType, file.digest, true)
|
|
879
1075
|
: { ok: false, reason: "budget" };
|
|
880
1076
|
if (outcome.ok) {
|
|
881
1077
|
entries.push({
|
|
882
1078
|
order: [collector.candidates.length, position],
|
|
883
1079
|
entry: blobEntry(key, file.digest.sha256, file.digest.size, outcome.contentType, file.name),
|
|
884
1080
|
});
|
|
885
|
-
tally.uploaded++;
|
|
886
1081
|
}
|
|
887
1082
|
else
|
|
888
1083
|
fallback(outcome.reason);
|