@hue-run/sdk 0.20.0 → 0.20.2

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.
@@ -0,0 +1,838 @@
1
+ import { readFile, rename, stat, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { gunzipSync, gzipSync } from "node:zlib";
4
+ import { sdkVersion } from "../version.js";
5
+ import { manifestTotals, sha256Hex, validateManifest, } from "./traces-import-convert.js";
6
+ import { MANIFEST_FILE, PROGRESS_FILE } from "./traces-import-prepare.js";
7
+ /**
8
+ * `hue traces import upload`: delivers a prepared bundle to Hue's import routes with the
9
+ * short-lived import authorization `prepare_trace_import` minted. The manifest's exact bytes
10
+ * are registered first; then, trace by trace in declared order and two requests at a time
11
+ * across traces, each large value is reserved, sent to the presigned URL and completed, each
12
+ * batch is posted with its digest, and the trace's receipt is read for every span id. A trace's
13
+ * requests run strictly in order. Transient answers and network failures are retried with
14
+ * bounded backoff (identical bytes are safe to resend), a rejected record is a conflict that is
15
+ * recorded rather than retried, and an authorization Hue no longer accepts stops the upload
16
+ * with the step to take. Progress is checkpointed to `progress.json` so a rerun resumes.
17
+ */
18
+ export const UPLOAD_CONCURRENCY = 2;
19
+ export const RECEIPT_IDS_PER_CALL = 100;
20
+ const MAX_RESPONSE_BYTES = 256 * 1024;
21
+ const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000, 30_000];
22
+ const MAX_RETRY_AFTER_MS = 60_000;
23
+ const JSON_TIMEOUT_MS = 30_000;
24
+ const BATCH_TIMEOUT_MS = 120_000;
25
+ /** A presigned PUT of up to 1 GB; the URL itself lasts 15 minutes. */
26
+ const BLOB_TIMEOUT_MS = 15 * 60_000;
27
+ const RECEIPT_RETRY_MS = 1_000;
28
+ const IMPORT_TOKEN = /^hue_it_[A-Za-z0-9_-]{43}$/;
29
+ const IMPORT_PATH = /^\/api\/v1\/trace-imports\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
30
+ const DEFAULT_CHECKPOINT_INTERVAL_MS = 250;
31
+ /** Why the upload stopped before it completed. */
32
+ export class UploadStop extends Error {
33
+ code;
34
+ prepareAgain;
35
+ constructor(message, code,
36
+ /** Whether `prepare_trace_import` must be called again before the next run. */
37
+ prepareAgain = false) {
38
+ super(message);
39
+ this.code = code;
40
+ this.prepareAgain = prepareAgain;
41
+ this.name = "UploadStop";
42
+ }
43
+ }
44
+ class Interrupted extends Error {
45
+ }
46
+ /** Hue answered that the import already completed: nothing more is accepted, nothing is owed. */
47
+ class AlreadyCompleted extends Error {
48
+ }
49
+ /** The refusal codes of a batch or value that the same bytes would earn again: the bundle
50
+ * disagrees with the manifest it declared, or with evidence Hue holds. Recorded as a conflict of
51
+ * the trace rather than retried or allowed to stop the other traces. */
52
+ const BATCH_CONFLICT_CODES = new Set([
53
+ "batch_undeclared",
54
+ "batch_digest_mismatch",
55
+ "batch_trace_mismatch",
56
+ "batch_span_count_mismatch",
57
+ ]);
58
+ const BLOB_CONFLICT_CODES = new Set(["blob_undeclared", "blob_mismatch"]);
59
+ /** The import authorization file, in either shape `prepare_trace_import`'s answer is saved in:
60
+ * its JSON (`authorization`, and `upload_url` beside it), or the authorization alone on the first
61
+ * line, optionally with the upload URL on the second. Nothing in it is ever printed. */
62
+ export function parseAuthorizationFile(content) {
63
+ const refuse = () => new UploadStop("The authorization file must hold the import authorization prepare_trace_import returned (hue_it_…): its JSON answer, or the authorization alone on the first line", "authorization_invalid");
64
+ const trimmed = content.trim();
65
+ let token;
66
+ let importUrl;
67
+ if (trimmed.startsWith("{")) {
68
+ let parsed;
69
+ try {
70
+ parsed = JSON.parse(trimmed);
71
+ }
72
+ catch {
73
+ throw refuse();
74
+ }
75
+ if (!isObject(parsed) || typeof parsed.authorization !== "string")
76
+ throw refuse();
77
+ token = parsed.authorization;
78
+ if (typeof parsed.upload_url === "string")
79
+ importUrl = parsed.upload_url;
80
+ }
81
+ else {
82
+ const lines = trimmed
83
+ .split(/\r?\n/)
84
+ .map((line) => line.trim())
85
+ .filter(Boolean);
86
+ token = lines[0] ?? "";
87
+ importUrl = lines[1];
88
+ }
89
+ if (!IMPORT_TOKEN.test(token))
90
+ throw refuse();
91
+ return { token, ...(importUrl ? { importUrl } : {}) };
92
+ }
93
+ const isLoopback = (hostname) => hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
94
+ /** The upload URL as `prepare_trace_import` returned it, or why it is refused. */
95
+ export function parseImportUrl(value, origin) {
96
+ let url;
97
+ try {
98
+ url = new URL(value);
99
+ }
100
+ catch {
101
+ throw new UploadStop("The import URL must be the upload_url prepare_trace_import returned", "url_invalid");
102
+ }
103
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback(url.hostname)))
104
+ throw new UploadStop("The import URL must use https (plain http is accepted for this machine only)", "url_invalid");
105
+ if (url.username || url.password || url.search || url.hash)
106
+ throw new UploadStop("The import URL must not carry credentials, a query or a fragment", "url_invalid");
107
+ if (!IMPORT_PATH.test(url.pathname.replace(/\/+$/, "")))
108
+ throw new UploadStop("The import URL must be <origin>/api/v1/trace-imports/<import id>", "url_invalid");
109
+ if (origin !== undefined && url.origin !== origin)
110
+ throw new UploadStop(`The import URL is not on ${origin}`, "url_invalid");
111
+ url.pathname = url.pathname.replace(/\/+$/, "");
112
+ return url;
113
+ }
114
+ function defaultSleep(ms, signal) {
115
+ return new Promise((done) => {
116
+ const finish = () => {
117
+ clearTimeout(timer);
118
+ signal.removeEventListener("abort", finish);
119
+ done();
120
+ };
121
+ const timer = setTimeout(finish, ms);
122
+ signal.addEventListener("abort", finish, { once: true });
123
+ });
124
+ }
125
+ function defaultOnInterrupt(listener) {
126
+ process.on("SIGINT", listener);
127
+ process.on("SIGTERM", listener);
128
+ return () => {
129
+ process.removeListener("SIGINT", listener);
130
+ process.removeListener("SIGTERM", listener);
131
+ };
132
+ }
133
+ function retryAfterMillis(value) {
134
+ if (!value)
135
+ return undefined;
136
+ if (/^\d+$/.test(value))
137
+ return Math.min(Number(value) * 1000, MAX_RETRY_AFTER_MS);
138
+ const date = Date.parse(value);
139
+ return Number.isFinite(date)
140
+ ? Math.min(Math.max(0, date - Date.now()), MAX_RETRY_AFTER_MS)
141
+ : undefined;
142
+ }
143
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
144
+ /** Writes `progress.json` (mode 0600, through a temporary file and an atomic rename) after each
145
+ * verified step, coalescing writes that fall within the interval. */
146
+ export class Progress {
147
+ path;
148
+ file;
149
+ intervalMs;
150
+ dirty = false;
151
+ timer;
152
+ writing = Promise.resolve();
153
+ lastWrite = 0;
154
+ /** The first write that failed (the disk full, the directory gone or no longer writable);
155
+ * nothing is written after it, and the upload reports it instead of crashing. */
156
+ failure;
157
+ /** Called once, when a write first fails. */
158
+ onFailure;
159
+ constructor(path, file, intervalMs) {
160
+ this.path = path;
161
+ this.file = file;
162
+ this.intervalMs = intervalMs;
163
+ }
164
+ trace(traceId) {
165
+ let trace = this.file.traces[traceId];
166
+ if (!trace) {
167
+ trace = { state: "pending", blobs: [], batches: [], conflicts: [] };
168
+ this.file.traces[traceId] = trace;
169
+ }
170
+ return trace;
171
+ }
172
+ /** Records a step; the file is written now, or at the end of the interval. Never rejects: a
173
+ * write that fails is kept in {@link failure}, whether it was awaited or ran from the timer. */
174
+ changed() {
175
+ this.dirty = true;
176
+ const due = this.lastWrite + this.intervalMs - Date.now();
177
+ if (due <= 0)
178
+ return this.flush();
179
+ this.timer ??= setTimeout(() => {
180
+ this.timer = undefined;
181
+ void this.flush();
182
+ }, due);
183
+ return Promise.resolve();
184
+ }
185
+ /** Writes what changed, now. Never rejects; see {@link failure}. */
186
+ flush() {
187
+ if (this.timer) {
188
+ clearTimeout(this.timer);
189
+ this.timer = undefined;
190
+ }
191
+ if (!this.dirty || this.failure)
192
+ return this.writing;
193
+ this.dirty = false;
194
+ this.lastWrite = Date.now();
195
+ const bytes = JSON.stringify(this.file);
196
+ this.writing = this.writing.then(async () => {
197
+ if (this.failure)
198
+ return;
199
+ try {
200
+ await writeFile(`${this.path}.tmp`, bytes, { mode: 0o600 });
201
+ await rename(`${this.path}.tmp`, this.path);
202
+ }
203
+ catch (error) {
204
+ this.failure = error;
205
+ this.onFailure?.();
206
+ }
207
+ });
208
+ return this.writing;
209
+ }
210
+ }
211
+ /** Runs the upload; resolves to the report. Throws only for a programming error. */
212
+ export async function uploadTraceBundle(options, io = {}) {
213
+ const fetcher = io.fetch ?? fetch;
214
+ const sleep = io.sleep ?? defaultSleep;
215
+ const progress = io.progress ?? (() => { });
216
+ const importUrl = parseImportUrl(options.importUrl);
217
+ const base = importUrl.href;
218
+ const importId = IMPORT_PATH.exec(importUrl.pathname)[1];
219
+ const manifestBytes = await readFile(join(options.directory, MANIFEST_FILE)).catch(() => {
220
+ throw new UploadStop(`${options.directory} holds no ${MANIFEST_FILE}; run hue traces import prepare first`, "bundle_invalid");
221
+ });
222
+ let manifest;
223
+ try {
224
+ manifest = JSON.parse(manifestBytes.toString("utf8"));
225
+ }
226
+ catch {
227
+ throw new UploadStop(`${MANIFEST_FILE} is not JSON`, "bundle_invalid");
228
+ }
229
+ const invalid = validateManifest(manifest);
230
+ if (invalid)
231
+ throw new UploadStop(`${MANIFEST_FILE} is not valid: ${invalid}`, "bundle_invalid");
232
+ const manifestDigest = sha256Hex(manifestBytes);
233
+ const totals = manifestTotals(manifest);
234
+ // Every declared file must be present before the first request: a bundle missing a batch
235
+ // would otherwise be registered and never complete.
236
+ for (const trace of manifest.traces) {
237
+ for (const batch of trace.batches)
238
+ await stat(join(options.directory, `${trace.trace_id}.${batch.sha256}.json.gz`)).catch(() => {
239
+ throw new UploadStop(`The bundle lacks batch ${batch.sha256} of trace ${trace.trace_id}`, "bundle_invalid");
240
+ });
241
+ for (const blob of trace.blobs)
242
+ await stat(join(options.directory, `${blob.sha256}.blob`)).catch(() => {
243
+ throw new UploadStop(`The bundle lacks value ${blob.sha256} of trace ${trace.trace_id}`, "bundle_invalid");
244
+ });
245
+ }
246
+ const progressPath = join(options.directory, PROGRESS_FILE);
247
+ let saved;
248
+ try {
249
+ saved = JSON.parse(await readFile(progressPath, "utf8"));
250
+ }
251
+ catch (error) {
252
+ if (error.code !== "ENOENT")
253
+ throw new UploadStop(`${PROGRESS_FILE} could not be read; remove it to start over`, "progress_invalid");
254
+ }
255
+ if (saved) {
256
+ if (!isObject(saved) || saved.version !== 1 || !isObject(saved.traces))
257
+ throw new UploadStop(`${PROGRESS_FILE} is not a progress file; remove it to start over`, "progress_invalid");
258
+ if (saved.manifest_digest !== manifestDigest)
259
+ throw new UploadStop(`${PROGRESS_FILE} belongs to another manifest (${saved.manifest_digest}); remove it to upload this bundle`, "progress_mismatch");
260
+ if (saved.import_url !== base)
261
+ throw new UploadStop(`${PROGRESS_FILE} belongs to another import (${saved.import_url}); remove it to upload to ${base}`, "progress_mismatch");
262
+ }
263
+ const checkpoint = new Progress(progressPath, saved ?? { version: 1, manifest_digest: manifestDigest, import_url: base, traces: {} }, io.checkpointIntervalMs ?? DEFAULT_CHECKPOINT_INTERVAL_MS);
264
+ const stopping = new AbortController();
265
+ const forced = new AbortController();
266
+ // A progress write that fails ends the run as a stop request does: nothing more is scheduled,
267
+ // the requests in flight finish, and the report names the failure and the directory to fix.
268
+ checkpoint.onFailure = () => stopping.abort();
269
+ const progressWriteFailure = (error) => new UploadStop(`${PROGRESS_FILE} could not be written in ${options.directory}${error.code ? ` (${error.code})` : ""}; the steps since its last write are not recorded for the next run. Fix the directory and run the same command again.`, "progress_write_failed");
270
+ const removeInterrupt = (io.onInterrupt ?? defaultOnInterrupt)(() => {
271
+ if (!stopping.signal.aborted)
272
+ stopping.abort();
273
+ else
274
+ forced.abort();
275
+ });
276
+ const userAgent = `hue-sdk-typescript/${sdkVersion} hue-traces-import`;
277
+ /** One request with bounded retries of transient failures. Resolves to the final answer, which
278
+ * the caller interprets; throws {@link Interrupted} when a stop was requested. */
279
+ const request = async (method, url, init = {}) => {
280
+ const headers = { "user-agent": userAgent, ...init.headers };
281
+ if (init.authorized !== false)
282
+ headers.authorization = `Bearer ${options.token}`;
283
+ for (let attempt = 0;; attempt++) {
284
+ if (stopping.signal.aborted)
285
+ throw new Interrupted();
286
+ const timeout = AbortSignal.timeout(init.timeoutMs ?? JSON_TIMEOUT_MS);
287
+ let response;
288
+ let retryAfter;
289
+ try {
290
+ response = await fetcher(url, {
291
+ method,
292
+ headers,
293
+ ...(init.body !== undefined ? { body: init.body } : {}),
294
+ redirect: "manual",
295
+ cache: "no-store",
296
+ credentials: "omit",
297
+ signal: AbortSignal.any([timeout, forced.signal]),
298
+ });
299
+ }
300
+ catch {
301
+ if (forced.signal.aborted)
302
+ throw new Interrupted();
303
+ response = undefined;
304
+ }
305
+ if (response) {
306
+ const transient = response.status === 408 ||
307
+ response.status === 429 ||
308
+ (response.status >= 500 && response.status <= 504);
309
+ let body;
310
+ if (!transient) {
311
+ // A body that breaks off (the connection dropped after the headers) is no answer: the
312
+ // request is made again rather than read as an empty acknowledgement.
313
+ try {
314
+ body = await readBounded(response);
315
+ }
316
+ catch {
317
+ if (forced.signal.aborted)
318
+ throw new Interrupted();
319
+ body = undefined;
320
+ }
321
+ }
322
+ if (body !== undefined) {
323
+ let json;
324
+ try {
325
+ json = body.byteLength ? JSON.parse(body.toString("utf8")) : undefined;
326
+ }
327
+ catch {
328
+ json = undefined;
329
+ }
330
+ const header = response.headers.get("hue-refusal-code");
331
+ const code = header ?? (isObject(json) && typeof json.code === "string" ? json.code : undefined);
332
+ // The JSON routes answer `error`; the batch route answers an OTLP Status, `message`.
333
+ const message = isObject(json)
334
+ ? typeof json.error === "string"
335
+ ? json.error
336
+ : typeof json.message === "string"
337
+ ? json.message
338
+ : undefined
339
+ : undefined;
340
+ return {
341
+ status: response.status,
342
+ headers: response.headers,
343
+ body,
344
+ json,
345
+ ...(code ? { code } : {}),
346
+ ...(message ? { message } : {}),
347
+ };
348
+ }
349
+ retryAfter = retryAfterMillis(response.headers.get("retry-after"));
350
+ await response.body?.cancel().catch(() => { });
351
+ }
352
+ if (attempt >= RETRY_DELAYS_MS.length)
353
+ throw new UploadStop(response
354
+ ? `Hue answered ${response.status} to ${method} ${new URL(url).pathname} ${attempt + 1} times`
355
+ : `Hue could not be reached at ${new URL(url).origin}`, response ? "busy" : "unreachable");
356
+ await sleep(Math.max(RETRY_DELAYS_MS[attempt], retryAfter ?? 0), stopping.signal);
357
+ }
358
+ };
359
+ /** A refusal every route may answer: the authorization or the import is no longer usable. The
360
+ * fixed code (`Hue-Refusal-Code`) decides; the message is passed on, never branched on. */
361
+ const stopFor = (reply, context) => {
362
+ const message = reply.message ?? `Hue answered ${reply.status}.`;
363
+ if (reply.status === 401)
364
+ return new UploadStop(`${message} Call prepare_trace_import again with the same manifest_digest and totals and write the new authorization to the file.`, reply.code ?? "unauthorized", true);
365
+ if (reply.status === 403 && reply.code === "authority_lapsed")
366
+ return new UploadStop(`${message} Call prepare_trace_import again with a connection that can write the project.`, reply.code, true);
367
+ if (reply.status === 403 && reply.code === "import_mismatch")
368
+ return new UploadStop(`${message} Use the authorization prepare_trace_import returned for this import.`, reply.code);
369
+ if (reply.status === 410 && reply.code === "import_completed")
370
+ throw new AlreadyCompleted();
371
+ if (reply.status === 410 && reply.code === "import_canceled")
372
+ return new UploadStop(`${message} It cannot be resumed.`, reply.code);
373
+ if (reply.status === 410)
374
+ return new UploadStop(`${message} Call prepare_trace_import again with the same manifest_digest and totals to continue.`, reply.code ?? "gone", true);
375
+ if (reply.status === 404 && (!reply.code || reply.code === "import_not_found"))
376
+ return new UploadStop(`${message} Check the import URL.`, reply.code ?? "not_found");
377
+ if (reply.status >= 300)
378
+ return new UploadStop(`${context}: ${message}${reply.code ? ` (${reply.code})` : ""}`, reply.code ?? `http_${reply.status}`);
379
+ return undefined;
380
+ };
381
+ const conflicts = [];
382
+ let verified = 0, conflicted = 0, unverified = 0, landed = 0, stored = 0;
383
+ const countSaved = () => {
384
+ for (const [traceId, trace] of Object.entries(checkpoint.file.traces)) {
385
+ for (const conflict of trace.conflicts)
386
+ conflicts.push({ trace_id: traceId, sha256: conflict.sha256, message: conflict.message });
387
+ }
388
+ };
389
+ const uploadBlob = async (traceId, blob, state) => {
390
+ const bytes = await readFile(join(options.directory, `${blob.sha256}.blob`));
391
+ if (sha256Hex(bytes) !== blob.sha256 || bytes.byteLength !== blob.byte_size)
392
+ throw new UploadStop(`Value ${blob.sha256} of trace ${traceId} was altered after preparation`, "bundle_invalid");
393
+ const reservation = JSON.stringify({
394
+ traceId,
395
+ sha256: blob.sha256,
396
+ byteSize: blob.byte_size,
397
+ contentType: blob.content_type,
398
+ });
399
+ /** A refusal the same value would earn again: the reservation named a size or type the
400
+ * manifest did not declare, the value is undeclared, or the stored object differs. */
401
+ const conflict = (reply, fallback) => {
402
+ state.conflicts.push({
403
+ sha256: blob.sha256,
404
+ message: `${reply.message ?? fallback}${reply.code ? ` (${reply.code})` : ""}`,
405
+ });
406
+ return false;
407
+ };
408
+ for (let round = 0; round < 2; round++) {
409
+ const presign = await request("POST", `${base}/blobs`, {
410
+ headers: { "content-type": "application/json" },
411
+ body: reservation,
412
+ });
413
+ if (presign.status === 409 ||
414
+ (presign.status === 403 &&
415
+ presign.code !== undefined &&
416
+ BLOB_CONFLICT_CODES.has(presign.code)))
417
+ return conflict(presign, "The value conflicts with one Hue holds.");
418
+ const stop = stopFor(presign, `Value ${blob.sha256} was refused`);
419
+ if (stop)
420
+ throw stop;
421
+ const answer = presign.json;
422
+ if (!isObject(answer))
423
+ throw new UploadStop("Hue answered a value reservation without JSON", "protocol");
424
+ if (answer.status === "upload") {
425
+ if (typeof answer.url !== "string" || !isObject(answer.headers))
426
+ throw new UploadStop("Hue answered a value reservation without an upload URL", "protocol");
427
+ let target;
428
+ try {
429
+ target = new URL(answer.url);
430
+ }
431
+ catch {
432
+ throw new UploadStop("Hue answered a value reservation with an invalid upload URL", "protocol");
433
+ }
434
+ if (target.protocol !== "https:" &&
435
+ !(target.protocol === "http:" && isLoopback(target.hostname)))
436
+ throw new UploadStop("Hue answered a value reservation with an insecure upload URL", "protocol");
437
+ const headers = {};
438
+ for (const [name, value] of Object.entries(answer.headers))
439
+ if (typeof value === "string" && name.toLowerCase() !== "content-length")
440
+ headers[name] = value;
441
+ const put = await request(typeof answer.method === "string" ? answer.method : "PUT", target.href, {
442
+ headers,
443
+ body: bytes,
444
+ authorized: false,
445
+ timeoutMs: BLOB_TIMEOUT_MS,
446
+ });
447
+ // 412: the object exists already (another upload of the same value won).
448
+ const storedNow = (put.status >= 200 && put.status < 300) || put.status === 412;
449
+ if (!storedNow) {
450
+ if (put.status === 403 &&
451
+ round === 0 &&
452
+ put.body.toString("utf8").includes("Request has expired"))
453
+ continue;
454
+ throw new UploadStop(`The store answered ${put.status} to the upload of value ${blob.sha256}`, "upload_failed");
455
+ }
456
+ }
457
+ else if (answer.status !== "exists")
458
+ throw new UploadStop("Hue answered a value reservation with an unknown status", "protocol");
459
+ const completion = await request("POST", `${base}/blobs/complete`, {
460
+ headers: { "content-type": "application/json" },
461
+ body: JSON.stringify({ traceId, sha256: blob.sha256 }),
462
+ });
463
+ // 404: no reservation stands for the value (Hue's own answer, with no import code).
464
+ if (completion.status === 409 ||
465
+ (completion.status === 404 && completion.code === undefined) ||
466
+ (completion.status === 403 &&
467
+ completion.code !== undefined &&
468
+ BLOB_CONFLICT_CODES.has(completion.code)))
469
+ return conflict(completion, "The stored value does not match.");
470
+ const refused = stopFor(completion, `Value ${blob.sha256} could not be completed`);
471
+ if (refused)
472
+ throw refused;
473
+ return true;
474
+ }
475
+ throw new UploadStop(`The upload URL of value ${blob.sha256} expired twice`, "upload_failed");
476
+ };
477
+ const sendBatch = async (traceId, batch, state) => {
478
+ const body = await readFile(join(options.directory, `${traceId}.${batch.sha256}.json.gz`));
479
+ if (sha256Hex(body) !== batch.sha256 || body.byteLength !== batch.bytes)
480
+ throw new UploadStop(`Batch ${batch.sha256} of trace ${traceId} was altered after preparation`, "bundle_invalid");
481
+ const reply = await request("POST", `${base}/batches`, {
482
+ headers: {
483
+ "content-type": "application/json",
484
+ "content-encoding": "gzip",
485
+ "hue-import-batch": batch.sha256,
486
+ },
487
+ body,
488
+ timeoutMs: BATCH_TIMEOUT_MS,
489
+ });
490
+ // A refusal the same bytes would earn again (undeclared, another digest, another trace's
491
+ // records, fewer finished spans than declared) is the trace's conflict, not the upload's end.
492
+ if (reply.code !== undefined && BATCH_CONFLICT_CODES.has(reply.code)) {
493
+ state.conflicts.push({
494
+ sha256: batch.sha256,
495
+ message: `${reply.message ?? "The batch was refused."} (${reply.code})`,
496
+ });
497
+ return false;
498
+ }
499
+ const stop = stopFor(reply, `Batch ${batch.sha256} of trace ${traceId} was refused`);
500
+ if (stop)
501
+ throw stop;
502
+ // Only an acknowledgement that reads whole is recorded: a success without one (an empty,
503
+ // cut or foreign body) leaves the batch pending, so the next run sends it again.
504
+ const acknowledged = batchAcknowledgement(reply);
505
+ if (!acknowledged)
506
+ throw new UploadStop(`Hue's answer to batch ${batch.sha256} of trace ${traceId} could not be read as an acknowledgement; the batch stays pending`, "protocol");
507
+ if (acknowledged.rejected > 0) {
508
+ state.conflicts.push({
509
+ sha256: batch.sha256,
510
+ message: `${acknowledged.rejected} span(s) rejected: ${acknowledged.message || "Records were refused."}`,
511
+ });
512
+ return false;
513
+ }
514
+ return true;
515
+ };
516
+ /** The span ids a batch holds, read back from its body. */
517
+ const spanIdsOf = async (traceId, sha256) => {
518
+ const body = await readFile(join(options.directory, `${traceId}.${sha256}.json.gz`));
519
+ const decoded = JSON.parse(gunzipSync(body).toString("utf8"));
520
+ const ids = [];
521
+ for (const group of decoded.resourceSpans ?? [])
522
+ for (const scope of group.scopeSpans ?? [])
523
+ for (const span of scope.spans ?? [])
524
+ if (typeof span.spanId === "string")
525
+ ids.push(span.spanId);
526
+ return ids;
527
+ };
528
+ const confirmReceipt = async (traceId, spanIds) => {
529
+ let missing = 0;
530
+ for (let at = 0; at < spanIds.length; at += RECEIPT_IDS_PER_CALL) {
531
+ const page = spanIds.slice(at, at + RECEIPT_IDS_PER_CALL);
532
+ const url = new URL(`${base}/traces/${traceId}/receipt`);
533
+ for (const id of page)
534
+ url.searchParams.append("expectedSpanId", id);
535
+ for (let attempt = 0;; attempt++) {
536
+ const reply = await request("GET", url.href, { headers: { accept: "application/json" } });
537
+ let missingHere;
538
+ if (reply.status === 404)
539
+ missingHere = page.length;
540
+ else {
541
+ const stop = stopFor(reply, `The receipt of trace ${traceId} was refused`);
542
+ if (stop)
543
+ throw stop;
544
+ const ids = isObject(reply.json) && Array.isArray(reply.json.missingSpanIds)
545
+ ? reply.json.missingSpanIds
546
+ : undefined;
547
+ if (!ids)
548
+ throw new UploadStop("Hue answered a receipt without span ids", "protocol");
549
+ missingHere = ids.length;
550
+ }
551
+ if (!missingHere || attempt >= 1) {
552
+ missing += missingHere;
553
+ break;
554
+ }
555
+ // Acknowledged records are durable; one more read covers a receipt served ahead of them.
556
+ await sleep(RECEIPT_RETRY_MS, stopping.signal);
557
+ if (stopping.signal.aborted)
558
+ throw new Interrupted();
559
+ }
560
+ }
561
+ return missing;
562
+ };
563
+ const processTrace = async (trace, index) => {
564
+ const state = checkpoint.trace(trace.trace_id);
565
+ if (state.state === "verified") {
566
+ verified++;
567
+ return;
568
+ }
569
+ const conflict = () => {
570
+ conflicted++;
571
+ progress({
572
+ event: "trace.conflicted",
573
+ trace_id: trace.trace_id,
574
+ index: index + 1,
575
+ of: manifest.traces.length,
576
+ });
577
+ };
578
+ const refusedBefore = (sha256) => state.conflicts.some((conflict) => conflict.sha256 === sha256);
579
+ // Values first: a trace whose value Hue refused can never verify, so none of its records
580
+ // are sent and Hue is left without a partly stored trace.
581
+ let complete = true;
582
+ for (const blob of trace.blobs) {
583
+ if (state.blobs.includes(blob.sha256))
584
+ continue;
585
+ if (refusedBefore(blob.sha256)) {
586
+ complete = false;
587
+ continue;
588
+ }
589
+ if (await uploadBlob(trace.trace_id, blob, state))
590
+ state.blobs.push(blob.sha256);
591
+ else
592
+ complete = false;
593
+ await checkpoint.changed();
594
+ }
595
+ if (!complete)
596
+ return conflict();
597
+ // Batches in declared order; a refused batch ends the trace's delivery there, so no later
598
+ // batch lands ahead of an earlier one.
599
+ const spanIds = [];
600
+ for (const batch of trace.batches) {
601
+ if (refusedBefore(batch.sha256))
602
+ return conflict();
603
+ if (!state.batches.includes(batch.sha256)) {
604
+ const landed = await sendBatch(trace.trace_id, batch, state);
605
+ if (landed)
606
+ state.batches.push(batch.sha256);
607
+ await checkpoint.changed();
608
+ if (!landed)
609
+ return conflict();
610
+ }
611
+ spanIds.push(...(await spanIdsOf(trace.trace_id, batch.sha256)));
612
+ }
613
+ const missing = await confirmReceipt(trace.trace_id, spanIds);
614
+ if (missing) {
615
+ unverified++;
616
+ progress({
617
+ event: "trace.unverified",
618
+ trace_id: trace.trace_id,
619
+ missing_spans: missing,
620
+ index: index + 1,
621
+ of: manifest.traces.length,
622
+ });
623
+ return;
624
+ }
625
+ state.state = "verified";
626
+ verified++;
627
+ await checkpoint.changed();
628
+ progress({
629
+ event: "trace.verified",
630
+ trace_id: trace.trace_id,
631
+ index: index + 1,
632
+ of: manifest.traces.length,
633
+ });
634
+ };
635
+ let failure;
636
+ let interrupted = false;
637
+ let pendingFromHue;
638
+ let note;
639
+ let completed = false;
640
+ let alreadyCompleted = false;
641
+ let incompleteMessage;
642
+ try {
643
+ const registration = await request("PUT", `${base}/manifest`, {
644
+ headers: { "content-type": "application/json", "content-encoding": "gzip" },
645
+ body: gzipSync(manifestBytes, { level: 6 }),
646
+ });
647
+ if (registration.status === 409 || registration.status === 422)
648
+ throw new UploadStop(`Hue did not register the manifest: ${registration.message ?? `HTTP ${registration.status}`}${registration.code === "digest_mismatch" || registration.code === "totals_differ" ? ". Call prepare_trace_import with this bundle's manifest_digest and totals." : ""}`, registration.code ?? "manifest_refused");
649
+ const refused = stopFor(registration, "The manifest was refused");
650
+ if (refused)
651
+ throw refused;
652
+ progress({
653
+ event: "manifest.registered",
654
+ registered: registration.status === 201,
655
+ import_id: importId,
656
+ });
657
+ let next = 0;
658
+ const worker = async () => {
659
+ while (!stopping.signal.aborted) {
660
+ const index = next++;
661
+ if (index >= manifest.traces.length)
662
+ return;
663
+ await processTrace(manifest.traces[index], index);
664
+ }
665
+ };
666
+ const outcomes = await Promise.allSettled(Array.from({ length: Math.min(UPLOAD_CONCURRENCY, manifest.traces.length) }, () => worker().catch((error) => {
667
+ // One worker's stop ends the others after their request in flight.
668
+ stopping.abort();
669
+ throw error;
670
+ })));
671
+ for (const outcome of outcomes)
672
+ if (outcome.status === "rejected") {
673
+ if (outcome.reason instanceof Interrupted)
674
+ continue;
675
+ throw outcome.reason;
676
+ }
677
+ if (checkpoint.failure)
678
+ throw progressWriteFailure(checkpoint.failure);
679
+ if (stopping.signal.aborted)
680
+ throw new Interrupted();
681
+ const reconciled = await request("POST", `${base}/reconcile`);
682
+ const reconcileStop = stopFor(reconciled, "Reconciliation was refused");
683
+ if (reconcileStop)
684
+ throw reconcileStop;
685
+ progress({ event: "reconciled", ...(isObject(reconciled.json) ? reconciled.json : {}) });
686
+ const completion = await request("POST", `${base}/complete`);
687
+ if (completion.status === 409 && completion.code === "incomplete") {
688
+ incompleteMessage = completion.message ?? "Some traces are not verified yet.";
689
+ const listing = await request("GET", base);
690
+ if (listing.status === 200 && isObject(listing.json)) {
691
+ if (Array.isArray(listing.json.traces))
692
+ pendingFromHue = listing.json.traces;
693
+ if (typeof listing.json.note === "string")
694
+ note = listing.json.note;
695
+ }
696
+ }
697
+ else {
698
+ const completeStop = stopFor(completion, "Completion was refused");
699
+ if (completeStop)
700
+ throw completeStop;
701
+ completed = true;
702
+ progress({ event: "completed", import_id: importId });
703
+ }
704
+ }
705
+ catch (error) {
706
+ if (error instanceof Interrupted)
707
+ interrupted = true;
708
+ else if (error instanceof AlreadyCompleted) {
709
+ completed = true;
710
+ alreadyCompleted = true;
711
+ note = "Hue answered that this import already completed; nothing more was sent.";
712
+ }
713
+ else if (error instanceof UploadStop)
714
+ failure = error;
715
+ else
716
+ throw error;
717
+ }
718
+ finally {
719
+ removeInterrupt();
720
+ await checkpoint.flush();
721
+ }
722
+ // A write that failed at the end, or beside another stop, is still reported: a completed import
723
+ // needs no progress file, so it is a note there; anywhere else it is the failure to fix first.
724
+ if (checkpoint.failure) {
725
+ const stop = progressWriteFailure(checkpoint.failure);
726
+ if (completed)
727
+ note = stop.message;
728
+ else if (failure && failure.code !== stop.code)
729
+ note = stop.message;
730
+ else
731
+ failure = stop;
732
+ }
733
+ countSaved();
734
+ for (const trace of Object.values(checkpoint.file.traces)) {
735
+ landed += trace.batches.length;
736
+ stored += trace.blobs.length;
737
+ }
738
+ const state = completed
739
+ ? "completed"
740
+ : failure
741
+ ? "failed"
742
+ : interrupted
743
+ ? "stopped"
744
+ : "incomplete";
745
+ if (alreadyCompleted)
746
+ progress({ event: "completed", import_id: importId, already: true });
747
+ const nextAction = completed
748
+ ? `Call get_trace_import with trace_import_id ${importId} for the results.`
749
+ : failure?.prepareAgain
750
+ ? "Call prepare_trace_import again with the same manifest_digest and totals, write the new authorization to the file and run the same command again."
751
+ : failure?.code === "import_canceled"
752
+ ? `This import was canceled in Hue and takes no more requests. Call get_trace_import with trace_import_id ${importId} for its state.`
753
+ : failure && !["busy", "unreachable", "protocol"].includes(failure.code)
754
+ ? "Fix the reported problem; then run the same command again to resume."
755
+ : conflicted && !unverified && !interrupted && !failure
756
+ ? `The conflicting traces keep the evidence Hue already holds. Call get_trace_import with trace_import_id ${importId} to review them.`
757
+ : "Run the same command again to resume; what Hue already holds is not sent twice.";
758
+ return {
759
+ state,
760
+ import_url: base,
761
+ manifest_digest: manifestDigest,
762
+ totals,
763
+ traces: {
764
+ declared: manifest.traces.length,
765
+ verified,
766
+ conflicted,
767
+ unverified: manifest.traces.length - verified - conflicted,
768
+ },
769
+ batches: {
770
+ declared: totals.batches,
771
+ landed,
772
+ conflicted: conflicts.filter((conflict) => manifest.traces.some((trace) => trace.batches.some((batch) => batch.sha256 === conflict.sha256))).length,
773
+ pending: totals.batches - landed,
774
+ },
775
+ blobs: { declared: totals.blobs, stored, pending: totals.blobs - stored },
776
+ ...(note ? { note } : {}),
777
+ ...(pendingFromHue ? { pending: pendingFromHue } : {}),
778
+ conflicts,
779
+ next_action: nextAction,
780
+ ...(failure
781
+ ? { error: { code: failure.code, message: failure.message } }
782
+ : incompleteMessage
783
+ ? { error: { code: "incomplete", message: incompleteMessage } }
784
+ : {}),
785
+ };
786
+ }
787
+ /** The OTLP export response of an acknowledged batch, read strictly: a JSON object whose
788
+ * `partialSuccess`, when present, counts the rejected spans as a number or a digit string.
789
+ * Undefined for anything else, which is no acknowledgement to act on. */
790
+ function batchAcknowledgement(reply) {
791
+ if (!isObject(reply.json))
792
+ return undefined;
793
+ const partial = reply.json.partialSuccess;
794
+ if (partial === undefined)
795
+ return { rejected: 0, message: "" };
796
+ if (!isObject(partial))
797
+ return undefined;
798
+ const raw = partial.rejectedSpans;
799
+ const rejected = raw === undefined
800
+ ? 0
801
+ : typeof raw === "number" && Number.isSafeInteger(raw) && raw >= 0
802
+ ? raw
803
+ : typeof raw === "string" && /^\d{1,15}$/.test(raw)
804
+ ? Number(raw)
805
+ : undefined;
806
+ if (rejected === undefined)
807
+ return undefined;
808
+ return {
809
+ rejected,
810
+ message: typeof partial.errorMessage === "string" ? partial.errorMessage : "",
811
+ };
812
+ }
813
+ /** The response body, up to {@link MAX_RESPONSE_BYTES} (a longer one is cut there). A body that
814
+ * breaks off before its end rejects: the caller treats the request as unanswered. */
815
+ async function readBounded(response) {
816
+ const reader = response.body?.getReader();
817
+ if (!reader)
818
+ return Buffer.alloc(0);
819
+ const chunks = [];
820
+ let size = 0;
821
+ try {
822
+ while (true) {
823
+ const next = await reader.read();
824
+ if (next.done)
825
+ break;
826
+ size += next.value.byteLength;
827
+ if (size > MAX_RESPONSE_BYTES) {
828
+ await reader.cancel().catch(() => { });
829
+ break;
830
+ }
831
+ chunks.push(next.value);
832
+ }
833
+ }
834
+ finally {
835
+ reader.releaseLock();
836
+ }
837
+ return Buffer.concat(chunks);
838
+ }