@hue-run/sdk 0.18.0 → 0.18.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.
- package/CLI.md +130 -8
- package/EVALUATIONS.md +7 -4
- package/README.md +86 -22
- package/dist/attachment.d.ts +51 -7
- package/dist/attachment.js +141 -33
- package/dist/blobs.d.ts +22 -5
- package/dist/blobs.js +187 -72
- package/dist/cli/import.d.ts +35 -0
- package/dist/cli/import.js +692 -0
- package/dist/cli/langfuse.d.ts +309 -0
- package/dist/cli/langfuse.js +1075 -0
- package/dist/client.d.ts +9 -4
- package/dist/client.js +46 -15
- package/dist/evals/client.d.ts +7 -2
- package/dist/evals/client.js +20 -4
- package/dist/evals/files.js +100 -12
- package/dist/evals/media.d.ts +5 -1
- package/dist/evals/media.js +15 -3
- package/dist/evals/types.d.ts +4 -3
- package/dist/inline-files.d.ts +16 -6
- package/dist/inline-files.js +18 -14
- package/dist/json-edits.js +26 -7
- package/dist/large-media.d.ts +106 -0
- package/dist/large-media.js +414 -0
- package/dist/setup/cli.js +7 -3
- package/dist/snapshot.d.ts +2 -0
- package/dist/snapshot.js +19 -1
- package/dist/transport.d.ts +4 -0
- package/dist/transport.js +36 -16
- package/dist/types.d.ts +10 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/blobs.js
CHANGED
|
@@ -3,6 +3,7 @@ import { context } from "@opentelemetry/api";
|
|
|
3
3
|
import { suppressTracing } from "@opentelemetry/core";
|
|
4
4
|
import { isLoopbackHost } from "./config.js";
|
|
5
5
|
import { digestPart, hashInlineFiles, hashNestedInlineFiles, INLINE_FILE_LIMIT, INLINE_FILE_TEXT_PER_RECORD, isMessageKey, keepsPrefix, literalKeys, mediaBytes, mediaContentType, mediaIn, isTextMediaType, mayInlineFiles, isMediaContentType, MAX_INLINE_FILE_TEXT, placeable, placedMediaText, prefixPart, readMediaText, reencodeMedia, } from "./inline-files.js";
|
|
6
|
+
import { isByteSource, JoinedText, longFileBytes, longHead, placedLargeText, readLargeMediaText, } from "./large-media.js";
|
|
6
7
|
import { capLogBody, TRUNCATED_KEY, truncatedMarker, withTruncatedKeys } from "./privacy.js";
|
|
7
8
|
import { truncateUtf8 } from "./safety.js";
|
|
8
9
|
/**
|
|
@@ -99,40 +100,53 @@ export function blobPrefix(value) {
|
|
|
99
100
|
return Buffer.from(truncateUtf8(value, BLOB_PREFIX_BYTES), "utf8").toString("utf8");
|
|
100
101
|
}
|
|
101
102
|
/** A value's size in bytes as it is uploaded: a string's UTF-8 encoding, counted, not made. */
|
|
102
|
-
function sizeOf(value) {
|
|
103
|
-
|
|
103
|
+
export function sizeOf(value) {
|
|
104
|
+
if (typeof value === "string")
|
|
105
|
+
return Buffer.byteLength(value, "utf8");
|
|
106
|
+
return isByteSource(value) ? value.size : value.byteLength;
|
|
107
|
+
}
|
|
108
|
+
/** The bytes a value holds in memory: none for a source read as it is uploaded. */
|
|
109
|
+
export function heldBytesOf(value) {
|
|
110
|
+
return isByteSource(value) ? 0 : value.byteLength;
|
|
104
111
|
}
|
|
105
112
|
/** A string's UTF-8 encoding a slice at a time, never splitting a surrogate pair. */
|
|
106
113
|
function* utf8Chunks(text) {
|
|
114
|
+
// One buffer, reused: a reader is done with each slice before it asks for the next.
|
|
115
|
+
const buffer = Buffer.allocUnsafe(TEXT_CHUNK_UNITS * 3);
|
|
107
116
|
for (let start = 0; start < text.length;) {
|
|
108
117
|
let end = Math.min(text.length, start + TEXT_CHUNK_UNITS);
|
|
109
118
|
if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1]))
|
|
110
119
|
end--;
|
|
111
|
-
yield
|
|
120
|
+
yield buffer.subarray(0, buffer.write(text.slice(start, end), "utf8"));
|
|
112
121
|
start = end;
|
|
113
122
|
}
|
|
114
123
|
}
|
|
115
124
|
/** The bytes an upload carries, a slice at a time: no second copy of a large value is made. */
|
|
116
|
-
function
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
125
|
+
function uploadChunks(value) {
|
|
126
|
+
if (isByteSource(value))
|
|
127
|
+
return value.chunks();
|
|
128
|
+
return (function* () {
|
|
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
|
+
})();
|
|
123
136
|
}
|
|
124
137
|
const nextTurn = () => new Promise((resolve) => setImmediate(resolve));
|
|
125
138
|
/** The SHA-256 (lowercase hex) and byte size of what an upload of `value` carries, yielding to
|
|
126
|
-
* the event loop between slices so a large value does not stall the application.
|
|
127
|
-
|
|
139
|
+
* the event loop between slices so a large value does not stall the application. Past
|
|
140
|
+
* `deadline`, it throws rather than read on. */
|
|
141
|
+
export async function digestOf(value, deadline = Infinity) {
|
|
128
142
|
const hash = createHash("sha256");
|
|
129
143
|
let size = 0;
|
|
130
|
-
|
|
131
|
-
for (const chunk of uploadChunks(value)) {
|
|
144
|
+
for await (const chunk of uploadChunks(value)) {
|
|
132
145
|
hash.update(chunk);
|
|
133
146
|
size += chunk.byteLength;
|
|
134
|
-
|
|
135
|
-
|
|
147
|
+
await nextTurn();
|
|
148
|
+
if (Date.now() >= deadline)
|
|
149
|
+
throw new Error("The upload ran out of time");
|
|
136
150
|
}
|
|
137
151
|
return { sha256: hash.digest("hex"), size };
|
|
138
152
|
}
|
|
@@ -258,9 +272,12 @@ export class BlobUploader {
|
|
|
258
272
|
if (digest.size > MAX_BLOB_BYTES)
|
|
259
273
|
return { ok: false, reason: "tooLarge" };
|
|
260
274
|
const identity = `${traceId}:${digest.sha256}`;
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
275
|
+
for (let known = this.outcomes.get(identity); known; known = this.outcomes.get(identity)) {
|
|
276
|
+
const shared = await known.promise;
|
|
277
|
+
// Another reference's file changed since it was recorded: this one tries its own bytes.
|
|
278
|
+
if (shared.ok || !shared.changed)
|
|
279
|
+
return shared;
|
|
280
|
+
}
|
|
264
281
|
if (this.outcomes.size >= 4096) {
|
|
265
282
|
const oldest = [...this.outcomes].find(([, item]) => item.settled)?.[0];
|
|
266
283
|
if (oldest === undefined)
|
|
@@ -308,6 +325,8 @@ export class BlobUploader {
|
|
|
308
325
|
const put = await this.put(grant, value, digest.size, upload.deadline);
|
|
309
326
|
if (put === "stored")
|
|
310
327
|
break;
|
|
328
|
+
if (put === "changed")
|
|
329
|
+
return { ok: false, reason: "failed", changed: true };
|
|
311
330
|
if (this.closed)
|
|
312
331
|
return { ok: false, reason: "failed" };
|
|
313
332
|
const expired = put === "expired" || Date.now() >= grant.expiresAt;
|
|
@@ -464,14 +483,21 @@ export class BlobUploader {
|
|
|
464
483
|
grant: { url, headers, expiresAt: Number.isFinite(expiresAt) ? expiresAt : Infinity },
|
|
465
484
|
};
|
|
466
485
|
}
|
|
467
|
-
/** The PUT of the value's bytes, with exactly the headers Hue signed.
|
|
486
|
+
/** The PUT of the value's bytes, with exactly the headers Hue signed. A source that no longer
|
|
487
|
+
* holds its bytes (a file changed since it was recorded) ends the request before its body is
|
|
488
|
+
* complete, and fails the value without a retry. */
|
|
468
489
|
async put(grant, value, size, deadline) {
|
|
469
490
|
const reply = await this.request("PUT", grant.url, grant.headers, value, deadline, MAX_ERROR_RESPONSE_BYTES, size);
|
|
491
|
+
if (isByteSource(value) && value.changed)
|
|
492
|
+
return "changed";
|
|
470
493
|
if (!reply)
|
|
471
494
|
return "transient";
|
|
472
|
-
// 412: the object exists already (another upload of the same value won).
|
|
473
|
-
|
|
495
|
+
// 412: the object exists already (another upload of the same value won). A success answered
|
|
496
|
+
// before the whole body was written stores nothing that was sent.
|
|
497
|
+
if (reply.status === 412)
|
|
474
498
|
return "stored";
|
|
499
|
+
if (reply.status >= 200 && reply.status < 300)
|
|
500
|
+
return reply.complete ? "stored" : "failed";
|
|
475
501
|
if (reply.status === 403)
|
|
476
502
|
return reply.body.toString("utf8").includes("Request has expired") ? "expired" : "failed";
|
|
477
503
|
if (reply.status >= 500 || reply.status === 408 || reply.status === 429)
|
|
@@ -501,6 +527,8 @@ export class BlobUploader {
|
|
|
501
527
|
return undefined;
|
|
502
528
|
return new Promise((resolve) => {
|
|
503
529
|
let settled = false;
|
|
530
|
+
// Whether the whole body was handed to the request.
|
|
531
|
+
let ended = false;
|
|
504
532
|
let request;
|
|
505
533
|
const finish = (reply) => {
|
|
506
534
|
if (settled)
|
|
@@ -509,16 +537,20 @@ export class BlobUploader {
|
|
|
509
537
|
clearTimeout(timer);
|
|
510
538
|
if (request) {
|
|
511
539
|
this.requests.delete(request);
|
|
512
|
-
|
|
540
|
+
// An answer before the whole body was handed over ends the request: a store that
|
|
541
|
+
// neither reads nor closes would otherwise hold it, and its source, open.
|
|
542
|
+
if (!reply || !ended)
|
|
513
543
|
request.destroy();
|
|
514
544
|
}
|
|
515
545
|
resolve(reply);
|
|
516
546
|
};
|
|
517
547
|
const timer = setTimeout(() => finish(), Math.max(1, deadline - Date.now()));
|
|
548
|
+
// The bytes handed to the request: its body is complete once they are its length.
|
|
549
|
+
let sentBytes = 0;
|
|
550
|
+
const written = () => length === undefined || sentBytes === length;
|
|
518
551
|
const requestHeaders = { ...headers };
|
|
519
552
|
if (length === undefined)
|
|
520
|
-
requestHeaders["Content-Length"] =
|
|
521
|
-
typeof body === "string" ? Buffer.byteLength(body, "utf8") : body.byteLength;
|
|
553
|
+
requestHeaders["Content-Length"] = sizeOf(body);
|
|
522
554
|
try {
|
|
523
555
|
// Uploads are the SDK's own export traffic: an http instrumentation must not trace them.
|
|
524
556
|
request = context.with(suppressTracing(context.active()), () => http.request(url, { method, headers: requestHeaders, agent: false }, (response) => {
|
|
@@ -528,6 +560,7 @@ export class BlobUploader {
|
|
|
528
560
|
status: response.statusCode ?? 0,
|
|
529
561
|
headers: response.headers,
|
|
530
562
|
body: Buffer.concat(chunks),
|
|
563
|
+
complete: written(),
|
|
531
564
|
});
|
|
532
565
|
response.on("data", (chunk) => {
|
|
533
566
|
size += chunk.byteLength;
|
|
@@ -552,26 +585,41 @@ export class BlobUploader {
|
|
|
552
585
|
sent.on("error", () => finish());
|
|
553
586
|
sent.setTimeout(this.options.timeoutMillis, () => finish());
|
|
554
587
|
void (async () => {
|
|
555
|
-
if (length === undefined) {
|
|
588
|
+
if (length === undefined && !isByteSource(body)) {
|
|
589
|
+
ended = true;
|
|
556
590
|
sent.end(body);
|
|
557
591
|
return;
|
|
558
592
|
}
|
|
559
|
-
for
|
|
593
|
+
// A source or a text may reuse a slice's memory for the next: each slice is written out
|
|
594
|
+
// before the next is read. Bytes are views of memory that does not change.
|
|
595
|
+
const reused = !(body instanceof Uint8Array);
|
|
596
|
+
for await (const chunk of uploadChunks(body)) {
|
|
560
597
|
if (settled)
|
|
561
598
|
return;
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
599
|
+
sentBytes += chunk.byteLength;
|
|
600
|
+
await new Promise((proceed) => {
|
|
601
|
+
const done = () => {
|
|
602
|
+
sent.off("drain", done);
|
|
603
|
+
sent.off("close", done);
|
|
604
|
+
proceed();
|
|
605
|
+
};
|
|
606
|
+
sent.on("close", done);
|
|
607
|
+
if (reused) {
|
|
608
|
+
sent.write(chunk, () => done());
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (sent.write(chunk))
|
|
612
|
+
done();
|
|
613
|
+
else
|
|
569
614
|
sent.on("drain", done);
|
|
570
|
-
|
|
571
|
-
|
|
615
|
+
});
|
|
616
|
+
if (settled)
|
|
617
|
+
return;
|
|
572
618
|
}
|
|
573
|
-
if (
|
|
574
|
-
|
|
619
|
+
if (settled)
|
|
620
|
+
return;
|
|
621
|
+
ended = true;
|
|
622
|
+
sent.end();
|
|
575
623
|
})().catch(() => finish());
|
|
576
624
|
});
|
|
577
625
|
}
|
|
@@ -615,30 +663,46 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
615
663
|
* uploaded keeps its bytes inline, except in a recorded message: a span's own message keeps its
|
|
616
664
|
* digest, and a log record's keeps the digest of a file over the inline limit, as nested
|
|
617
665
|
* messages kept them before log records uploaded. */
|
|
618
|
-
const placeFiles = async (index, key, files) => {
|
|
666
|
+
const placeFiles = async (index, key, files, long) => {
|
|
619
667
|
const message = isMessageKey(key);
|
|
620
668
|
let changed = false;
|
|
621
669
|
await Promise.all(files.map((file, position) => {
|
|
622
|
-
|
|
623
|
-
|
|
670
|
+
// A file whose content is a long string is read from its place in the recorded text, a
|
|
671
|
+
// slice at a time: never decoded or copied whole.
|
|
672
|
+
const inPlace = long?.get(file.content);
|
|
673
|
+
const decoded = inPlace ? longFileBytes(file, inPlace) : mediaBytes(file);
|
|
674
|
+
const bytes = "source" in decoded ? decoded.source : decoded.bytes;
|
|
675
|
+
const size = sizeOf(bytes);
|
|
676
|
+
if (!placeable(file, decoded, size))
|
|
624
677
|
return Promise.resolve();
|
|
625
678
|
const contentType = mediaContentType(file, decoded);
|
|
626
679
|
const textual = isTextMediaType(contentType);
|
|
627
|
-
if (textual &&
|
|
680
|
+
if (textual && size <= INLINE_FILE_LIMIT)
|
|
628
681
|
return Promise.resolve();
|
|
629
|
-
const prefix = keepsPrefix(file, textual);
|
|
682
|
+
const prefix = keepsPrefix(file, textual, decoded);
|
|
630
683
|
const partKey = `${key}#${prefix ? file.pointer : file.partPointer}`;
|
|
631
684
|
const slot = slots > 0 && isBlobKey(partKey) ? (slots--, true) : false;
|
|
632
685
|
return (async () => {
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
686
|
+
// Hashing a long file takes an upload place, as hashing a large value does; one the
|
|
687
|
+
// export's upload budget cannot hash stays as recorded and is not uploaded.
|
|
688
|
+
let digest;
|
|
689
|
+
try {
|
|
690
|
+
digest = isByteSource(bytes)
|
|
691
|
+
? await uploader.limit(() => digestOf(bytes, upload.deadline))
|
|
692
|
+
: options.memo
|
|
693
|
+
? options.memo.digest(file, decoded, traceId)
|
|
694
|
+
: await digestOf(bytes);
|
|
695
|
+
}
|
|
696
|
+
catch {
|
|
697
|
+
fallback("failed");
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
636
700
|
const outcome = slot
|
|
637
|
-
? await uploader.store(traceId, partKey,
|
|
701
|
+
? await uploader.store(traceId, partKey, bytes, contentType, digest, upload)
|
|
638
702
|
: { ok: false, reason: "budget" };
|
|
639
703
|
if (outcome.ok) {
|
|
640
704
|
if (prefix)
|
|
641
|
-
prefixPart(file, blobPrefix(file.content));
|
|
705
|
+
prefixPart(file, blobPrefix(inPlace ? longHead(inPlace, BLOB_PREFIX_BYTES + 1) : file.content));
|
|
642
706
|
else
|
|
643
707
|
digestPart(file, digest);
|
|
644
708
|
changed = true;
|
|
@@ -649,7 +713,7 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
649
713
|
tally.uploaded++;
|
|
650
714
|
}
|
|
651
715
|
else {
|
|
652
|
-
if (message && (!collector.log ||
|
|
716
|
+
if (message && (!collector.log || size > INLINE_FILE_LIMIT)) {
|
|
653
717
|
digestPart(file, digest);
|
|
654
718
|
changed = true;
|
|
655
719
|
}
|
|
@@ -661,30 +725,59 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
661
725
|
};
|
|
662
726
|
const literal = literalKeys(attributes);
|
|
663
727
|
const parts = async (index, key, text) => {
|
|
664
|
-
//
|
|
665
|
-
if (!mayInlineFiles(key, text) ||
|
|
728
|
+
// Literal text holds no file.
|
|
729
|
+
if (!mayInlineFiles(key, text) || literal.has(key))
|
|
666
730
|
return text;
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
731
|
+
let media;
|
|
732
|
+
let large;
|
|
733
|
+
if (collector.large?.has(key)) {
|
|
734
|
+
// Held whole past the inspection bounds: its long strings were read in place, the JSON
|
|
735
|
+
// around them within the per-attribute bound.
|
|
736
|
+
media = large = largeReads.get(index);
|
|
671
737
|
}
|
|
672
738
|
else {
|
|
673
|
-
|
|
739
|
+
// Both bounds count UTF-16 code units, as admission does.
|
|
740
|
+
if (text.length > MAX_INLINE_FILE_TEXT)
|
|
674
741
|
return text;
|
|
675
|
-
inspected
|
|
742
|
+
if (collector.inspected) {
|
|
743
|
+
// A value admission skipped stays as recorded.
|
|
744
|
+
if (!collector.inspected.has(key))
|
|
745
|
+
return text;
|
|
746
|
+
}
|
|
747
|
+
else {
|
|
748
|
+
if (inspected + text.length > INLINE_FILE_TEXT_PER_RECORD)
|
|
749
|
+
return text;
|
|
750
|
+
inspected += text.length;
|
|
751
|
+
}
|
|
752
|
+
try {
|
|
753
|
+
media = readMediaText(key, text);
|
|
754
|
+
}
|
|
755
|
+
catch {
|
|
756
|
+
return text;
|
|
757
|
+
}
|
|
676
758
|
}
|
|
677
|
-
|
|
759
|
+
if (!media?.files.length)
|
|
760
|
+
return text;
|
|
761
|
+
if (!(await placeFiles(index, key, media.files, large?.long)))
|
|
762
|
+
return text;
|
|
763
|
+
return large ? await placedLargeText(large) : placedMediaText(media);
|
|
764
|
+
};
|
|
765
|
+
// The values read in place are scanned first, in candidate order, between turns of the event
|
|
766
|
+
// loop: places in `hue.blobs` are then taken in candidate order, as for any value.
|
|
767
|
+
const largeReads = new Map();
|
|
768
|
+
for (const [index, { key, value }] of collector.candidates.entries()) {
|
|
769
|
+
if (typeof value !== "string" ||
|
|
770
|
+
!collector.large?.has(key) ||
|
|
771
|
+
!mayInlineFiles(key, value) ||
|
|
772
|
+
literal.has(key))
|
|
773
|
+
continue;
|
|
678
774
|
try {
|
|
679
|
-
|
|
775
|
+
largeReads.set(index, await readLargeMediaText(key, value, options.longUnits));
|
|
680
776
|
}
|
|
681
777
|
catch {
|
|
682
|
-
|
|
778
|
+
// JSON that does not parse holds no file: the value stays as recorded.
|
|
683
779
|
}
|
|
684
|
-
|
|
685
|
-
return text;
|
|
686
|
-
return (await placeFiles(index, key, media.files)) ? placedMediaText(media) : text;
|
|
687
|
-
};
|
|
780
|
+
}
|
|
688
781
|
/** A log record's body with its files placed. Each file's content counts toward the record's
|
|
689
782
|
* inspection bounds, as an attribute's text does. */
|
|
690
783
|
const placeBody = async (body) => {
|
|
@@ -710,14 +803,22 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
710
803
|
return media.box.value;
|
|
711
804
|
};
|
|
712
805
|
const place = async (index, { key, value }) => {
|
|
713
|
-
const
|
|
714
|
-
|
|
806
|
+
const read = typeof value === "string" ? await parts(index, key, value) : value;
|
|
807
|
+
// Text joined from pieces of the recorded text is made one string only when it is kept
|
|
808
|
+
// inline; otherwise it is measured, hashed, uploaded and cut from its pieces.
|
|
809
|
+
const joined = read instanceof JoinedText ? read : undefined;
|
|
810
|
+
const size = joined ? joined.utf8Size() : sizeOf(read);
|
|
715
811
|
if (size <= valueBytes) {
|
|
716
|
-
result[key] =
|
|
812
|
+
result[key] = joined ? joined.text() : read;
|
|
717
813
|
return;
|
|
718
814
|
}
|
|
815
|
+
const placed = joined ? joined.source(size) : read;
|
|
719
816
|
listed.push({ order: index, key });
|
|
720
|
-
const cut = () =>
|
|
817
|
+
const cut = () => joined
|
|
818
|
+
? truncateUtf8(joined.head(valueBytes + 1), valueBytes)
|
|
819
|
+
: typeof placed === "string"
|
|
820
|
+
? truncateUtf8(placed, valueBytes)
|
|
821
|
+
: truncatedMarker(size);
|
|
721
822
|
// A log record's value is cut, as no log reader pages an uploaded text value.
|
|
722
823
|
if (collector.log) {
|
|
723
824
|
result[key] = cut();
|
|
@@ -733,13 +834,27 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
733
834
|
if (size > MAX_BLOB_BYTES)
|
|
734
835
|
return { ok: false, reason: "tooLarge" };
|
|
735
836
|
// Hashing a large value takes a slot as its upload does; the upload takes its own.
|
|
736
|
-
|
|
737
|
-
|
|
837
|
+
let digest;
|
|
838
|
+
try {
|
|
839
|
+
digest = await uploader.limit(() => digestOf(placed, upload.deadline));
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
return { ok: false, reason: "failed" };
|
|
843
|
+
}
|
|
844
|
+
const contentType = joined
|
|
845
|
+
? textContentType(joined.head(256) + joined.tail(256))
|
|
846
|
+
: typeof placed === "string"
|
|
847
|
+
? textContentType(placed)
|
|
848
|
+
: "application/octet-stream";
|
|
738
849
|
const outcome = await uploader.store(traceId, key, placed, contentType, digest, upload);
|
|
739
850
|
return outcome.ok ? { ...outcome, digest } : outcome;
|
|
740
851
|
})();
|
|
741
852
|
if (stored.ok) {
|
|
742
|
-
result[key] =
|
|
853
|
+
result[key] = joined
|
|
854
|
+
? blobPrefix(joined.head(BLOB_PREFIX_BYTES + 1))
|
|
855
|
+
: typeof placed === "string"
|
|
856
|
+
? blobPrefix(placed)
|
|
857
|
+
: blobPrefix(placed);
|
|
743
858
|
entries.push({
|
|
744
859
|
order: [index, Infinity],
|
|
745
860
|
entry: blobEntry(key, stored.digest.sha256, stored.digest.size, stored.contentType),
|
|
@@ -754,7 +869,7 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
754
869
|
const body = collector.body ? placeBody(collector.body.value) : undefined;
|
|
755
870
|
await Promise.all(collector.candidates.map((candidate, index) => place(index, candidate)));
|
|
756
871
|
await Promise.all((collector.attachments ?? []).map((file, position) => {
|
|
757
|
-
if (!file.bytes
|
|
872
|
+
if (!sizeOf(file.bytes))
|
|
758
873
|
return Promise.resolve();
|
|
759
874
|
const slot = slots > 0 ? (slots--, true) : false;
|
|
760
875
|
return (async () => {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hue import langfuse`: copies a Langfuse project's traces into Hue from this machine. Langfuse
|
|
3
|
+
* is read with the project's own keys through its public API; each observation becomes the span
|
|
4
|
+
* the Langfuse OpenTelemetry SDKs would have recorded and is exported through the SDK's own
|
|
5
|
+
* transport, so batching, retries, limits, content capture and large-value uploads are the SDK's.
|
|
6
|
+
* Ids are kept or derived from Langfuse's, so a second run sends the same spans again and Hue
|
|
7
|
+
* keeps one copy of each: an interrupted or partly failed import is resumed by running it again.
|
|
8
|
+
*/
|
|
9
|
+
/** Streams, environment and timing seams for {@link runImportCommand}; tests inject these. */
|
|
10
|
+
export interface ImportCommandIo {
|
|
11
|
+
stdout?: NodeJS.WritableStream;
|
|
12
|
+
stderr?: NodeJS.WritableStream;
|
|
13
|
+
stdin?: NodeJS.ReadableStream & {
|
|
14
|
+
isTTY?: boolean;
|
|
15
|
+
};
|
|
16
|
+
env?: NodeJS.ProcessEnv;
|
|
17
|
+
cwd?: string;
|
|
18
|
+
fetch?: typeof fetch;
|
|
19
|
+
/** Waits `ms`, resolving early when the import is stopped. */
|
|
20
|
+
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
21
|
+
/** Registers the stop request (SIGINT and SIGTERM by default) and returns its removal. */
|
|
22
|
+
onInterrupt?: (listener: () => void) => () => void;
|
|
23
|
+
/** The current time, for the default end of the window. */
|
|
24
|
+
now?: () => Date;
|
|
25
|
+
/** Ends the process after a second stop request (`process.exit` by default), so exports still
|
|
26
|
+
* in flight do not hold it until their requests time out. */
|
|
27
|
+
exit?: (code: number) => void;
|
|
28
|
+
}
|
|
29
|
+
export declare const IMPORT_USAGE = "Usage: hue import langfuse [options]\n\nCopy traces from a Langfuse project into Hue. The import runs on this machine with your own\nLangfuse keys, which are sent only to Langfuse. Each observation becomes a span with the\nlangfuse.* attributes Hue reads; ids are kept, so running the same command again resumes an\nimport without duplicating what was already sent.\n\nLangfuse (keys from the environment or --env-path; the secret key never from the command line):\n LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY The Langfuse project's API keys\n LANGFUSE_BASE_URL or LANGFUSE_HOST Langfuse host (default https://cloud.langfuse.com)\n --langfuse-host URL Langfuse host, instead of the variables\n --langfuse-public-key KEY Public key, instead of LANGFUSE_PUBLIC_KEY\n\nHue:\n HUE_API_KEY The Hue project key the traces are sent with (environment, --env-path\n or ./.env.hue); not needed with --dry-run\n --origin URL Hue origin (default HUE_BASE_URL, then https://app.hue.run)\n --service-name NAME service.name of the imported spans (default langfuse)\n\nSelection (newest first):\n --from TIME Traces that started at or after TIME: an ISO 8601 date (midnight UTC)\n or a date and time with a zone, such as 2026-01-01T09:00:00Z\n --to TIME Traces that started before TIME (default: an hour before the command\n starts, so traces still running are left for a later run)\n --limit N At most N traces\n --trace-id ID This Langfuse trace (repeatable); not with --from, --to or --limit\n\nContent:\n --media Download the files Langfuse media references (@@@langfuseMedia:\u2026@@@)\n point to and upload them to Hue; without it, references stay as text\n --no-content Send metadata only: no inputs, outputs, status messages or model\n parameters (content is sent by default, as the SDK records it)\n\nRun:\n --dry-run Read Langfuse and count what would be imported; send nothing to Hue\n --yes Import without asking for confirmation (required without a terminal)\n --env-path PATH Load a dotenv file first (--env-file also works when it exists);\n ./.env.hue loads when HUE_API_KEY is not set. Variables already set win\n -h, --help Show this help\n\nExit codes: 0 every trace was imported, 1 a trace, span or file was not, 2 usage error,\n130 stopped (run the same command again to resume).";
|
|
30
|
+
/** An instant of the window: an ISO 8601 date (midnight UTC) or a date and time with a zone. */
|
|
31
|
+
export declare function parseInstant(option: string, value: string): string;
|
|
32
|
+
/** The Langfuse host as a base URL without a trailing slash, or why it is refused. */
|
|
33
|
+
export declare function parseLangfuseHost(value: string): string;
|
|
34
|
+
/** Runs `hue import langfuse`; resolves to the exit code. */
|
|
35
|
+
export declare function runImportCommand(argv: string[], io?: ImportCommandIo): Promise<number>;
|