@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/blobs.js
ADDED
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { context } from "@opentelemetry/api";
|
|
3
|
+
import { suppressTracing } from "@opentelemetry/core";
|
|
4
|
+
import { isLoopbackHost } from "./config.js";
|
|
5
|
+
import { digestPart, hashInlineFiles, INLINE_FILE_LIMIT, INLINE_FILE_TEXT_PER_RECORD, inlineFileBytes, isMessageKey, largeInlineFileParts, MAX_INLINE_FILE_TEXT, } from "./inline-files.js";
|
|
6
|
+
import { TRUNCATED_KEY, truncatedMarker, withTruncatedKeys } from "./privacy.js";
|
|
7
|
+
import { truncateUtf8 } from "./safety.js";
|
|
8
|
+
/**
|
|
9
|
+
* Values larger than Hue's inline limit travel apart from their span: the SDK uploads the value's
|
|
10
|
+
* bytes straight to the project's evidence store through a presigned PUT that Hue answers at
|
|
11
|
+
* `POST /api/v1/otlp/blobs`, keeps the value's first 16 KiB in the attribute it replaced, and
|
|
12
|
+
* lists the value in the span attribute `hue.blobs`. A value that cannot be uploaded is exported
|
|
13
|
+
* as it was before uploads existed: cut to the inline limit and listed under `hue.truncated` (an
|
|
14
|
+
* inline file as its digest), reported as a warning and counted.
|
|
15
|
+
*/
|
|
16
|
+
/** The span attribute listing the values the SDK uploaded apart from the span: one compact JSON
|
|
17
|
+
* object per value, `{"key", "sha256", "size", "content_type"}`, as Hue reads it. */
|
|
18
|
+
export const BLOBS_KEY = "hue.blobs";
|
|
19
|
+
/** The largest value one upload may hold (`Hue-Max-Blob-Bytes`): a larger value is cut inline. */
|
|
20
|
+
export const MAX_BLOB_BYTES = 1_000_000_000;
|
|
21
|
+
/** What an uploaded value keeps inline: its first 16 KiB, the receiver's search excerpt. */
|
|
22
|
+
export const BLOB_PREFIX_BYTES = 16 * 1024;
|
|
23
|
+
/** The entries one record's `hue.blobs` may list; Hue reads no more. */
|
|
24
|
+
export const MAX_BLOB_ENTRIES = 64;
|
|
25
|
+
const MAX_BLOB_KEY_LENGTH = 256;
|
|
26
|
+
/**
|
|
27
|
+
* The values over the inline limit that queued spans may hold whole for upload at once, charged
|
|
28
|
+
* apart from `maxQueueBytes` (a string as two bytes a code unit, bytes as themselves). A value
|
|
29
|
+
* the budget cannot hold is cut when it is queued, as before uploads existed; a single value
|
|
30
|
+
* larger than the whole budget is held while nothing else is.
|
|
31
|
+
*/
|
|
32
|
+
export const MAX_HELD_BLOB_BYTES = 128 * 1024 * 1024;
|
|
33
|
+
/** Uploads in flight at once, across both signals' exports. */
|
|
34
|
+
const UPLOAD_CONCURRENCY = 4;
|
|
35
|
+
/** How long a receiver without the upload route is believed to stay without it. */
|
|
36
|
+
const UNSUPPORTED_PAUSE_MILLIS = 10 * 60_000;
|
|
37
|
+
/** How long uploads pause after one failed for a transient reason (a 5xx, the network, a
|
|
38
|
+
* timeout), so an unavailable store meets no retry storm: values fall back to the inline cut. */
|
|
39
|
+
const FAILURE_PAUSE_MILLIS = 30_000;
|
|
40
|
+
const MAX_JSON_RESPONSE_BYTES = 64 * 1024;
|
|
41
|
+
const MAX_ERROR_RESPONSE_BYTES = 4096;
|
|
42
|
+
/** Text is encoded to UTF-8 a slice at a time to hash and upload it: no full copy is held. */
|
|
43
|
+
const TEXT_CHUNK_UNITS = 1 << 20;
|
|
44
|
+
const RETRY_DELAY_MILLIS = 1000;
|
|
45
|
+
/** The longest `Retry-After` a transient refusal of a reservation is waited for. */
|
|
46
|
+
const MAX_TRANSIENT_WAIT_MILLIS = 5000;
|
|
47
|
+
/** How long one export may spend uploading its values, on top of its requests. */
|
|
48
|
+
export function uploadBudgetMillis(timeoutMillis) {
|
|
49
|
+
return timeoutMillis * 6;
|
|
50
|
+
}
|
|
51
|
+
/** The fixed description of each fallback, as an export issue reports it. */
|
|
52
|
+
export const FALLBACK_MESSAGES = {
|
|
53
|
+
unsupported: "This Hue server does not accept uploaded values; values over its inline limit were exported cut to it (inline files as their digest)",
|
|
54
|
+
failed: "Values over Hue's inline limit could not be uploaded; they were exported cut to it (inline files as their digest)",
|
|
55
|
+
tooLarge: "Values over Hue's 1 GB upload limit were exported cut to the inline limit",
|
|
56
|
+
budget: "Values over Hue's inline limit did not fit the SDK's upload budget; they were exported cut to it (inline files as their digest)",
|
|
57
|
+
};
|
|
58
|
+
export function newTally() {
|
|
59
|
+
return { uploaded: 0, fallbacks: new Map() };
|
|
60
|
+
}
|
|
61
|
+
function countFallback(tally, reason, traceId) {
|
|
62
|
+
const entry = tally.fallbacks.get(reason) ?? { count: 0, traceIds: new Set() };
|
|
63
|
+
entry.count++;
|
|
64
|
+
entry.traceIds.add(traceId);
|
|
65
|
+
tally.fallbacks.set(reason, entry);
|
|
66
|
+
}
|
|
67
|
+
/** A media type Hue accepts for a value: `type/subtype` with optional parameters, 3 to 255
|
|
68
|
+
* printable ASCII characters. */
|
|
69
|
+
const CONTENT_TYPE = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9!#$&^_.+-]+(?: *;[ -~]*)?$/;
|
|
70
|
+
export function isBlobContentType(value) {
|
|
71
|
+
return (typeof value === "string" &&
|
|
72
|
+
value.length >= 3 &&
|
|
73
|
+
value.length <= 255 &&
|
|
74
|
+
CONTENT_TYPE.test(value));
|
|
75
|
+
}
|
|
76
|
+
function isBlobKey(key) {
|
|
77
|
+
if (key.length < 1 || key.length > MAX_BLOB_KEY_LENGTH)
|
|
78
|
+
return false;
|
|
79
|
+
for (let index = 0; index < key.length; index++) {
|
|
80
|
+
const code = key.charCodeAt(index);
|
|
81
|
+
if (code < 0x20 || code === 0x7f)
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
/** The media type an uploaded text is stored under: JSON when it reads as a JSON object or array
|
|
87
|
+
* (a structured value's text), plain UTF-8 text otherwise. Both are previewed as text. */
|
|
88
|
+
export function textContentType(text) {
|
|
89
|
+
const head = text.slice(0, 256).trimStart();
|
|
90
|
+
const tail = text.slice(-256).trimEnd();
|
|
91
|
+
return (head.startsWith("{") && tail.endsWith("}")) ||
|
|
92
|
+
(head.startsWith("[") && tail.endsWith("]"))
|
|
93
|
+
? "application/json"
|
|
94
|
+
: "text/plain; charset=utf-8";
|
|
95
|
+
}
|
|
96
|
+
/** The media type an inline file is stored under: the part's own (`mime_type`, `mediaType`), its
|
|
97
|
+
* `data:` URL's, or else bytes, or UTF-8 text for content that was not encoded. */
|
|
98
|
+
function fileContentType(part, file) {
|
|
99
|
+
for (const declared of [part.mime_type, part.mimeType, part.mediaType, part.media_type])
|
|
100
|
+
if (isBlobContentType(declared))
|
|
101
|
+
return declared;
|
|
102
|
+
if (isBlobContentType(file.urlMediaType))
|
|
103
|
+
return file.urlMediaType;
|
|
104
|
+
return file.decoded ? "application/octet-stream" : "text/plain; charset=utf-8";
|
|
105
|
+
}
|
|
106
|
+
export function blobPrefix(value) {
|
|
107
|
+
if (typeof value !== "string")
|
|
108
|
+
return Uint8Array.prototype.slice.call(value, 0, BLOB_PREFIX_BYTES);
|
|
109
|
+
// A slice of a longer string can keep the whole string alive; the decoded copy holds only the
|
|
110
|
+
// prefix.
|
|
111
|
+
return Buffer.from(truncateUtf8(value, BLOB_PREFIX_BYTES), "utf8").toString("utf8");
|
|
112
|
+
}
|
|
113
|
+
/** A value's size in bytes as it is uploaded: a string's UTF-8 encoding, counted, not made. */
|
|
114
|
+
function sizeOf(value) {
|
|
115
|
+
return typeof value === "string" ? Buffer.byteLength(value, "utf8") : value.byteLength;
|
|
116
|
+
}
|
|
117
|
+
/** A string's UTF-8 encoding a slice at a time, never splitting a surrogate pair. */
|
|
118
|
+
function* utf8Chunks(text) {
|
|
119
|
+
for (let start = 0; start < text.length;) {
|
|
120
|
+
let end = Math.min(text.length, start + TEXT_CHUNK_UNITS);
|
|
121
|
+
if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1]))
|
|
122
|
+
end--;
|
|
123
|
+
yield Buffer.from(text.slice(start, end), "utf8");
|
|
124
|
+
start = end;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** The bytes an upload carries, a slice at a time: no second copy of a large value is made. */
|
|
128
|
+
function* uploadChunks(value) {
|
|
129
|
+
if (typeof value === "string") {
|
|
130
|
+
yield* utf8Chunks(value);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
for (let start = 0; start < value.byteLength; start += TEXT_CHUNK_UNITS)
|
|
134
|
+
yield value.subarray(start, Math.min(value.byteLength, start + TEXT_CHUNK_UNITS));
|
|
135
|
+
}
|
|
136
|
+
const nextTurn = () => new Promise((resolve) => setImmediate(resolve));
|
|
137
|
+
/** The SHA-256 (lowercase hex) and byte size of what an upload of `value` carries, yielding to
|
|
138
|
+
* the event loop between slices so a large value does not stall the application. */
|
|
139
|
+
export async function digestOf(value) {
|
|
140
|
+
const hash = createHash("sha256");
|
|
141
|
+
let size = 0;
|
|
142
|
+
let slices = 0;
|
|
143
|
+
for (const chunk of uploadChunks(value)) {
|
|
144
|
+
hash.update(chunk);
|
|
145
|
+
size += chunk.byteLength;
|
|
146
|
+
if (++slices % 4 === 0)
|
|
147
|
+
await nextTurn();
|
|
148
|
+
}
|
|
149
|
+
return { sha256: hash.digest("hex"), size };
|
|
150
|
+
}
|
|
151
|
+
/** The `hue.blobs` element for one uploaded value. */
|
|
152
|
+
function blobEntry(key, sha256, size, contentType) {
|
|
153
|
+
return JSON.stringify({ key, sha256, size, content_type: contentType });
|
|
154
|
+
}
|
|
155
|
+
/** HTTP header names (tokens) and values without line breaks or NUL. */
|
|
156
|
+
const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
157
|
+
const HEADER_VALUE = /^[\t\x20-\x7e\x80-\xff]*$/;
|
|
158
|
+
function retryAfterMillis(headers) {
|
|
159
|
+
const value = headers["retry-after"];
|
|
160
|
+
if (typeof value !== "string" || !/^\s*\d{1,6}\s*$/.test(value))
|
|
161
|
+
return undefined;
|
|
162
|
+
return Number(value.trim()) * 1000;
|
|
163
|
+
}
|
|
164
|
+
function parseJson(body) {
|
|
165
|
+
try {
|
|
166
|
+
return JSON.parse(body.toString("utf8"));
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function isObject(value) {
|
|
173
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Uploads values to Hue: reserves each with `POST /api/v1/otlp/blobs`, PUTs its bytes to the
|
|
177
|
+
* presigned URL Hue answers (skipped when Hue already stores the value), and completes it. At
|
|
178
|
+
* most {@link UPLOAD_CONCURRENCY} uploads run at once; every request has a deadline and none
|
|
179
|
+
* follows a redirect. A receiver without the route (a 404 without Hue's JSON error, a 405 or
|
|
180
|
+
* 501, or a 200 that is no reservation) is remembered for {@link UNSUPPORTED_PAUSE_MILLIS}, and a
|
|
181
|
+
* transient failure or a refusal that is not about the value alone pauses uploads for
|
|
182
|
+
* {@link FAILURE_PAUSE_MILLIS}, so values fall back to the inline cut instead of retrying.
|
|
183
|
+
*/
|
|
184
|
+
export class BlobUploader {
|
|
185
|
+
options;
|
|
186
|
+
unsupportedUntil = 0;
|
|
187
|
+
pausedUntil = 0;
|
|
188
|
+
closed = false;
|
|
189
|
+
running = 0;
|
|
190
|
+
waiting = [];
|
|
191
|
+
requests = new Set();
|
|
192
|
+
constructor(options) {
|
|
193
|
+
this.options = options;
|
|
194
|
+
}
|
|
195
|
+
/** Whether a value over the inline limit is worth holding whole for upload now. */
|
|
196
|
+
available() {
|
|
197
|
+
return !this.closed && Date.now() >= this.unsupportedUntil;
|
|
198
|
+
}
|
|
199
|
+
/** Whether the receiver lacks the upload route, as it last answered. */
|
|
200
|
+
unsupported() {
|
|
201
|
+
return Date.now() < this.unsupportedUntil;
|
|
202
|
+
}
|
|
203
|
+
/** Ends uploads in flight and refuses new ones: their values fall back to the inline cut. */
|
|
204
|
+
close() {
|
|
205
|
+
this.closed = true;
|
|
206
|
+
for (const request of this.requests)
|
|
207
|
+
request.destroy();
|
|
208
|
+
this.requests.clear();
|
|
209
|
+
for (const run of this.waiting.splice(0)) {
|
|
210
|
+
this.running++;
|
|
211
|
+
run();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Runs `work` once fewer than {@link UPLOAD_CONCURRENCY} uploads are in flight. */
|
|
215
|
+
async limit(work) {
|
|
216
|
+
// A finishing upload hands its place to the next one waiting, so none is ever taken twice.
|
|
217
|
+
if (this.running >= UPLOAD_CONCURRENCY && !this.closed)
|
|
218
|
+
await new Promise((resolve) => this.waiting.push(resolve));
|
|
219
|
+
else
|
|
220
|
+
this.running++;
|
|
221
|
+
try {
|
|
222
|
+
return await work();
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
const next = this.waiting.shift();
|
|
226
|
+
if (next)
|
|
227
|
+
next();
|
|
228
|
+
else
|
|
229
|
+
this.running--;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Why a value cannot be uploaded right now without trying, if it cannot. */
|
|
233
|
+
refusal() {
|
|
234
|
+
if (this.closed)
|
|
235
|
+
return "failed";
|
|
236
|
+
if (this.unsupported())
|
|
237
|
+
return "unsupported";
|
|
238
|
+
if (Date.now() < this.pausedUntil)
|
|
239
|
+
return "failed";
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
pause() {
|
|
243
|
+
this.pausedUntil = Date.now() + FAILURE_PAUSE_MILLIS;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Stores `value` under `key` of the trace: reserved, uploaded unless Hue already holds it, and
|
|
247
|
+
* completed. The caller holds a {@link limit} slot.
|
|
248
|
+
*/
|
|
249
|
+
async store(traceId, key, value, contentType, digest, upload) {
|
|
250
|
+
const refused = this.refusal();
|
|
251
|
+
if (refused)
|
|
252
|
+
return { ok: false, reason: refused };
|
|
253
|
+
if (digest.size > MAX_BLOB_BYTES)
|
|
254
|
+
return { ok: false, reason: "tooLarge" };
|
|
255
|
+
if (!isBlobKey(key) ||
|
|
256
|
+
!isBlobContentType(contentType) ||
|
|
257
|
+
!/^[0-9a-f]{32}$/.test(traceId) ||
|
|
258
|
+
/^0+$/.test(traceId))
|
|
259
|
+
return { ok: false, reason: "failed" };
|
|
260
|
+
const body = JSON.stringify({
|
|
261
|
+
traceId,
|
|
262
|
+
sha256: digest.sha256,
|
|
263
|
+
byteSize: digest.size,
|
|
264
|
+
contentType,
|
|
265
|
+
key,
|
|
266
|
+
});
|
|
267
|
+
let reservation = await this.reserve(body, digest.size, upload);
|
|
268
|
+
let presigns = 1;
|
|
269
|
+
let attempts = 0;
|
|
270
|
+
while (!("fallback" in reservation) && !reservation.exists) {
|
|
271
|
+
const { grant } = reservation;
|
|
272
|
+
const put = await this.put(grant, value, digest.size, upload.deadline);
|
|
273
|
+
if (put === "stored")
|
|
274
|
+
break;
|
|
275
|
+
if (this.closed)
|
|
276
|
+
return { ok: false, reason: "failed" };
|
|
277
|
+
const expired = put === "expired" || Date.now() >= grant.expiresAt;
|
|
278
|
+
if (put === "transient" &&
|
|
279
|
+
++attempts < 2 &&
|
|
280
|
+
Date.now() + RETRY_DELAY_MILLIS < upload.deadline) {
|
|
281
|
+
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MILLIS));
|
|
282
|
+
if (Date.now() < grant.expiresAt)
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
else if (put === "transient") {
|
|
286
|
+
this.pause();
|
|
287
|
+
return { ok: false, reason: "failed" };
|
|
288
|
+
}
|
|
289
|
+
else if (!expired)
|
|
290
|
+
return { ok: false, reason: "failed" };
|
|
291
|
+
// The URL expired: reserved again once.
|
|
292
|
+
if (presigns++ >= 2)
|
|
293
|
+
return { ok: false, reason: "failed" };
|
|
294
|
+
reservation = await this.reserve(body, digest.size, upload);
|
|
295
|
+
}
|
|
296
|
+
if ("fallback" in reservation)
|
|
297
|
+
return { ok: false, reason: reservation.fallback };
|
|
298
|
+
if (!reservation.exists)
|
|
299
|
+
// For Hue's accounting only: a value is read whether or not it was completed, so a failed
|
|
300
|
+
// completion is not retried.
|
|
301
|
+
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);
|
|
302
|
+
return { ok: true, contentType: reservation.contentType };
|
|
303
|
+
}
|
|
304
|
+
jsonHeaders() {
|
|
305
|
+
return {
|
|
306
|
+
Authorization: `Bearer ${this.options.apiKey}`,
|
|
307
|
+
"Content-Type": "application/json",
|
|
308
|
+
"User-Agent": this.options.userAgent,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/** Reserves the value: Hue answers that it holds it already, or a presigned PUT. A rate limit
|
|
312
|
+
* is waited out within the export's hold, a transient refusal retried once. */
|
|
313
|
+
async reserve(body, size, upload) {
|
|
314
|
+
const url = new URL("/api/v1/otlp/blobs", this.options.baseUrl);
|
|
315
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
316
|
+
if (this.closed)
|
|
317
|
+
return { fallback: "failed" };
|
|
318
|
+
const reply = await this.request("POST", url, this.jsonHeaders(), body, Math.min(upload.deadline, Date.now() + this.options.timeoutMillis), MAX_JSON_RESPONSE_BYTES);
|
|
319
|
+
if (!reply) {
|
|
320
|
+
if (attempt === 0 && (await this.delay(RETRY_DELAY_MILLIS, upload)))
|
|
321
|
+
continue;
|
|
322
|
+
this.pause();
|
|
323
|
+
return { fallback: "failed" };
|
|
324
|
+
}
|
|
325
|
+
const { status } = reply;
|
|
326
|
+
const answer = status === 200 || status === 404 ? parseJson(reply.body) : undefined;
|
|
327
|
+
// A receiver without the route answers its framework's not-found (Hue's own 404, an
|
|
328
|
+
// archived project, is JSON with an `error`), refuses the method, or answers something
|
|
329
|
+
// that is no reservation at all: it is not asked again for a while.
|
|
330
|
+
if (status === 405 ||
|
|
331
|
+
status === 501 ||
|
|
332
|
+
(status === 404 && !(isObject(answer) && typeof answer.error === "string")) ||
|
|
333
|
+
(status === 200 &&
|
|
334
|
+
!(isObject(answer) && (answer.status === "exists" || answer.status === "upload")))) {
|
|
335
|
+
this.unsupportedUntil = Date.now() + UNSUPPORTED_PAUSE_MILLIS;
|
|
336
|
+
return { fallback: "unsupported" };
|
|
337
|
+
}
|
|
338
|
+
if (status === 200) {
|
|
339
|
+
const reservation = this.reservation(answer, size);
|
|
340
|
+
// A reservation that cannot be used would fail every value alike.
|
|
341
|
+
if ("fallback" in reservation)
|
|
342
|
+
this.pause();
|
|
343
|
+
return reservation;
|
|
344
|
+
}
|
|
345
|
+
if (status === 413)
|
|
346
|
+
return { fallback: "tooLarge" };
|
|
347
|
+
if (status === 429) {
|
|
348
|
+
const wait = retryAfterMillis(reply.headers) ?? RETRY_DELAY_MILLIS;
|
|
349
|
+
if (Date.now() + wait < upload.deadline && (await upload.wait(wait)))
|
|
350
|
+
continue;
|
|
351
|
+
return { fallback: "failed" };
|
|
352
|
+
}
|
|
353
|
+
if (status >= 500) {
|
|
354
|
+
const wait = retryAfterMillis(reply.headers) ?? RETRY_DELAY_MILLIS;
|
|
355
|
+
if (attempt === 0 && wait <= MAX_TRANSIENT_WAIT_MILLIS && (await this.delay(wait, upload)))
|
|
356
|
+
continue;
|
|
357
|
+
this.pause();
|
|
358
|
+
return { fallback: "failed" };
|
|
359
|
+
}
|
|
360
|
+
// A conflict over this value alone (409) fails only it. Any other refusal (a refused key,
|
|
361
|
+
// an archived project, a request Hue could not read, a redirect) would meet every value
|
|
362
|
+
// alike, so uploads pause; the export's own requests report a key problem.
|
|
363
|
+
if (status !== 409)
|
|
364
|
+
this.pause();
|
|
365
|
+
return { fallback: "failed" };
|
|
366
|
+
}
|
|
367
|
+
return { fallback: "failed" };
|
|
368
|
+
}
|
|
369
|
+
async delay(millis, upload) {
|
|
370
|
+
if (Date.now() + millis >= upload.deadline)
|
|
371
|
+
return false;
|
|
372
|
+
await new Promise((resolve) => setTimeout(resolve, millis));
|
|
373
|
+
return !this.closed;
|
|
374
|
+
}
|
|
375
|
+
/** A 200 answer read: `exists`, or an upload whose URL and headers are usable as given. */
|
|
376
|
+
reservation(answer, size) {
|
|
377
|
+
if (!isObject(answer))
|
|
378
|
+
return { fallback: "failed" };
|
|
379
|
+
const ref = isObject(answer.ref) ? answer.ref : {};
|
|
380
|
+
const contentType = isBlobContentType(ref.content_type) ? ref.content_type : undefined;
|
|
381
|
+
if (answer.status === "exists" && contentType)
|
|
382
|
+
return { exists: true, contentType };
|
|
383
|
+
if (answer.status !== "upload" ||
|
|
384
|
+
answer.method !== "PUT" ||
|
|
385
|
+
typeof answer.url !== "string" ||
|
|
386
|
+
!isObject(answer.headers) ||
|
|
387
|
+
!contentType)
|
|
388
|
+
return { fallback: "failed" };
|
|
389
|
+
let url;
|
|
390
|
+
try {
|
|
391
|
+
url = new URL(answer.url);
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return { fallback: "failed" };
|
|
395
|
+
}
|
|
396
|
+
// The bytes go only where TLS protects them, or to a loopback development store.
|
|
397
|
+
const secure = url.protocol === "https:" ||
|
|
398
|
+
(url.protocol === "http:" &&
|
|
399
|
+
(isLoopbackHost(url.hostname) || this.options.allowInsecureHttp));
|
|
400
|
+
if (!secure || url.username || url.password)
|
|
401
|
+
return { fallback: "failed" };
|
|
402
|
+
const headers = {};
|
|
403
|
+
const entries = Object.entries(answer.headers);
|
|
404
|
+
if (entries.length > 32)
|
|
405
|
+
return { fallback: "failed" };
|
|
406
|
+
for (const [name, value] of entries) {
|
|
407
|
+
if (typeof value !== "string" || !HEADER_NAME.test(name) || !HEADER_VALUE.test(value))
|
|
408
|
+
return { fallback: "failed" };
|
|
409
|
+
// Hue's credentials never travel to the store; the signature is in the URL.
|
|
410
|
+
if (name.toLowerCase() === "authorization")
|
|
411
|
+
return { fallback: "failed" };
|
|
412
|
+
headers[name] = value;
|
|
413
|
+
}
|
|
414
|
+
const length = Object.entries(headers).find(([name]) => name.toLowerCase() === "content-length");
|
|
415
|
+
if (length && length[1] !== String(size))
|
|
416
|
+
return { fallback: "failed" };
|
|
417
|
+
if (!length)
|
|
418
|
+
headers["Content-Length"] = String(size);
|
|
419
|
+
const expiresAt = typeof answer.expiresAt === "string" ? Date.parse(answer.expiresAt) : NaN;
|
|
420
|
+
return {
|
|
421
|
+
exists: false,
|
|
422
|
+
contentType,
|
|
423
|
+
grant: { url, headers, expiresAt: Number.isFinite(expiresAt) ? expiresAt : Infinity },
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
/** The PUT of the value's bytes, with exactly the headers Hue signed. */
|
|
427
|
+
async put(grant, value, size, deadline) {
|
|
428
|
+
const reply = await this.request("PUT", grant.url, grant.headers, value, deadline, MAX_ERROR_RESPONSE_BYTES, size);
|
|
429
|
+
if (!reply)
|
|
430
|
+
return "transient";
|
|
431
|
+
// 412: the object exists already (another upload of the same value won).
|
|
432
|
+
if ((reply.status >= 200 && reply.status < 300) || reply.status === 412)
|
|
433
|
+
return "stored";
|
|
434
|
+
if (reply.status === 403)
|
|
435
|
+
return reply.body.toString("utf8").includes("Request has expired") ? "expired" : "failed";
|
|
436
|
+
if (reply.status >= 500 || reply.status === 408)
|
|
437
|
+
return "transient";
|
|
438
|
+
return "failed";
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* One HTTP request with a deadline and an idle timeout, never following a redirect, its
|
|
442
|
+
* response body bounded. Undefined for a network error, a timeout or a closed uploader. A text
|
|
443
|
+
* or byte body larger than one slice is written a slice at a time with backpressure; `length`
|
|
444
|
+
* is then its byte size.
|
|
445
|
+
*/
|
|
446
|
+
async request(method, url, headers, body, deadline, maxResponseBytes, length) {
|
|
447
|
+
if (this.closed || deadline <= Date.now())
|
|
448
|
+
return undefined;
|
|
449
|
+
let http;
|
|
450
|
+
try {
|
|
451
|
+
// Loaded on first use, as the OTLP transport loads it, after the application's own http
|
|
452
|
+
// instrumentation could patch it. Each specifier is a literal, so the package's import
|
|
453
|
+
// boundary can be checked.
|
|
454
|
+
http = (url.protocol === "https:" ? await import("node:https") : await import("node:http"));
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
return undefined;
|
|
458
|
+
}
|
|
459
|
+
if (this.closed)
|
|
460
|
+
return undefined;
|
|
461
|
+
return new Promise((resolve) => {
|
|
462
|
+
let settled = false;
|
|
463
|
+
let request;
|
|
464
|
+
const finish = (reply) => {
|
|
465
|
+
if (settled)
|
|
466
|
+
return;
|
|
467
|
+
settled = true;
|
|
468
|
+
clearTimeout(timer);
|
|
469
|
+
if (request) {
|
|
470
|
+
this.requests.delete(request);
|
|
471
|
+
if (!reply)
|
|
472
|
+
request.destroy();
|
|
473
|
+
}
|
|
474
|
+
resolve(reply);
|
|
475
|
+
};
|
|
476
|
+
const timer = setTimeout(() => finish(), Math.max(1, deadline - Date.now()));
|
|
477
|
+
const requestHeaders = { ...headers };
|
|
478
|
+
if (length === undefined)
|
|
479
|
+
requestHeaders["Content-Length"] =
|
|
480
|
+
typeof body === "string" ? Buffer.byteLength(body, "utf8") : body.byteLength;
|
|
481
|
+
try {
|
|
482
|
+
// Uploads are the SDK's own export traffic: an http instrumentation must not trace them.
|
|
483
|
+
request = context.with(suppressTracing(context.active()), () => http.request(url, { method, headers: requestHeaders, agent: false }, (response) => {
|
|
484
|
+
const chunks = [];
|
|
485
|
+
let size = 0;
|
|
486
|
+
const reply = () => ({
|
|
487
|
+
status: response.statusCode ?? 0,
|
|
488
|
+
headers: response.headers,
|
|
489
|
+
body: Buffer.concat(chunks),
|
|
490
|
+
});
|
|
491
|
+
response.on("data", (chunk) => {
|
|
492
|
+
size += chunk.byteLength;
|
|
493
|
+
if (size <= maxResponseBytes) {
|
|
494
|
+
chunks.push(chunk);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
// Hue's answers and the store's errors are small: what follows is not read.
|
|
498
|
+
finish(reply());
|
|
499
|
+
response.destroy();
|
|
500
|
+
});
|
|
501
|
+
response.on("end", () => finish(reply()));
|
|
502
|
+
response.on("error", () => finish());
|
|
503
|
+
}));
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
finish();
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const sent = request;
|
|
510
|
+
this.requests.add(sent);
|
|
511
|
+
sent.on("error", () => finish());
|
|
512
|
+
sent.setTimeout(this.options.timeoutMillis, () => finish());
|
|
513
|
+
void (async () => {
|
|
514
|
+
if (length === undefined) {
|
|
515
|
+
sent.end(body);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
for (const chunk of uploadChunks(body)) {
|
|
519
|
+
if (settled)
|
|
520
|
+
return;
|
|
521
|
+
if (!sent.write(chunk))
|
|
522
|
+
await new Promise((drained) => {
|
|
523
|
+
const done = () => {
|
|
524
|
+
sent.off("drain", done);
|
|
525
|
+
sent.off("close", done);
|
|
526
|
+
drained();
|
|
527
|
+
};
|
|
528
|
+
sent.on("drain", done);
|
|
529
|
+
sent.on("close", done);
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
if (!settled)
|
|
533
|
+
sent.end();
|
|
534
|
+
})().catch(() => finish());
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* A span's own attributes with each candidate placed: an inline file part over 64 KiB in a
|
|
540
|
+
* recorded message uploaded and replaced by its first 16 KiB (or by its digest, as before, when
|
|
541
|
+
* it could not be), and a value still over the inline limit uploaded and replaced by its first
|
|
542
|
+
* 16 KiB (or cut to the limit), each upload listed under `hue.blobs`, each whole value cut or
|
|
543
|
+
* uploaded listed under `hue.truncated`. Every fallback is counted in the tally.
|
|
544
|
+
*/
|
|
545
|
+
export async function offloadAttributes(attributes, collector, options) {
|
|
546
|
+
const { uploader, traceId, valueBytes, upload, tally } = options;
|
|
547
|
+
const result = { ...attributes };
|
|
548
|
+
const existing = Array.isArray(attributes[BLOBS_KEY])
|
|
549
|
+
? attributes[BLOBS_KEY].filter((item) => typeof item === "string")
|
|
550
|
+
: [];
|
|
551
|
+
let slots = MAX_BLOB_ENTRIES - existing.length;
|
|
552
|
+
// Places are taken in candidate order, synchronously, so entries are listed in that order
|
|
553
|
+
// whatever order the uploads finish in.
|
|
554
|
+
const entries = [];
|
|
555
|
+
const listed = [];
|
|
556
|
+
const fallback = (reason) => countFallback(tally, reason, traceId);
|
|
557
|
+
// A value the queue cut when it was admitted was never held whole to upload.
|
|
558
|
+
for (let index = 0; index < collector.cut.length; index++)
|
|
559
|
+
fallback(uploader.unsupported() ? "unsupported" : "budget");
|
|
560
|
+
let inspected = 0;
|
|
561
|
+
const parts = async (index, key, text) => {
|
|
562
|
+
if (!isMessageKey(key) ||
|
|
563
|
+
text.length * 3 <= INLINE_FILE_LIMIT ||
|
|
564
|
+
!(text.includes('"blob"') || text.includes('"file"')))
|
|
565
|
+
return text;
|
|
566
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
567
|
+
if (bytes <= INLINE_FILE_LIMIT || bytes > MAX_INLINE_FILE_TEXT)
|
|
568
|
+
return text;
|
|
569
|
+
if (inspected + text.length > INLINE_FILE_TEXT_PER_RECORD)
|
|
570
|
+
return text;
|
|
571
|
+
inspected += text.length;
|
|
572
|
+
let parsed;
|
|
573
|
+
let found;
|
|
574
|
+
try {
|
|
575
|
+
parsed = JSON.parse(text);
|
|
576
|
+
found = largeInlineFileParts(parsed);
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
return text;
|
|
580
|
+
}
|
|
581
|
+
if (!found.length)
|
|
582
|
+
return text;
|
|
583
|
+
let changed = false;
|
|
584
|
+
await Promise.all(found.map((file, position) => {
|
|
585
|
+
const partKey = `${key}#${file.pointer}`;
|
|
586
|
+
const slot = slots > 0 && isBlobKey(partKey) ? (slots--, true) : false;
|
|
587
|
+
return uploader.limit(async () => {
|
|
588
|
+
const decoded = inlineFileBytes(file.content);
|
|
589
|
+
if (decoded.bytes.byteLength <= INLINE_FILE_LIMIT) {
|
|
590
|
+
if (slot)
|
|
591
|
+
slots++;
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
changed = true;
|
|
595
|
+
const digest = await digestOf(decoded.bytes);
|
|
596
|
+
const outcome = slot
|
|
597
|
+
? await uploader.store(traceId, partKey, decoded.bytes, fileContentType(file.part, decoded), digest, upload)
|
|
598
|
+
: { ok: false, reason: "budget" };
|
|
599
|
+
if (outcome.ok) {
|
|
600
|
+
file.part[file.key] = blobPrefix(file.content);
|
|
601
|
+
entries.push({
|
|
602
|
+
order: [index, position],
|
|
603
|
+
entry: blobEntry(partKey, digest.sha256, digest.size, outcome.contentType),
|
|
604
|
+
});
|
|
605
|
+
tally.uploaded++;
|
|
606
|
+
}
|
|
607
|
+
else {
|
|
608
|
+
digestPart(file.part, file.key, digest);
|
|
609
|
+
fallback(outcome.reason);
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
}));
|
|
613
|
+
return changed ? JSON.stringify(parsed) : text;
|
|
614
|
+
};
|
|
615
|
+
const place = async (index, { key, value }) => {
|
|
616
|
+
const placed = typeof value === "string" ? await parts(index, key, value) : value;
|
|
617
|
+
const size = sizeOf(placed);
|
|
618
|
+
if (size <= valueBytes) {
|
|
619
|
+
result[key] = placed;
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
listed.push({ order: index, key });
|
|
623
|
+
const cut = () => typeof placed === "string" ? truncateUtf8(placed, valueBytes) : truncatedMarker(size);
|
|
624
|
+
if (slots <= 0) {
|
|
625
|
+
result[key] = cut();
|
|
626
|
+
fallback("budget");
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
slots--;
|
|
630
|
+
const stored = await uploader.limit(async () => {
|
|
631
|
+
if (size > MAX_BLOB_BYTES)
|
|
632
|
+
return { ok: false, reason: "tooLarge" };
|
|
633
|
+
const refused = uploader.refusal();
|
|
634
|
+
if (refused)
|
|
635
|
+
return { ok: false, reason: refused };
|
|
636
|
+
const digest = await digestOf(placed);
|
|
637
|
+
const contentType = typeof placed === "string" ? textContentType(placed) : "application/octet-stream";
|
|
638
|
+
const outcome = await uploader.store(traceId, key, placed, contentType, digest, upload);
|
|
639
|
+
return outcome.ok ? { ...outcome, digest } : outcome;
|
|
640
|
+
});
|
|
641
|
+
if (stored.ok) {
|
|
642
|
+
result[key] = typeof placed === "string" ? blobPrefix(placed) : blobPrefix(placed);
|
|
643
|
+
entries.push({
|
|
644
|
+
order: [index, Infinity],
|
|
645
|
+
entry: blobEntry(key, stored.digest.sha256, stored.digest.size, stored.contentType),
|
|
646
|
+
});
|
|
647
|
+
tally.uploaded++;
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
result[key] = cut();
|
|
651
|
+
fallback(stored.reason);
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
await Promise.all(collector.candidates.map((candidate, index) => place(index, candidate)));
|
|
655
|
+
if (listed.length)
|
|
656
|
+
result[TRUNCATED_KEY] = withTruncatedKeys(result[TRUNCATED_KEY], listed.sort((a, b) => a.order - b.order).map(({ key }) => key));
|
|
657
|
+
if (entries.length) {
|
|
658
|
+
entries.sort((a, b) => a.order[0] - b.order[0] || a.order[1] - b.order[1]);
|
|
659
|
+
result[BLOBS_KEY] = [...existing, ...entries.map(({ entry }) => entry)];
|
|
660
|
+
}
|
|
661
|
+
return result;
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* The span's own attributes with every candidate placed as it was before uploads existed, for an
|
|
665
|
+
* upload step that failed unexpectedly: a message's large inline files as their digest, a value
|
|
666
|
+
* over the inline limit cut to it (bytes replaced by the receiver's marker) and listed under
|
|
667
|
+
* `hue.truncated`.
|
|
668
|
+
*/
|
|
669
|
+
export function fallbackAttributes(attributes, collector, valueBytes, tally, traceId) {
|
|
670
|
+
const result = { ...attributes };
|
|
671
|
+
const listed = [];
|
|
672
|
+
for (let index = 0; index < collector.cut.length; index++)
|
|
673
|
+
countFallback(tally, "failed", traceId);
|
|
674
|
+
for (const { key, value } of collector.candidates) {
|
|
675
|
+
let placed = value;
|
|
676
|
+
// Each large inline file is reported as a value not uploaded, as the upload step reports it.
|
|
677
|
+
const count = { files: 0 };
|
|
678
|
+
try {
|
|
679
|
+
placed = hashInlineFiles(key, value, count);
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
// Left as it is: cut below when over the limit.
|
|
683
|
+
}
|
|
684
|
+
for (let file = 0; file < count.files; file++)
|
|
685
|
+
countFallback(tally, "failed", traceId);
|
|
686
|
+
if (typeof placed === "string") {
|
|
687
|
+
if (Buffer.byteLength(placed, "utf8") > valueBytes) {
|
|
688
|
+
placed = truncateUtf8(placed, valueBytes);
|
|
689
|
+
listed.push(key);
|
|
690
|
+
countFallback(tally, "failed", traceId);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
else if (placed instanceof Uint8Array && placed.byteLength > valueBytes) {
|
|
694
|
+
placed = truncatedMarker(placed.byteLength);
|
|
695
|
+
listed.push(key);
|
|
696
|
+
countFallback(tally, "failed", traceId);
|
|
697
|
+
}
|
|
698
|
+
result[key] = placed;
|
|
699
|
+
}
|
|
700
|
+
if (listed.length)
|
|
701
|
+
result[TRUNCATED_KEY] = withTruncatedKeys(result[TRUNCATED_KEY], listed);
|
|
702
|
+
return result;
|
|
703
|
+
}
|