@hue-run/sdk 0.17.0 → 0.18.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 +130 -8
- package/EVALUATIONS.md +56 -2
- package/README.md +299 -37
- package/dist/attachment.d.ts +82 -0
- package/dist/attachment.js +225 -0
- package/dist/blobs.d.ts +41 -3
- package/dist/blobs.js +126 -41
- 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 +18 -0
- package/dist/client.js +148 -13
- package/dist/evals/client.d.ts +19 -1
- package/dist/evals/client.js +91 -11
- package/dist/evals/files.d.ts +11 -1
- package/dist/evals/files.js +248 -20
- package/dist/evals/media.d.ts +221 -0
- package/dist/evals/media.js +919 -0
- package/dist/evals/runner.js +130 -106
- package/dist/evals/types.d.ts +49 -0
- package/dist/evals.d.ts +2 -0
- package/dist/evals.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/inline-files.d.ts +140 -8
- package/dist/inline-files.js +572 -51
- package/dist/json-edits.d.ts +24 -0
- package/dist/json-edits.js +187 -0
- package/dist/privacy.d.ts +13 -0
- package/dist/privacy.js +84 -17
- package/dist/safety.d.ts +6 -2
- package/dist/safety.js +15 -3
- package/dist/setup/cli.js +7 -3
- package/dist/snapshot.d.ts +1 -1
- package/dist/snapshot.js +123 -29
- package/dist/transport.d.ts +10 -0
- package/dist/transport.js +296 -56
- package/dist/types.d.ts +25 -6
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
3
|
+
import { types as utilTypes } from "node:util";
|
|
4
|
+
import { MAX_FILE_DATA_BYTES } from "./config.js";
|
|
5
|
+
import { inlineFileBytes, isMediaContentType, isMediaName } from "./inline-files.js";
|
|
6
|
+
/** How deep in a value an attachment's part may sit and still be uploaded: Hue reads media parts
|
|
7
|
+
* within ten levels of the value's root. */
|
|
8
|
+
export const MAX_ATTACHMENT_DEPTH = 10;
|
|
9
|
+
const states = new WeakMap();
|
|
10
|
+
/** A strict base64 string: alphabet characters only, padded to a multiple of four. */
|
|
11
|
+
const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
12
|
+
/** The bytes of a string attachment: a `data:` URL by its own encoding, else strict base64. */
|
|
13
|
+
function stringBytes(text) {
|
|
14
|
+
if (text.startsWith("data:")) {
|
|
15
|
+
const decoded = inlineFileBytes(text);
|
|
16
|
+
return decoded.decoded ? decoded.bytes : undefined;
|
|
17
|
+
}
|
|
18
|
+
if (text.length % 4 !== 0 || !BASE64.test(text))
|
|
19
|
+
return undefined;
|
|
20
|
+
return Buffer.from(text, "base64");
|
|
21
|
+
}
|
|
22
|
+
/** A file's bytes, read through one descriptor and never past the limit. */
|
|
23
|
+
function fileBytes(path) {
|
|
24
|
+
const descriptor = openSync(path, "r");
|
|
25
|
+
try {
|
|
26
|
+
if (fstatSync(descriptor).size > MAX_FILE_DATA_BYTES)
|
|
27
|
+
return undefined;
|
|
28
|
+
// Read to its end, which may lie past its size as stated (a file that grew, a pipe), but
|
|
29
|
+
// never more than one byte past the limit.
|
|
30
|
+
const parts = [];
|
|
31
|
+
let total = 0;
|
|
32
|
+
for (;;) {
|
|
33
|
+
const room = Math.min(1 << 20, MAX_FILE_DATA_BYTES + 1 - total);
|
|
34
|
+
if (room <= 0)
|
|
35
|
+
return undefined;
|
|
36
|
+
const buffer = Buffer.alloc(room);
|
|
37
|
+
const read = readSync(descriptor, buffer, 0, room, null);
|
|
38
|
+
if (read === 0)
|
|
39
|
+
break;
|
|
40
|
+
parts.push(buffer.subarray(0, read));
|
|
41
|
+
total += read;
|
|
42
|
+
}
|
|
43
|
+
return Buffer.concat(parts, total);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
closeSync(descriptor);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function attachmentBytes(options) {
|
|
50
|
+
const { data, path } = options;
|
|
51
|
+
if ((data === undefined) === (path === undefined))
|
|
52
|
+
return undefined;
|
|
53
|
+
let bytes;
|
|
54
|
+
if (typeof path === "string")
|
|
55
|
+
bytes = path ? fileBytes(path) : undefined;
|
|
56
|
+
else if (typeof data === "string") {
|
|
57
|
+
if (data.length > 4 * MAX_FILE_DATA_BYTES)
|
|
58
|
+
return undefined;
|
|
59
|
+
bytes = stringBytes(data);
|
|
60
|
+
}
|
|
61
|
+
// A copy, so a later change to the caller's buffer cannot change what the digest describes;
|
|
62
|
+
// bytes over the limit are refused before anything is copied.
|
|
63
|
+
else if (ArrayBuffer.isView(data)) {
|
|
64
|
+
if (data.byteLength > MAX_FILE_DATA_BYTES)
|
|
65
|
+
return undefined;
|
|
66
|
+
bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice();
|
|
67
|
+
}
|
|
68
|
+
else if (data instanceof ArrayBuffer) {
|
|
69
|
+
if (data.byteLength > MAX_FILE_DATA_BYTES)
|
|
70
|
+
return undefined;
|
|
71
|
+
bytes = new Uint8Array(data.slice(0));
|
|
72
|
+
}
|
|
73
|
+
return bytes && bytes.byteLength <= MAX_FILE_DATA_BYTES ? bytes : undefined;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A file placed in a recorded value, the equivalent of Langfuse's `LangfuseMedia` and
|
|
77
|
+
* Braintrust's `Attachment`. Put it anywhere in a `withSpan()` input or output, in `model()`
|
|
78
|
+
* messages, in a tool's arguments or result, in `recordMessages()` messages or in a span
|
|
79
|
+
* attribute passed to `withSpan()`. Hue records it at that position as a GenAI `blob` part that
|
|
80
|
+
* names the file by digest (`{ type: "blob", mime_type, modality, sha256, size, name }`) and
|
|
81
|
+
* uploads its bytes during export, listed under the record's `hue.blobs`.
|
|
82
|
+
*
|
|
83
|
+
* The bytes are read and hashed when the attachment is created; `data` and `path` are limited to
|
|
84
|
+
* 25 MiB. Creating one never throws: an attachment without exactly one of `data` and `path`, with
|
|
85
|
+
* unreadable data, larger data or an invalid `contentType` records `null` where it is placed and
|
|
86
|
+
* counts an instrumentation failure; an invalid `name` alone is omitted and counted.
|
|
87
|
+
*/
|
|
88
|
+
export class Attachment {
|
|
89
|
+
/** The media type given. */
|
|
90
|
+
contentType;
|
|
91
|
+
/** The name given, when it is a valid media name. */
|
|
92
|
+
name;
|
|
93
|
+
/** Lowercase hex SHA-256 of the bytes; undefined for an invalid attachment. */
|
|
94
|
+
sha256;
|
|
95
|
+
/** Size of the bytes; undefined for an invalid attachment. */
|
|
96
|
+
size;
|
|
97
|
+
constructor(options) {
|
|
98
|
+
const contentType = options?.contentType;
|
|
99
|
+
const name = options?.name;
|
|
100
|
+
let bytes;
|
|
101
|
+
try {
|
|
102
|
+
bytes = isMediaContentType(contentType) ? attachmentBytes(options) : undefined;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// An unreadable path, or data that is not bytes: the attachment is invalid.
|
|
106
|
+
bytes = undefined;
|
|
107
|
+
}
|
|
108
|
+
const sha256 = bytes ? createHash("sha256").update(bytes).digest("hex") : undefined;
|
|
109
|
+
this.contentType = typeof contentType === "string" ? contentType : "";
|
|
110
|
+
this.name = isMediaName(name) ? name : undefined;
|
|
111
|
+
this.sha256 = sha256;
|
|
112
|
+
this.size = bytes?.byteLength;
|
|
113
|
+
states.set(this, {
|
|
114
|
+
...(bytes ? { bytes, sha256 } : {}),
|
|
115
|
+
contentType: this.contentType,
|
|
116
|
+
...(this.name === undefined ? {} : { name: this.name }),
|
|
117
|
+
invalidName: name !== undefined && name !== null && this.name === undefined,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** @internal The attachment's bytes and validated fields; undefined for anything else. */
|
|
122
|
+
export function attachmentState(value) {
|
|
123
|
+
return value instanceof Attachment ? states.get(value) : undefined;
|
|
124
|
+
}
|
|
125
|
+
/** The modality a media type implies, as Hue reads a part's media type. */
|
|
126
|
+
export function modalityOf(contentType) {
|
|
127
|
+
const type = contentType.split(";", 1)[0].trim().toLowerCase();
|
|
128
|
+
if (type.startsWith("image/"))
|
|
129
|
+
return "image";
|
|
130
|
+
if (type.startsWith("audio/"))
|
|
131
|
+
return "audio";
|
|
132
|
+
if (type.startsWith("video/"))
|
|
133
|
+
return "video";
|
|
134
|
+
if (type.startsWith("text/") ||
|
|
135
|
+
type === "application/pdf" ||
|
|
136
|
+
type === "application/rtf" ||
|
|
137
|
+
type === "application/msword" ||
|
|
138
|
+
type === "application/vnd.ms-excel" ||
|
|
139
|
+
type === "application/vnd.ms-powerpoint" ||
|
|
140
|
+
type.startsWith("application/vnd.openxmlformats-officedocument."))
|
|
141
|
+
return "document";
|
|
142
|
+
return "file";
|
|
143
|
+
}
|
|
144
|
+
/** An attachment's digest-form GenAI part, the name only with content capture; undefined for an
|
|
145
|
+
* invalid attachment. */
|
|
146
|
+
export function attachmentPart(state, captureContent) {
|
|
147
|
+
if (!state.bytes || !state.sha256)
|
|
148
|
+
return undefined;
|
|
149
|
+
return {
|
|
150
|
+
type: "blob",
|
|
151
|
+
mime_type: state.contentType,
|
|
152
|
+
modality: modalityOf(state.contentType),
|
|
153
|
+
sha256: state.sha256,
|
|
154
|
+
size: state.bytes.byteLength,
|
|
155
|
+
...(captureContent && state.name !== undefined ? { name: state.name } : {}),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/** Whether a value holds an attachment anywhere, within the encoder's bounds. */
|
|
159
|
+
export function holdsAttachment(value) {
|
|
160
|
+
let nodes = 0;
|
|
161
|
+
const visit = (item, depth) => {
|
|
162
|
+
// Proxies and accessors are never touched: reading them could run application code.
|
|
163
|
+
if (item === null || typeof item !== "object" || utilTypes.isProxy(item))
|
|
164
|
+
return false;
|
|
165
|
+
if (item instanceof Attachment)
|
|
166
|
+
return true;
|
|
167
|
+
if (depth > 32 || ++nodes > 65_536)
|
|
168
|
+
return false;
|
|
169
|
+
const prototype = Object.getPrototypeOf(item);
|
|
170
|
+
if (!Array.isArray(item) && prototype !== Object.prototype && prototype !== null)
|
|
171
|
+
return false;
|
|
172
|
+
return Object.keys(item).some((key) => {
|
|
173
|
+
const descriptor = Object.getOwnPropertyDescriptor(item, key);
|
|
174
|
+
return (descriptor !== undefined && "value" in descriptor && visit(descriptor.value, depth + 1));
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
try {
|
|
178
|
+
return visit(value, 0);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** A JSON pointer's reference token. */
|
|
185
|
+
export function pointerSegment(segment) {
|
|
186
|
+
return segment.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
187
|
+
}
|
|
188
|
+
/** The digest parts in `value` (within {@link MAX_ATTACHMENT_DEPTH}) that are the `files`' own
|
|
189
|
+
* parts: an object with `type` `blob`, no `content`, and the file's `sha256`, `size` and
|
|
190
|
+
* `mime_type`. Each found part takes one file of that digest, in the value's order; a part's
|
|
191
|
+
* `name`, when valid, is the one it carries. */
|
|
192
|
+
export function locateParts(value, files) {
|
|
193
|
+
const pending = [...files];
|
|
194
|
+
const found = [];
|
|
195
|
+
let nodes = 0;
|
|
196
|
+
const visit = (node, pointer, depth) => {
|
|
197
|
+
if (!pending.length || depth > MAX_ATTACHMENT_DEPTH || ++nodes > 1_048_576)
|
|
198
|
+
return;
|
|
199
|
+
if (Array.isArray(node)) {
|
|
200
|
+
node.forEach((item, index) => visit(item, `${pointer}/${index}`, depth + 1));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (node === null || typeof node !== "object")
|
|
204
|
+
return;
|
|
205
|
+
const part = node;
|
|
206
|
+
if (part.type === "blob" && !Object.hasOwn(part, "content")) {
|
|
207
|
+
const index = pending.findIndex((file) => part.sha256 === file.digest.sha256 &&
|
|
208
|
+
part.size === file.digest.size &&
|
|
209
|
+
part.mime_type === file.contentType);
|
|
210
|
+
if (index !== -1) {
|
|
211
|
+
const [file] = pending.splice(index, 1);
|
|
212
|
+
found.push({
|
|
213
|
+
file: file,
|
|
214
|
+
pointer,
|
|
215
|
+
...(isMediaName(part.name) ? { name: part.name } : {}),
|
|
216
|
+
});
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
for (const [key, item] of Object.entries(part))
|
|
221
|
+
visit(item, `${pointer}/${pointerSegment(key)}`, depth + 1);
|
|
222
|
+
};
|
|
223
|
+
visit(value, "", 0);
|
|
224
|
+
return found;
|
|
225
|
+
}
|
package/dist/blobs.d.ts
CHANGED
|
@@ -39,10 +39,21 @@ export interface OffloadCollector {
|
|
|
39
39
|
candidates: OffloadCandidate[];
|
|
40
40
|
cut: string[];
|
|
41
41
|
attachments?: MediaAttachment[];
|
|
42
|
-
/** The span's own
|
|
43
|
-
* export inspects only these. Absent for a record admission did not copy,
|
|
44
|
-
* inspects within its own allowance. */
|
|
42
|
+
/** The span's own attributes admission inspected for media within the record's allowance:
|
|
43
|
+
* export inspects only these. Absent for a record admission did not copy, and for a log record,
|
|
44
|
+
* which export inspects within its own allowance. */
|
|
45
45
|
inspected?: ReadonlySet<string> | undefined;
|
|
46
|
+
/** Set for a log record: its values over the inline limit are cut, never uploaded whole; only
|
|
47
|
+
* the media in its inspected attributes and its body upload, with the attachments placed in
|
|
48
|
+
* its body (`attachments`, keyed `body#<pointer>`). */
|
|
49
|
+
log?: boolean;
|
|
50
|
+
/** A log record's structured body, redacted whole, whose media upload under `body#<pointer>`. */
|
|
51
|
+
body?: {
|
|
52
|
+
value: unknown;
|
|
53
|
+
};
|
|
54
|
+
/** The strings of a log record's body the queue cut when the record was admitted: never read
|
|
55
|
+
* as files, since their bytes are not the file's. */
|
|
56
|
+
bodyCut?: Set<string>;
|
|
46
57
|
}
|
|
47
58
|
export interface MediaAttachment {
|
|
48
59
|
bytes: Uint8Array;
|
|
@@ -50,7 +61,14 @@ export interface MediaAttachment {
|
|
|
50
61
|
contentType: string;
|
|
51
62
|
name?: string;
|
|
52
63
|
eventTime?: HrTime;
|
|
64
|
+
/** For an attachment placed in a value: the record's attribute (or `body`) that holds its part. */
|
|
65
|
+
attribute?: string;
|
|
66
|
+
/** For a placed attachment found in its value: its `hue.blobs` key, `<attribute>#<pointer>`. */
|
|
67
|
+
key?: string;
|
|
53
68
|
}
|
|
69
|
+
/** The `hue.blobs` key of a staged file: its part's for an attachment placed in a value, else
|
|
70
|
+
* its `hue.file` event's. */
|
|
71
|
+
export declare function mediaKey(file: MediaAttachment): string;
|
|
54
72
|
/** The outcome of one export's uploads, reported once the export's records are placed. */
|
|
55
73
|
export interface OffloadTally {
|
|
56
74
|
uploaded: number;
|
|
@@ -172,6 +190,19 @@ export interface OffloadOptions {
|
|
|
172
190
|
* uploaded listed under `hue.truncated`. Every fallback is counted in the tally.
|
|
173
191
|
*/
|
|
174
192
|
export declare function offloadAttributes(attributes: Record<string, unknown>, collector: OffloadCollector, options: OffloadOptions): Promise<Record<string, unknown>>;
|
|
193
|
+
/**
|
|
194
|
+
* A record's own attributes, and a log record's body, with each candidate placed (see
|
|
195
|
+
* {@link offloadAttributes}). A file is uploaded and placed in its digest form (a text file in a
|
|
196
|
+
* provider part keeps a 16 KiB prefix), its `hue.blobs` entry keyed `<attribute>#<pointer>` or, in
|
|
197
|
+
* a log record's body, `body#<pointer>`. A file that is not uploaded keeps its bytes inline, except
|
|
198
|
+
* in a span's own recorded messages, where it keeps its digest as before. A log record's values
|
|
199
|
+
* over the inline limit are cut, never uploaded whole, and a body still over it is replaced by the
|
|
200
|
+
* receiver's marker, as before.
|
|
201
|
+
*/
|
|
202
|
+
export declare function offloadRecord(attributes: Record<string, unknown>, collector: OffloadCollector, options: OffloadOptions): Promise<{
|
|
203
|
+
attributes: Record<string, unknown>;
|
|
204
|
+
body?: unknown;
|
|
205
|
+
}>;
|
|
175
206
|
/**
|
|
176
207
|
* The span's own attributes with every candidate placed as it was before uploads existed, for an
|
|
177
208
|
* upload step that failed unexpectedly: a message's large inline files as their digest, a value
|
|
@@ -179,4 +210,11 @@ export declare function offloadAttributes(attributes: Record<string, unknown>, c
|
|
|
179
210
|
* `hue.truncated`.
|
|
180
211
|
*/
|
|
181
212
|
export declare function fallbackAttributes(attributes: Record<string, unknown>, collector: OffloadCollector, valueBytes: number, tally: OffloadTally, traceId: string, memo?: MediaDigestMemo): Record<string, unknown>;
|
|
213
|
+
/** A record's attributes, and a log record's body, placed as {@link fallbackAttributes} places
|
|
214
|
+
* them: a log record's files keep their bytes inline, and its body over the inline limit is
|
|
215
|
+
* replaced by the receiver's marker, as before. */
|
|
216
|
+
export declare function fallbackRecord(attributes: Record<string, unknown>, collector: OffloadCollector, valueBytes: number, tally: OffloadTally, traceId: string, memo?: MediaDigestMemo): {
|
|
217
|
+
attributes: Record<string, unknown>;
|
|
218
|
+
body?: unknown;
|
|
219
|
+
};
|
|
182
220
|
export {};
|
package/dist/blobs.js
CHANGED
|
@@ -2,8 +2,8 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { context } from "@opentelemetry/api";
|
|
3
3
|
import { suppressTracing } from "@opentelemetry/core";
|
|
4
4
|
import { isLoopbackHost } from "./config.js";
|
|
5
|
-
import { digestPart, hashInlineFiles, INLINE_FILE_LIMIT, INLINE_FILE_TEXT_PER_RECORD, mediaBytes, mediaContentType, isTextMediaType, mayInlineFiles, isMediaContentType,
|
|
6
|
-
import { TRUNCATED_KEY, truncatedMarker, withTruncatedKeys } from "./privacy.js";
|
|
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 { capLogBody, TRUNCATED_KEY, truncatedMarker, withTruncatedKeys } from "./privacy.js";
|
|
7
7
|
import { truncateUtf8 } from "./safety.js";
|
|
8
8
|
/**
|
|
9
9
|
* Large values and binary media travel apart from their span through reservation, presigned PUT,
|
|
@@ -53,6 +53,11 @@ export const FALLBACK_MESSAGES = {
|
|
|
53
53
|
tooLarge: "Content over its upload limit kept inline fallback or file metadata",
|
|
54
54
|
budget: "Content did not fit the SDK's upload budget; large values were cut inline and media kept digest metadata",
|
|
55
55
|
};
|
|
56
|
+
/** The `hue.blobs` key of a staged file: its part's for an attachment placed in a value, else
|
|
57
|
+
* its `hue.file` event's. */
|
|
58
|
+
export function mediaKey(file) {
|
|
59
|
+
return file.key ?? `event:hue.file:${file.digest.sha256}`;
|
|
60
|
+
}
|
|
56
61
|
export function newTally() {
|
|
57
62
|
return { uploaded: 0, fallbacks: new Map() };
|
|
58
63
|
}
|
|
@@ -579,6 +584,18 @@ export class BlobUploader {
|
|
|
579
584
|
* uploaded listed under `hue.truncated`. Every fallback is counted in the tally.
|
|
580
585
|
*/
|
|
581
586
|
export async function offloadAttributes(attributes, collector, options) {
|
|
587
|
+
return (await offloadRecord(attributes, collector, options)).attributes;
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* A record's own attributes, and a log record's body, with each candidate placed (see
|
|
591
|
+
* {@link offloadAttributes}). A file is uploaded and placed in its digest form (a text file in a
|
|
592
|
+
* provider part keeps a 16 KiB prefix), its `hue.blobs` entry keyed `<attribute>#<pointer>` or, in
|
|
593
|
+
* a log record's body, `body#<pointer>`. A file that is not uploaded keeps its bytes inline, except
|
|
594
|
+
* in a span's own recorded messages, where it keeps its digest as before. A log record's values
|
|
595
|
+
* over the inline limit are cut, never uploaded whole, and a body still over it is replaced by the
|
|
596
|
+
* receiver's marker, as before.
|
|
597
|
+
*/
|
|
598
|
+
export async function offloadRecord(attributes, collector, options) {
|
|
582
599
|
const { uploader, traceId, valueBytes, upload, tally } = options;
|
|
583
600
|
const result = { ...attributes };
|
|
584
601
|
const existing = Array.isArray(attributes[BLOBS_KEY])
|
|
@@ -594,44 +611,25 @@ export async function offloadAttributes(attributes, collector, options) {
|
|
|
594
611
|
for (let index = 0; index < collector.cut.length; index++)
|
|
595
612
|
fallback(uploader.unsupported() ? "unsupported" : "budget");
|
|
596
613
|
let inspected = 0;
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
if (!collector.inspected.has(key))
|
|
604
|
-
return text;
|
|
605
|
-
}
|
|
606
|
-
else {
|
|
607
|
-
if (inspected + text.length > INLINE_FILE_TEXT_PER_RECORD)
|
|
608
|
-
return text;
|
|
609
|
-
inspected += text.length;
|
|
610
|
-
}
|
|
611
|
-
let parsed;
|
|
612
|
-
let found;
|
|
613
|
-
try {
|
|
614
|
-
parsed = JSON.parse(text);
|
|
615
|
-
found = largeInlineFileParts(parsed);
|
|
616
|
-
}
|
|
617
|
-
catch {
|
|
618
|
-
return text;
|
|
619
|
-
}
|
|
620
|
-
if (!found.length)
|
|
621
|
-
return text;
|
|
614
|
+
/** Uploads each file of a value and places it; whether any was placed. A file that is not
|
|
615
|
+
* uploaded keeps its bytes inline, except in a recorded message: a span's own message keeps its
|
|
616
|
+
* digest, and a log record's keeps the digest of a file over the inline limit, as nested
|
|
617
|
+
* messages kept them before log records uploaded. */
|
|
618
|
+
const placeFiles = async (index, key, files) => {
|
|
619
|
+
const message = isMessageKey(key);
|
|
622
620
|
let changed = false;
|
|
623
|
-
await Promise.all(
|
|
621
|
+
await Promise.all(files.map((file, position) => {
|
|
624
622
|
const decoded = mediaBytes(file);
|
|
625
|
-
if (!decoded
|
|
623
|
+
if (!placeable(file, decoded))
|
|
626
624
|
return Promise.resolve();
|
|
627
625
|
const contentType = mediaContentType(file, decoded);
|
|
628
626
|
const textual = isTextMediaType(contentType);
|
|
629
627
|
if (textual && decoded.bytes.byteLength <= INLINE_FILE_LIMIT)
|
|
630
628
|
return Promise.resolve();
|
|
631
|
-
const
|
|
629
|
+
const prefix = keepsPrefix(file, textual, decoded);
|
|
630
|
+
const partKey = `${key}#${prefix ? file.pointer : file.partPointer}`;
|
|
632
631
|
const slot = slots > 0 && isBlobKey(partKey) ? (slots--, true) : false;
|
|
633
632
|
return (async () => {
|
|
634
|
-
changed = true;
|
|
635
633
|
const digest = options.memo
|
|
636
634
|
? options.memo.digest(file, decoded, traceId)
|
|
637
635
|
: await digestOf(decoded.bytes);
|
|
@@ -639,10 +637,11 @@ export async function offloadAttributes(attributes, collector, options) {
|
|
|
639
637
|
? await uploader.store(traceId, partKey, decoded.bytes, contentType, digest, upload)
|
|
640
638
|
: { ok: false, reason: "budget" };
|
|
641
639
|
if (outcome.ok) {
|
|
642
|
-
if (
|
|
643
|
-
file
|
|
640
|
+
if (prefix)
|
|
641
|
+
prefixPart(file, blobPrefix(file.content));
|
|
644
642
|
else
|
|
645
643
|
digestPart(file, digest);
|
|
644
|
+
changed = true;
|
|
646
645
|
entries.push({
|
|
647
646
|
order: [index, position],
|
|
648
647
|
entry: blobEntry(partKey, digest.sha256, digest.size, outcome.contentType, file.name),
|
|
@@ -650,12 +649,65 @@ export async function offloadAttributes(attributes, collector, options) {
|
|
|
650
649
|
tally.uploaded++;
|
|
651
650
|
}
|
|
652
651
|
else {
|
|
653
|
-
|
|
652
|
+
if (message && (!collector.log || decoded.bytes.byteLength > INLINE_FILE_LIMIT)) {
|
|
653
|
+
digestPart(file, digest);
|
|
654
|
+
changed = true;
|
|
655
|
+
}
|
|
654
656
|
fallback(outcome.reason);
|
|
655
657
|
}
|
|
656
658
|
})();
|
|
657
659
|
}));
|
|
658
|
-
return changed
|
|
660
|
+
return changed;
|
|
661
|
+
};
|
|
662
|
+
const literal = literalKeys(attributes);
|
|
663
|
+
const parts = async (index, key, text) => {
|
|
664
|
+
// Both bounds count UTF-16 code units, as admission does. Literal text holds no file.
|
|
665
|
+
if (!mayInlineFiles(key, text) || text.length > MAX_INLINE_FILE_TEXT || literal.has(key))
|
|
666
|
+
return text;
|
|
667
|
+
if (collector.inspected) {
|
|
668
|
+
// A value admission skipped stays as recorded.
|
|
669
|
+
if (!collector.inspected.has(key))
|
|
670
|
+
return text;
|
|
671
|
+
}
|
|
672
|
+
else {
|
|
673
|
+
if (inspected + text.length > INLINE_FILE_TEXT_PER_RECORD)
|
|
674
|
+
return text;
|
|
675
|
+
inspected += text.length;
|
|
676
|
+
}
|
|
677
|
+
let media;
|
|
678
|
+
try {
|
|
679
|
+
media = readMediaText(key, text);
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
return text;
|
|
683
|
+
}
|
|
684
|
+
if (!media?.files.length)
|
|
685
|
+
return text;
|
|
686
|
+
return (await placeFiles(index, key, media.files)) ? placedMediaText(media) : text;
|
|
687
|
+
};
|
|
688
|
+
/** A log record's body with its files placed. Each file's content counts toward the record's
|
|
689
|
+
* inspection bounds, as an attribute's text does. */
|
|
690
|
+
const placeBody = async (body) => {
|
|
691
|
+
let media;
|
|
692
|
+
try {
|
|
693
|
+
media = mediaIn(body);
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
return body;
|
|
697
|
+
}
|
|
698
|
+
const files = media.files.filter((file) => {
|
|
699
|
+
// A string the queue cut holds only the start of its file: it stays as cut.
|
|
700
|
+
if (collector.bodyCut?.has(file.content))
|
|
701
|
+
return false;
|
|
702
|
+
const units = file.content.length;
|
|
703
|
+
if (units > MAX_INLINE_FILE_TEXT || inspected + units > INLINE_FILE_TEXT_PER_RECORD)
|
|
704
|
+
return false;
|
|
705
|
+
inspected += units;
|
|
706
|
+
return true;
|
|
707
|
+
});
|
|
708
|
+
if (await placeFiles(-1, "body", files))
|
|
709
|
+
reencodeMedia(media);
|
|
710
|
+
return media.box.value;
|
|
659
711
|
};
|
|
660
712
|
const place = async (index, { key, value }) => {
|
|
661
713
|
const placed = typeof value === "string" ? await parts(index, key, value) : value;
|
|
@@ -666,6 +718,11 @@ export async function offloadAttributes(attributes, collector, options) {
|
|
|
666
718
|
}
|
|
667
719
|
listed.push({ order: index, key });
|
|
668
720
|
const cut = () => typeof placed === "string" ? truncateUtf8(placed, valueBytes) : truncatedMarker(size);
|
|
721
|
+
// A log record's value is cut, as no log reader pages an uploaded text value.
|
|
722
|
+
if (collector.log) {
|
|
723
|
+
result[key] = cut();
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
669
726
|
if (slots <= 0) {
|
|
670
727
|
result[key] = cut();
|
|
671
728
|
fallback("budget");
|
|
@@ -694,13 +751,14 @@ export async function offloadAttributes(attributes, collector, options) {
|
|
|
694
751
|
fallback(stored.reason);
|
|
695
752
|
}
|
|
696
753
|
};
|
|
754
|
+
const body = collector.body ? placeBody(collector.body.value) : undefined;
|
|
697
755
|
await Promise.all(collector.candidates.map((candidate, index) => place(index, candidate)));
|
|
698
756
|
await Promise.all((collector.attachments ?? []).map((file, position) => {
|
|
699
757
|
if (!file.bytes.byteLength)
|
|
700
758
|
return Promise.resolve();
|
|
701
759
|
const slot = slots > 0 ? (slots--, true) : false;
|
|
702
760
|
return (async () => {
|
|
703
|
-
const key =
|
|
761
|
+
const key = mediaKey(file);
|
|
704
762
|
const outcome = slot
|
|
705
763
|
? await uploader.store(traceId, key, file.bytes, file.contentType, file.digest, upload)
|
|
706
764
|
: { ok: false, reason: "budget" };
|
|
@@ -715,13 +773,20 @@ export async function offloadAttributes(attributes, collector, options) {
|
|
|
715
773
|
fallback(outcome.reason);
|
|
716
774
|
})();
|
|
717
775
|
}));
|
|
776
|
+
let placedBody;
|
|
777
|
+
if (body) {
|
|
778
|
+
const capped = capLogBody(await body, valueBytes);
|
|
779
|
+
placedBody = capped.body;
|
|
780
|
+
if (capped.cut)
|
|
781
|
+
listed.push({ order: -1, key: "body" });
|
|
782
|
+
}
|
|
718
783
|
if (listed.length)
|
|
719
784
|
result[TRUNCATED_KEY] = withTruncatedKeys(result[TRUNCATED_KEY], listed.sort((a, b) => a.order - b.order).map(({ key }) => key));
|
|
720
785
|
if (entries.length) {
|
|
721
786
|
entries.sort((a, b) => a.order[0] - b.order[0] || a.order[1] - b.order[1]);
|
|
722
787
|
result[BLOBS_KEY] = [...existing, ...entries.map(({ entry }) => entry)];
|
|
723
788
|
}
|
|
724
|
-
return result;
|
|
789
|
+
return { attributes: result, ...(collector.body ? { body: placedBody } : {}) };
|
|
725
790
|
}
|
|
726
791
|
/**
|
|
727
792
|
* The span's own attributes with every candidate placed as it was before uploads existed, for an
|
|
@@ -730,8 +795,21 @@ export async function offloadAttributes(attributes, collector, options) {
|
|
|
730
795
|
* `hue.truncated`.
|
|
731
796
|
*/
|
|
732
797
|
export function fallbackAttributes(attributes, collector, valueBytes, tally, traceId, memo) {
|
|
798
|
+
return fallbackRecord(attributes, collector, valueBytes, tally, traceId, memo).attributes;
|
|
799
|
+
}
|
|
800
|
+
/** A record's attributes, and a log record's body, placed as {@link fallbackAttributes} places
|
|
801
|
+
* them: a log record's files keep their bytes inline, and its body over the inline limit is
|
|
802
|
+
* replaced by the receiver's marker, as before. */
|
|
803
|
+
export function fallbackRecord(attributes, collector, valueBytes, tally, traceId, memo) {
|
|
733
804
|
const result = { ...attributes };
|
|
734
805
|
const listed = [];
|
|
806
|
+
let body;
|
|
807
|
+
if (collector.body) {
|
|
808
|
+
const capped = capLogBody(collector.body.value, valueBytes);
|
|
809
|
+
body = capped.body;
|
|
810
|
+
if (capped.cut)
|
|
811
|
+
listed.push("body");
|
|
812
|
+
}
|
|
735
813
|
for (const _file of collector.attachments ?? [])
|
|
736
814
|
countFallback(tally, "failed", traceId);
|
|
737
815
|
for (let index = 0; index < collector.cut.length; index++)
|
|
@@ -741,7 +819,11 @@ export function fallbackAttributes(attributes, collector, valueBytes, tally, tra
|
|
|
741
819
|
// Each large inline file is reported as a value not uploaded, as the upload step reports it.
|
|
742
820
|
const count = { files: 0 };
|
|
743
821
|
try {
|
|
744
|
-
|
|
822
|
+
// A log record's files stay inline, its messages' files over the inline limit as their
|
|
823
|
+
// digest, as nested messages kept them before log records uploaded.
|
|
824
|
+
if (collector.log)
|
|
825
|
+
placed = hashNestedInlineFiles(key, value, count);
|
|
826
|
+
else if (!collector.inspected || collector.inspected.has(key))
|
|
745
827
|
placed = hashInlineFiles(key, value, count, memo, traceId);
|
|
746
828
|
}
|
|
747
829
|
catch {
|
|
@@ -749,21 +831,24 @@ export function fallbackAttributes(attributes, collector, valueBytes, tally, tra
|
|
|
749
831
|
}
|
|
750
832
|
for (let file = 0; file < count.files; file++)
|
|
751
833
|
countFallback(tally, "failed", traceId);
|
|
834
|
+
// A log record's value over the limit is cut whether or not uploads work.
|
|
752
835
|
if (typeof placed === "string") {
|
|
753
836
|
if (Buffer.byteLength(placed, "utf8") > valueBytes) {
|
|
754
837
|
placed = truncateUtf8(placed, valueBytes);
|
|
755
838
|
listed.push(key);
|
|
756
|
-
|
|
839
|
+
if (!collector.log)
|
|
840
|
+
countFallback(tally, "failed", traceId);
|
|
757
841
|
}
|
|
758
842
|
}
|
|
759
843
|
else if (placed instanceof Uint8Array && placed.byteLength > valueBytes) {
|
|
760
844
|
placed = truncatedMarker(placed.byteLength);
|
|
761
845
|
listed.push(key);
|
|
762
|
-
|
|
846
|
+
if (!collector.log)
|
|
847
|
+
countFallback(tally, "failed", traceId);
|
|
763
848
|
}
|
|
764
849
|
result[key] = placed;
|
|
765
850
|
}
|
|
766
851
|
if (listed.length)
|
|
767
852
|
result[TRUNCATED_KEY] = withTruncatedKeys(result[TRUNCATED_KEY], listed);
|
|
768
|
-
return result;
|
|
853
|
+
return { attributes: result, ...(collector.body ? { body } : {}) };
|
|
769
854
|
}
|
|
@@ -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>;
|