@hue-run/sdk 0.18.1 → 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 +10 -10
- package/README.md +45 -18
- package/dist/attachment.d.ts +51 -7
- package/dist/attachment.js +141 -33
- package/dist/blobs.d.ts +22 -5
- package/dist/blobs.js +186 -71
- package/dist/client.d.ts +9 -4
- package/dist/client.js +46 -15
- package/dist/inline-files.d.ts +5 -4
- package/dist/inline-files.js +6 -5
- package/dist/json-edits.js +26 -7
- package/dist/large-media.d.ts +106 -0
- package/dist/large-media.js +414 -0
- package/dist/snapshot.d.ts +2 -0
- package/dist/snapshot.js +19 -1
- package/dist/transport.d.ts +1 -0
- package/dist/transport.js +25 -15
- 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/CLI.md
CHANGED
|
@@ -42,7 +42,7 @@ project manifest are refused because managers can update ancestor locks; Python
|
|
|
42
42
|
The generated `hue.setup.mjs` or `hue_setup.py` always selects `captureContent: false` /
|
|
43
43
|
`capture_content=False`. For a supported application, setup installs the dependency and adds the
|
|
44
44
|
managed import and middleware registration to the existing entrypoint; an unreferenced helper is
|
|
45
|
-
not a completed integration. TypeScript uses `@hue-run/sdk@0.18.
|
|
45
|
+
not a completed integration. TypeScript uses `@hue-run/sdk@0.18.2`, `@opentelemetry/api@1.9.1` and
|
|
46
46
|
`@opentelemetry/context-async-hooks@2.11.0`; Python setup uses its separately tested package pin.
|
|
47
47
|
Content capture requires an ordinary account-managed key and a later explicit application decision.
|
|
48
48
|
|
|
@@ -379,8 +379,8 @@ and Hue never receives or stores them. The command is part of the `hue` executab
|
|
|
379
379
|
# LANGFUSE_PUBLIC_KEY=pk-lf-...
|
|
380
380
|
# LANGFUSE_SECRET_KEY=sk-lf-...
|
|
381
381
|
# LANGFUSE_BASE_URL=https://cloud.langfuse.com
|
|
382
|
-
npx -y @hue-run/sdk@0.18.
|
|
383
|
-
npx -y @hue-run/sdk@0.18.
|
|
382
|
+
npx -y @hue-run/sdk@0.18.2 import langfuse --env-path .env.langfuse --from 2026-01-01 --dry-run
|
|
383
|
+
npx -y @hue-run/sdk@0.18.2 import langfuse --env-path .env.langfuse --from 2026-01-01 --media
|
|
384
384
|
```
|
|
385
385
|
|
|
386
386
|
`LANGFUSE_PUBLIC_KEY` and `LANGFUSE_SECRET_KEY` come from the environment or `--env-path`
|
|
@@ -591,7 +591,7 @@ node packages/sdk-typescript/scripts/verify-package.mjs --artifacts-dir .artifac
|
|
|
591
591
|
# Set project to an existing supported fixture; use the same directory on resume.
|
|
592
592
|
project=/absolute/path/to/supported-fixture
|
|
593
593
|
node packages/sdk-typescript/scripts/verify-setup-live.mjs \
|
|
594
|
-
--archive .artifacts/typescript/hue-run-sdk-0.18.
|
|
594
|
+
--archive .artifacts/typescript/hue-run-sdk-0.18.2.tgz \
|
|
595
595
|
--origin https://STAGING_ORIGIN \
|
|
596
596
|
--project "$project" --command setup \
|
|
597
597
|
--evidence .context/setup-staging-before-claim.json
|
|
@@ -602,7 +602,7 @@ the private local handoff and finish the real browser claim, then reconcile the
|
|
|
602
602
|
|
|
603
603
|
```sh
|
|
604
604
|
node packages/sdk-typescript/scripts/verify-setup-live.mjs \
|
|
605
|
-
--archive .artifacts/typescript/hue-run-sdk-0.18.
|
|
605
|
+
--archive .artifacts/typescript/hue-run-sdk-0.18.2.tgz \
|
|
606
606
|
--origin https://STAGING_ORIGIN \
|
|
607
607
|
--project "$project" --command claim \
|
|
608
608
|
--evidence .context/setup-staging-after-claim.json
|
|
@@ -644,10 +644,10 @@ otherwise. Never hand the agent Hue-native tools (`context.tools`, `context.mcp`
|
|
|
644
644
|
production agent lacks. Pin the SDK, so the agent's repository gains no dependency:
|
|
645
645
|
|
|
646
646
|
```sh
|
|
647
|
-
npx -y @hue-run/sdk@0.18.
|
|
648
|
-
npx -y @hue-run/sdk@0.18.
|
|
649
|
-
npx -y @hue-run/sdk@0.18.
|
|
650
|
-
npx -y @hue-run/sdk@0.18.
|
|
647
|
+
npx -y @hue-run/sdk@0.18.2 eval --case "Refund an eligible charge" --command "node agent.js"
|
|
648
|
+
npx -y @hue-run/sdk@0.18.2 eval --case https://app.hue.run/projects/demo/scenarios/<id> --command "python agent.py" --timeout 120 --baseline <run id>
|
|
649
|
+
npx -y @hue-run/sdk@0.18.2 eval --set "Billing regressions" --scorer-version <id> --command "node agent.js" --save-version
|
|
650
|
+
npx -y @hue-run/sdk@0.18.2 eval --worker --command "node agent.js" --agent-key support-agent --revision <new-agent-revision>
|
|
651
651
|
```
|
|
652
652
|
|
|
653
653
|
One invocation creates one run holding every selected case; it prints the run URL
|
|
@@ -685,7 +685,7 @@ export default function runMyAgent(inputs: JsonValue, context: SimulationTargetC
|
|
|
685
685
|
```
|
|
686
686
|
|
|
687
687
|
```sh
|
|
688
|
-
npx -y @hue-run/sdk@0.18.
|
|
688
|
+
npx -y @hue-run/sdk@0.18.2 eval --case "Refund an eligible charge" ./hue-agent.ts --content
|
|
689
689
|
```
|
|
690
690
|
|
|
691
691
|
`--scenario` remains an alias for `--case` for existing scripts. Pass one selection flag.
|
package/README.md
CHANGED
|
@@ -312,13 +312,23 @@ listed under `hue.truncated`. While uploads are available, `recordMessages` reco
|
|
|
312
312
|
the value limit when it fits the limit with its files as their digest.
|
|
313
313
|
|
|
314
314
|
Inspection is bounded to 8 Mi UTF-16 code units of text per attribute and 16 Mi per record, a log
|
|
315
|
-
body's file contents included
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
315
|
+
body's file contents included. Within those bounds, inspection parses the value and decodes each
|
|
316
|
+
file while the record is queued and again when it is exported. A span's own attribute past either
|
|
317
|
+
bound that is held whole for upload (it is over the value limit) has its media read in place when
|
|
318
|
+
it is exported, as a voice agent's audio usually needs: each string in it longer than 16 Ki code
|
|
319
|
+
units and written without escapes as strict base64 or a base64 `data:` URL is decoded, hashed and
|
|
320
|
+
uploaded a 1 MiB window at a time from the recorded text, never parsed or copied out of it, and a
|
|
321
|
+
file may be up to Hue's 1 GB upload limit. The scan that finds those strings also reads the value
|
|
322
|
+
a window at a time, between turns of the event loop, so no step of it stalls the application for
|
|
323
|
+
long; a percent-escaped `data:` URL is parsed with the JSON around it. The value is placed exactly as one within the bounds: the same
|
|
324
|
+
digest form, entries and fallbacks. The JSON around those strings, a file written with escapes or
|
|
325
|
+
inside a JSON-encoded string included, is parsed within the 8 Mi bound; a value past it uses the
|
|
326
|
+
ordinary value limit (it uploads whole). Each such value is read within that bound on its own, apart
|
|
327
|
+
from the record's 16 Mi allowance, while it stays held within the 128 MiB for values awaiting
|
|
328
|
+
upload. Log records keep the bounds. Resources, links and span
|
|
329
|
+
events never upload inline media: in their recorded messages, media up to 64 KiB stays verbatim
|
|
330
|
+
and larger media is replaced by its digest, as in 0.16, and log records that cannot upload (not
|
|
331
|
+
correlated with a span, or while uploads are unavailable) keep the same rule. With
|
|
322
332
|
`captureContent: false`, nothing is inspected or uploaded.
|
|
323
333
|
|
|
324
334
|
### Attach a file anywhere
|
|
@@ -345,10 +355,19 @@ await hue.withSpan(
|
|
|
345
355
|
```
|
|
346
356
|
|
|
347
357
|
Give exactly one of `data` (a `Buffer`, typed array, `ArrayBuffer`, or a base64 string or `data:`
|
|
348
|
-
URL) and `path` (
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
358
|
+
URL) and `path` (a file on the file system), a `contentType` and optionally a `name`. `data` is
|
|
359
|
+
read and hashed when the attachment is created and is limited to 25 MiB, the `recordFile` limit:
|
|
360
|
+
it is held in memory from then until export, within the 128 MiB budget that queued spans share for
|
|
361
|
+
values awaiting upload. A `path` names a regular file: anything else, such as a named pipe, is
|
|
362
|
+
refused without waiting on it. A `path` up to 25 MiB is read the same way. A larger regular file, up to
|
|
363
|
+
Hue's 1 GB upload limit, is not held: it is hashed a 1 MiB slice at a time when the attachment is
|
|
364
|
+
created and read again from disk when it is uploaded, each slice hashed as it is sent and the last
|
|
365
|
+
sent only once the file ended at the size and SHA-256 it was recorded with. Keep such a file in
|
|
366
|
+
place and unchanged until the client flushes: one that changed, is gone or is no longer a regular
|
|
367
|
+
file is not uploaded (the
|
|
368
|
+
request ends before its body is complete, and Hue's upload URL signs the size and SHA-256, so the
|
|
369
|
+
store refuses other bytes), and it counts in `report.uploadFallbacks`, its part keeping the digest
|
|
370
|
+
it was recorded with.
|
|
352
371
|
|
|
353
372
|
At its position the attachment is recorded as a GenAI `blob` part that names the file by digest,
|
|
354
373
|
`{"type":"blob","mime_type":"application/pdf","modality":"document","sha256":"<64 hex>","size":48213,"name":"contract.pdf"}`,
|
|
@@ -392,9 +411,14 @@ await hue.withSpan("review contract", async () => {
|
|
|
392
411
|
```
|
|
393
412
|
|
|
394
413
|
`upload` defaults to `false`, which hashes and measures `data` locally without exporting bytes.
|
|
395
|
-
`data` is limited to 25 MiB
|
|
396
|
-
pass `sha256` and `byteSize` when you already have them.
|
|
397
|
-
|
|
414
|
+
`data` is limited to 25 MiB, as it is held in memory until it is uploaded. Larger data is omitted
|
|
415
|
+
and counted as an instrumentation failure; pass `sha256` and `byteSize` when you already have them.
|
|
416
|
+
Give `path` instead of `data` for a file on disk: up to 25 MiB it is read as `data` is, and a larger
|
|
417
|
+
regular file, up to Hue's 1 GB upload limit, is hashed a slice at a time now and, with `upload`,
|
|
418
|
+
read again from disk when it is uploaded, as an [`Attachment`](#attach-a-file-anywhere) given by
|
|
419
|
+
path is: it uploads only if it still has the size and SHA-256 it was recorded with. A file over
|
|
420
|
+
1 GB, a path that is no regular file, or `data` and `path` together, is omitted and counted. Upload requires supplied bytes or a
|
|
421
|
+
path, content capture and an ordinary project key. It runs during export and adds a `hue.blobs` entry to the
|
|
398
422
|
same span, keyed `event:hue.file:<sha256>`, while preserving the event. An export fits every
|
|
399
423
|
span's events and entries before it uploads any of these files, then uploads the files of all its
|
|
400
424
|
spans together, four at a time; a file whose event was redacted or shed is not uploaded.
|
|
@@ -497,8 +521,8 @@ the Langfuse project's own keys (`LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, a
|
|
|
497
521
|
`HUE_API_KEY`:
|
|
498
522
|
|
|
499
523
|
```sh
|
|
500
|
-
npx -y @hue-run/sdk@0.18.
|
|
501
|
-
npx -y @hue-run/sdk@0.18.
|
|
524
|
+
npx -y @hue-run/sdk@0.18.2 import langfuse --env-path .env.langfuse --from 2026-01-01 --dry-run
|
|
525
|
+
npx -y @hue-run/sdk@0.18.2 import langfuse --env-path .env.langfuse --from 2026-01-01 --media
|
|
502
526
|
```
|
|
503
527
|
|
|
504
528
|
- It reads Langfuse Cloud and self-hosted v4 through the Observations API v2, and self-hosted v3
|
|
@@ -824,7 +848,9 @@ apart from their record, up to Hue's upload limit of 1,000,000,000 bytes:
|
|
|
824
848
|
|
|
825
849
|
Hue stores the value under the trace, so the trace's retention, deletion and erasure cover it.
|
|
826
850
|
Uploads run in the export, never on the application's thread: at most four at a time, each request
|
|
827
|
-
within the export timeout, and all of one export's uploads within six export timeouts
|
|
851
|
+
within the export timeout, and all of one export's uploads within six export timeouts (60 seconds
|
|
852
|
+
at the default `timeoutMillis` of 10000), so a 1 GB file needs about 17 MB/s at the defaults;
|
|
853
|
+
raise `timeoutMillis` (up to 60000) for slower links. A
|
|
828
854
|
transient failure is retried once. A rate-limited reservation (429) waits out its `Retry-After`
|
|
829
855
|
within the export's 60-second hold. `flush()` and `shutdown()` wait for uploads like any export
|
|
830
856
|
work, and a `shutdownSafe()` whose budget runs out ends the uploads in flight.
|
|
@@ -833,7 +859,8 @@ A value that cannot be uploaded (Hue or the store refused it or failed, the netw
|
|
|
833
859
|
did, the receiver has no upload route, or the value is over 1 GB) is exported as before uploads
|
|
834
860
|
existed: whole attributes keep a prefix up to the limit and are listed under `hue.truncated`;
|
|
835
861
|
media parts keep their digest without a blob entry; failed opt-in `recordFile` uploads preserve
|
|
836
|
-
the file event.
|
|
862
|
+
the file event. Bytes given in memory to `recordFile` or an `Attachment` are limited to 25 MiB; a
|
|
863
|
+
file given by path, a file read in place from a value and a whole attribute to 1 GB. Each
|
|
837
864
|
export reports such values in a `warning` issue per cause, whose `count` is the values and
|
|
838
865
|
`traceIds` their traces, and counts them in `report.uploadFallbacks`; successful uploads are counted
|
|
839
866
|
in `report.uploadedValues`. Both counters include whole attributes, eligible media, attachments
|
package/dist/attachment.d.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import type { ByteSource } from "./large-media.js";
|
|
1
2
|
/** How deep in a value an attachment's part may sit and still be uploaded: Hue reads media parts
|
|
2
3
|
* within ten levels of the value's root. */
|
|
3
4
|
export declare const MAX_ATTACHMENT_DEPTH = 10;
|
|
4
5
|
/** Options for {@link Attachment}. Give exactly one of `data` and `path`. */
|
|
5
6
|
export interface AttachmentOptions {
|
|
6
7
|
/** The file's bytes (a `Buffer`, typed array or `ArrayBuffer`), or a string holding them as
|
|
7
|
-
* base64 or a `data:` URL. */
|
|
8
|
+
* base64 or a `data:` URL; up to 25 MiB. */
|
|
8
9
|
data?: ArrayBufferView | ArrayBuffer | string;
|
|
9
|
-
/** A file to read
|
|
10
|
+
/** A file to read (Node.js file system): up to 25 MiB it is read when the attachment is created;
|
|
11
|
+
* a larger regular file, up to Hue's 1 GB upload limit, is hashed then and read again from disk
|
|
12
|
+
* when it is uploaded. */
|
|
10
13
|
path?: string;
|
|
11
14
|
/** The file's media type, such as `image/png` or `application/pdf`. */
|
|
12
15
|
contentType: string;
|
|
@@ -15,15 +18,50 @@ export interface AttachmentOptions {
|
|
|
15
18
|
}
|
|
16
19
|
/** What an attachment holds, read by the client and transport only. */
|
|
17
20
|
export interface AttachmentState {
|
|
18
|
-
/** The file's bytes; undefined for
|
|
21
|
+
/** The file's bytes; undefined for a file read from disk when it is uploaded, and for an
|
|
22
|
+
* attachment that could not be read or is invalid. */
|
|
19
23
|
bytes?: Uint8Array;
|
|
24
|
+
/** A file given by path over 25 MiB, read again from disk when it is uploaded. */
|
|
25
|
+
file?: PathFile;
|
|
20
26
|
sha256?: string;
|
|
27
|
+
/** The file's size; undefined for an invalid attachment. */
|
|
28
|
+
size?: number;
|
|
21
29
|
contentType: string;
|
|
22
30
|
/** The name, when it is a valid media name. */
|
|
23
31
|
name?: string;
|
|
24
32
|
/** A name was given and is not a valid media name: it is omitted and counted. */
|
|
25
33
|
invalidName: boolean;
|
|
26
34
|
}
|
|
35
|
+
/** A regular file given by path over the in-memory limit, as it was hashed when it was recorded:
|
|
36
|
+
* its absolute path, size and SHA-256. Its bytes are read again from disk when it is uploaded. */
|
|
37
|
+
export interface PathFile {
|
|
38
|
+
path: string;
|
|
39
|
+
size: number;
|
|
40
|
+
sha256: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A regular file given by path: its bytes when it is no larger than 25 MiB, and otherwise, up to
|
|
44
|
+
* Hue's 1 GB upload limit, its size and SHA-256 from one reading pass a slice at a time, never
|
|
45
|
+
* holding it whole. Undefined for a file over its limit and for anything that is no regular file
|
|
46
|
+
* (a named pipe, a device), which is opened without waiting and never read; throws for one that
|
|
47
|
+
* cannot be opened.
|
|
48
|
+
*/
|
|
49
|
+
export declare function readPath(path: string): {
|
|
50
|
+
bytes: Uint8Array;
|
|
51
|
+
} | {
|
|
52
|
+
file: PathFile;
|
|
53
|
+
} | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* A file given by path as an upload body, read from disk a slice at a time. Each slice is hashed as
|
|
56
|
+
* it is read and sent once the next one is read; the last is sent only once the file ended at its
|
|
57
|
+
* recorded size with its recorded SHA-256. A file that changed since it was recorded, or can no
|
|
58
|
+
* longer be read, ends the read with an error before its body is complete and sets `changed`, so
|
|
59
|
+
* the upload is abandoned and the value is counted as not uploaded; so does a path that is no
|
|
60
|
+
* longer a regular file, which is opened without waiting (a named pipe in its place would wait for
|
|
61
|
+
* a writer) and never read. Hue's presigned PUT also signs the size and SHA-256, so the store
|
|
62
|
+
* refuses a body that differs.
|
|
63
|
+
*/
|
|
64
|
+
export declare function fileSource(file: PathFile): ByteSource;
|
|
27
65
|
/**
|
|
28
66
|
* A file placed in a recorded value, the equivalent of Langfuse's `LangfuseMedia` and
|
|
29
67
|
* Braintrust's `Attachment`. Put it anywhere in a `withSpan()` input or output, in `model()`
|
|
@@ -32,10 +70,16 @@ export interface AttachmentState {
|
|
|
32
70
|
* names the file by digest (`{ type: "blob", mime_type, modality, sha256, size, name }`) and
|
|
33
71
|
* uploads its bytes during export, listed under the record's `hue.blobs`.
|
|
34
72
|
*
|
|
35
|
-
* The bytes are read and hashed when the attachment is created; `data`
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
73
|
+
* The bytes are read and hashed when the attachment is created; `data` is limited to 25 MiB, as
|
|
74
|
+
* the bytes are held in memory until they are uploaded. A `path` names a regular file, opened
|
|
75
|
+
* without waiting: anything else, such as a named pipe, is invalid. One up to 25 MiB is read then
|
|
76
|
+
* too; a larger regular file, up to Hue's 1 GB upload limit, is hashed then a slice at a time and read
|
|
77
|
+
* again from disk when it is uploaded, so it must stay in place and unchanged until the client
|
|
78
|
+
* flushes: a file whose size or SHA-256 then differs is not uploaded and counts as an upload
|
|
79
|
+
* fallback, its part keeping the digest it was recorded with. Creating one never throws: an
|
|
80
|
+
* attachment without exactly one of `data` and `path`, with unreadable data, larger data or an
|
|
81
|
+
* invalid `contentType` records `null` where it is placed and counts an instrumentation failure;
|
|
82
|
+
* an invalid `name` alone is omitted and counted.
|
|
39
83
|
*/
|
|
40
84
|
export declare class Attachment {
|
|
41
85
|
/** The media type given. */
|
package/dist/attachment.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
2
|
+
import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
|
|
3
|
+
import { open } from "node:fs/promises";
|
|
4
|
+
import { resolve } from "node:path";
|
|
3
5
|
import { types as utilTypes } from "node:util";
|
|
6
|
+
import { MAX_BLOB_BYTES } from "./blobs.js";
|
|
4
7
|
import { MAX_FILE_DATA_BYTES } from "./config.js";
|
|
5
8
|
import { inlineFileBytes, isMediaContentType, isMediaName } from "./inline-files.js";
|
|
6
9
|
/** How deep in a value an attachment's part may sit and still be uploaded: Hue reads media parts
|
|
@@ -19,41 +22,137 @@ function stringBytes(text) {
|
|
|
19
22
|
return undefined;
|
|
20
23
|
return Buffer.from(text, "base64");
|
|
21
24
|
}
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
/** How much of a file given by path is read at once, to hash it or to upload it. */
|
|
26
|
+
const FILE_SLICE = 1024 * 1024;
|
|
27
|
+
/**
|
|
28
|
+
* A regular file given by path: its bytes when it is no larger than 25 MiB, and otherwise, up to
|
|
29
|
+
* Hue's 1 GB upload limit, its size and SHA-256 from one reading pass a slice at a time, never
|
|
30
|
+
* holding it whole. Undefined for a file over its limit and for anything that is no regular file
|
|
31
|
+
* (a named pipe, a device), which is opened without waiting and never read; throws for one that
|
|
32
|
+
* cannot be opened.
|
|
33
|
+
*/
|
|
34
|
+
export function readPath(path) {
|
|
35
|
+
// Resolved first: the file read now is the one read again when it is uploaded.
|
|
36
|
+
const absolute = resolve(path);
|
|
37
|
+
// Without waiting: a named pipe would block the application until something wrote to it.
|
|
38
|
+
const descriptor = openSync(absolute, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
|
|
25
39
|
try {
|
|
26
|
-
|
|
40
|
+
const info = fstatSync(descriptor);
|
|
41
|
+
if (!info.isFile())
|
|
42
|
+
return undefined;
|
|
43
|
+
if (info.size <= MAX_FILE_DATA_BYTES) {
|
|
44
|
+
const bytes = fileBytes(descriptor);
|
|
45
|
+
return bytes ? { bytes } : undefined;
|
|
46
|
+
}
|
|
47
|
+
if (info.size > MAX_BLOB_BYTES)
|
|
27
48
|
return undefined;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
let total = 0;
|
|
49
|
+
const hash = createHash("sha256");
|
|
50
|
+
const slice = Buffer.allocUnsafe(FILE_SLICE);
|
|
51
|
+
let size = 0;
|
|
32
52
|
for (;;) {
|
|
33
|
-
const
|
|
34
|
-
if (room <= 0)
|
|
35
|
-
return undefined;
|
|
36
|
-
const buffer = Buffer.alloc(room);
|
|
37
|
-
const read = readSync(descriptor, buffer, 0, room, null);
|
|
53
|
+
const read = readSync(descriptor, slice, 0, FILE_SLICE, null);
|
|
38
54
|
if (read === 0)
|
|
39
55
|
break;
|
|
40
|
-
|
|
41
|
-
|
|
56
|
+
size += read;
|
|
57
|
+
// A file that grew past the limit while it was read is refused, not read on.
|
|
58
|
+
if (size > MAX_BLOB_BYTES)
|
|
59
|
+
return undefined;
|
|
60
|
+
hash.update(slice.subarray(0, read));
|
|
42
61
|
}
|
|
43
|
-
return
|
|
62
|
+
return { file: { path: absolute, size, sha256: hash.digest("hex") } };
|
|
44
63
|
}
|
|
45
64
|
finally {
|
|
46
65
|
closeSync(descriptor);
|
|
47
66
|
}
|
|
48
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* A file given by path as an upload body, read from disk a slice at a time. Each slice is hashed as
|
|
70
|
+
* it is read and sent once the next one is read; the last is sent only once the file ended at its
|
|
71
|
+
* recorded size with its recorded SHA-256. A file that changed since it was recorded, or can no
|
|
72
|
+
* longer be read, ends the read with an error before its body is complete and sets `changed`, so
|
|
73
|
+
* the upload is abandoned and the value is counted as not uploaded; so does a path that is no
|
|
74
|
+
* longer a regular file, which is opened without waiting (a named pipe in its place would wait for
|
|
75
|
+
* a writer) and never read. Hue's presigned PUT also signs the size and SHA-256, so the store
|
|
76
|
+
* refuses a body that differs.
|
|
77
|
+
*/
|
|
78
|
+
export function fileSource(file) {
|
|
79
|
+
const source = {
|
|
80
|
+
size: file.size,
|
|
81
|
+
async *chunks() {
|
|
82
|
+
// Each read starts fresh: a retried upload reads the file again.
|
|
83
|
+
source.changed = false;
|
|
84
|
+
const changed = () => {
|
|
85
|
+
source.changed = true;
|
|
86
|
+
return new Error("The file changed after it was recorded");
|
|
87
|
+
};
|
|
88
|
+
const handle = await open(file.path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0)).catch(() => {
|
|
89
|
+
throw changed();
|
|
90
|
+
});
|
|
91
|
+
try {
|
|
92
|
+
const info = await handle.stat().catch(() => undefined);
|
|
93
|
+
if (!info?.isFile())
|
|
94
|
+
throw changed();
|
|
95
|
+
const hash = createHash("sha256");
|
|
96
|
+
let size = 0;
|
|
97
|
+
let pending;
|
|
98
|
+
// Two buffers in turn: the slice withheld until the next is read, and the next. A reader
|
|
99
|
+
// is done with a slice before it asks for the next, so each buffer is free when reused.
|
|
100
|
+
const buffers = [Buffer.allocUnsafe(FILE_SLICE), Buffer.allocUnsafe(FILE_SLICE)];
|
|
101
|
+
for (let turn = 0;; turn ^= 1) {
|
|
102
|
+
const slice = buffers[turn];
|
|
103
|
+
const { bytesRead } = await handle.read(slice, 0, FILE_SLICE, null).catch(() => {
|
|
104
|
+
throw changed();
|
|
105
|
+
});
|
|
106
|
+
if (bytesRead === 0)
|
|
107
|
+
break;
|
|
108
|
+
size += bytesRead;
|
|
109
|
+
if (size > file.size)
|
|
110
|
+
throw changed();
|
|
111
|
+
const read = slice.subarray(0, bytesRead);
|
|
112
|
+
hash.update(read);
|
|
113
|
+
if (pending)
|
|
114
|
+
yield pending;
|
|
115
|
+
pending = read;
|
|
116
|
+
}
|
|
117
|
+
if (size !== file.size || hash.digest("hex") !== file.sha256)
|
|
118
|
+
throw changed();
|
|
119
|
+
if (pending)
|
|
120
|
+
yield pending;
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
await handle.close().catch(() => undefined);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
return source;
|
|
128
|
+
}
|
|
129
|
+
/** A file's bytes, read through one descriptor and never past the in-memory limit. */
|
|
130
|
+
function fileBytes(descriptor) {
|
|
131
|
+
// Read to its end, which may lie past its size as stated (a file that grew), but never more
|
|
132
|
+
// than one byte past the limit.
|
|
133
|
+
const parts = [];
|
|
134
|
+
let total = 0;
|
|
135
|
+
for (;;) {
|
|
136
|
+
const room = Math.min(1 << 20, MAX_FILE_DATA_BYTES + 1 - total);
|
|
137
|
+
if (room <= 0)
|
|
138
|
+
return undefined;
|
|
139
|
+
const buffer = Buffer.alloc(room);
|
|
140
|
+
const read = readSync(descriptor, buffer, 0, room, null);
|
|
141
|
+
if (read === 0)
|
|
142
|
+
break;
|
|
143
|
+
parts.push(buffer.subarray(0, read));
|
|
144
|
+
total += read;
|
|
145
|
+
}
|
|
146
|
+
return Buffer.concat(parts, total);
|
|
147
|
+
}
|
|
49
148
|
function attachmentBytes(options) {
|
|
50
149
|
const { data, path } = options;
|
|
51
150
|
if ((data === undefined) === (path === undefined))
|
|
52
151
|
return undefined;
|
|
152
|
+
if (path !== undefined)
|
|
153
|
+
return typeof path === "string" && path ? readPath(path) : undefined;
|
|
53
154
|
let bytes;
|
|
54
|
-
if (typeof
|
|
55
|
-
bytes = path ? fileBytes(path) : undefined;
|
|
56
|
-
else if (typeof data === "string") {
|
|
155
|
+
if (typeof data === "string") {
|
|
57
156
|
if (data.length > 4 * MAX_FILE_DATA_BYTES)
|
|
58
157
|
return undefined;
|
|
59
158
|
bytes = stringBytes(data);
|
|
@@ -70,7 +169,7 @@ function attachmentBytes(options) {
|
|
|
70
169
|
return undefined;
|
|
71
170
|
bytes = new Uint8Array(data.slice(0));
|
|
72
171
|
}
|
|
73
|
-
return bytes && bytes.byteLength <= MAX_FILE_DATA_BYTES ? bytes : undefined;
|
|
172
|
+
return bytes && bytes.byteLength <= MAX_FILE_DATA_BYTES ? { bytes } : undefined;
|
|
74
173
|
}
|
|
75
174
|
/**
|
|
76
175
|
* A file placed in a recorded value, the equivalent of Langfuse's `LangfuseMedia` and
|
|
@@ -80,10 +179,16 @@ function attachmentBytes(options) {
|
|
|
80
179
|
* names the file by digest (`{ type: "blob", mime_type, modality, sha256, size, name }`) and
|
|
81
180
|
* uploads its bytes during export, listed under the record's `hue.blobs`.
|
|
82
181
|
*
|
|
83
|
-
* The bytes are read and hashed when the attachment is created; `data`
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
182
|
+
* The bytes are read and hashed when the attachment is created; `data` is limited to 25 MiB, as
|
|
183
|
+
* the bytes are held in memory until they are uploaded. A `path` names a regular file, opened
|
|
184
|
+
* without waiting: anything else, such as a named pipe, is invalid. One up to 25 MiB is read then
|
|
185
|
+
* too; a larger regular file, up to Hue's 1 GB upload limit, is hashed then a slice at a time and read
|
|
186
|
+
* again from disk when it is uploaded, so it must stay in place and unchanged until the client
|
|
187
|
+
* flushes: a file whose size or SHA-256 then differs is not uploaded and counts as an upload
|
|
188
|
+
* fallback, its part keeping the digest it was recorded with. Creating one never throws: an
|
|
189
|
+
* attachment without exactly one of `data` and `path`, with unreadable data, larger data or an
|
|
190
|
+
* invalid `contentType` records `null` where it is placed and counts an instrumentation failure;
|
|
191
|
+
* an invalid `name` alone is omitted and counted.
|
|
87
192
|
*/
|
|
88
193
|
export class Attachment {
|
|
89
194
|
/** The media type given. */
|
|
@@ -97,21 +202,24 @@ export class Attachment {
|
|
|
97
202
|
constructor(options) {
|
|
98
203
|
const contentType = options?.contentType;
|
|
99
204
|
const name = options?.name;
|
|
100
|
-
let
|
|
205
|
+
let read;
|
|
101
206
|
try {
|
|
102
|
-
|
|
207
|
+
read = isMediaContentType(contentType) ? attachmentBytes(options) : undefined;
|
|
103
208
|
}
|
|
104
209
|
catch {
|
|
105
210
|
// An unreadable path, or data that is not bytes: the attachment is invalid.
|
|
106
|
-
|
|
211
|
+
read = undefined;
|
|
107
212
|
}
|
|
108
|
-
const
|
|
213
|
+
const bytes = read && "bytes" in read ? read.bytes : undefined;
|
|
214
|
+
const file = read && "file" in read ? read.file : undefined;
|
|
215
|
+
const sha256 = bytes ? createHash("sha256").update(bytes).digest("hex") : file?.sha256;
|
|
109
216
|
this.contentType = typeof contentType === "string" ? contentType : "";
|
|
110
217
|
this.name = isMediaName(name) ? name : undefined;
|
|
111
218
|
this.sha256 = sha256;
|
|
112
|
-
this.size = bytes?.byteLength;
|
|
219
|
+
this.size = bytes?.byteLength ?? file?.size;
|
|
113
220
|
states.set(this, {
|
|
114
|
-
...(bytes ? { bytes, sha256 } : {}),
|
|
221
|
+
...(bytes ? { bytes, sha256, size: bytes.byteLength } : {}),
|
|
222
|
+
...(file ? { file, sha256, size: file.size } : {}),
|
|
115
223
|
contentType: this.contentType,
|
|
116
224
|
...(this.name === undefined ? {} : { name: this.name }),
|
|
117
225
|
invalidName: name !== undefined && name !== null && this.name === undefined,
|
|
@@ -144,14 +252,14 @@ export function modalityOf(contentType) {
|
|
|
144
252
|
/** An attachment's digest-form GenAI part, the name only with content capture; undefined for an
|
|
145
253
|
* invalid attachment. */
|
|
146
254
|
export function attachmentPart(state, captureContent) {
|
|
147
|
-
if (
|
|
255
|
+
if (state.size === undefined || !state.sha256)
|
|
148
256
|
return undefined;
|
|
149
257
|
return {
|
|
150
258
|
type: "blob",
|
|
151
259
|
mime_type: state.contentType,
|
|
152
260
|
modality: modalityOf(state.contentType),
|
|
153
261
|
sha256: state.sha256,
|
|
154
|
-
size: state.
|
|
262
|
+
size: state.size,
|
|
155
263
|
...(captureContent && state.name !== undefined ? { name: state.name } : {}),
|
|
156
264
|
};
|
|
157
265
|
}
|
package/dist/blobs.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type HrTime } from "@opentelemetry/api";
|
|
2
2
|
import { type MediaDigestMemo, type FileDigest } from "./inline-files.js";
|
|
3
|
+
import { type ByteSource } from "./large-media.js";
|
|
3
4
|
/**
|
|
4
5
|
* Large values and binary media travel apart from their span through reservation, presigned PUT,
|
|
5
6
|
* and completion. Text keeps a 16 KiB prefix. Binary media keeps digest metadata on its part.
|
|
@@ -43,6 +44,9 @@ export interface OffloadCollector {
|
|
|
43
44
|
* export inspects only these. Absent for a record admission did not copy, and for a log record,
|
|
44
45
|
* which export inspects within its own allowance. */
|
|
45
46
|
inspected?: ReadonlySet<string> | undefined;
|
|
47
|
+
/** The span's own attributes admission held whole for upload past the inspection bounds: export
|
|
48
|
+
* reads their media in place (`readLargeMediaText`). */
|
|
49
|
+
large?: ReadonlySet<string> | undefined;
|
|
46
50
|
/** Set for a log record: its values over the inline limit are cut, never uploaded whole; only
|
|
47
51
|
* the media in its inspected attributes and its body upload, with the attachments placed in
|
|
48
52
|
* its body (`attachments`, keyed `body#<pointer>`). */
|
|
@@ -56,7 +60,8 @@ export interface OffloadCollector {
|
|
|
56
60
|
bodyCut?: Set<string>;
|
|
57
61
|
}
|
|
58
62
|
export interface MediaAttachment {
|
|
59
|
-
bytes
|
|
63
|
+
/** The file's bytes, or for a file given by path over the in-memory limit, its source on disk. */
|
|
64
|
+
bytes: Uint8Array | ByteSource;
|
|
60
65
|
digest: FileDigest;
|
|
61
66
|
contentType: string;
|
|
62
67
|
name?: string;
|
|
@@ -88,9 +93,14 @@ export declare function textContentType(text: string): string;
|
|
|
88
93
|
* as its own copy so the whole value is not kept alive by it. */
|
|
89
94
|
export declare function blobPrefix(value: string): string;
|
|
90
95
|
export declare function blobPrefix(value: Uint8Array): Uint8Array;
|
|
96
|
+
/** A value's size in bytes as it is uploaded: a string's UTF-8 encoding, counted, not made. */
|
|
97
|
+
export declare function sizeOf(value: string | Uint8Array | ByteSource): number;
|
|
98
|
+
/** The bytes a value holds in memory: none for a source read as it is uploaded. */
|
|
99
|
+
export declare function heldBytesOf(value: Uint8Array | ByteSource): number;
|
|
91
100
|
/** The SHA-256 (lowercase hex) and byte size of what an upload of `value` carries, yielding to
|
|
92
|
-
* the event loop between slices so a large value does not stall the application.
|
|
93
|
-
|
|
101
|
+
* the event loop between slices so a large value does not stall the application. Past
|
|
102
|
+
* `deadline`, it throws rather than read on. */
|
|
103
|
+
export declare function digestOf(value: string | Uint8Array | ByteSource, deadline?: number): Promise<{
|
|
94
104
|
sha256: string;
|
|
95
105
|
size: number;
|
|
96
106
|
}>;
|
|
@@ -102,12 +112,15 @@ export interface UploadContext {
|
|
|
102
112
|
deadline: number;
|
|
103
113
|
wait(millis: number): Promise<boolean>;
|
|
104
114
|
}
|
|
115
|
+
/** An upload's outcome; `changed` when the value's own source no longer held its bytes (a file
|
|
116
|
+
* that changed since it was recorded), which says nothing of another reference's bytes. */
|
|
105
117
|
type Outcome = {
|
|
106
118
|
ok: true;
|
|
107
119
|
contentType: string;
|
|
108
120
|
} | {
|
|
109
121
|
ok: false;
|
|
110
122
|
reason: FallbackReason;
|
|
123
|
+
changed?: boolean;
|
|
111
124
|
};
|
|
112
125
|
/** The upload route's options, from the transport's validated options. */
|
|
113
126
|
export interface UploaderOptions {
|
|
@@ -153,7 +166,7 @@ export declare class BlobUploader {
|
|
|
153
166
|
* trace is already storing shares that upload's outcome without one, so repeated files never
|
|
154
167
|
* hold the slots new files need. The caller must not hold a slot.
|
|
155
168
|
*/
|
|
156
|
-
store(traceId: string, key: string, value: string | Uint8Array, contentType: string, digest: FileDigest, upload: UploadContext): Promise<Outcome>;
|
|
169
|
+
store(traceId: string, key: string, value: string | Uint8Array | ByteSource, contentType: string, digest: FileDigest, upload: UploadContext): Promise<Outcome>;
|
|
157
170
|
private storeFresh;
|
|
158
171
|
private jsonHeaders;
|
|
159
172
|
/** Reserves the value: Hue answers that it holds it already, or a presigned PUT. A rate limit
|
|
@@ -162,7 +175,9 @@ export declare class BlobUploader {
|
|
|
162
175
|
private delay;
|
|
163
176
|
/** A 200 answer read: `exists`, or an upload whose URL and headers are usable as given. */
|
|
164
177
|
private reservation;
|
|
165
|
-
/** The PUT of the value's bytes, with exactly the headers Hue signed.
|
|
178
|
+
/** The PUT of the value's bytes, with exactly the headers Hue signed. A source that no longer
|
|
179
|
+
* holds its bytes (a file changed since it was recorded) ends the request before its body is
|
|
180
|
+
* complete, and fails the value without a retry. */
|
|
166
181
|
private put;
|
|
167
182
|
/**
|
|
168
183
|
* One HTTP request with a deadline and an idle timeout, never following a redirect, its
|
|
@@ -181,6 +196,8 @@ export interface OffloadOptions {
|
|
|
181
196
|
upload: UploadContext;
|
|
182
197
|
tally: OffloadTally;
|
|
183
198
|
memo?: MediaDigestMemo;
|
|
199
|
+
/** For tests: the length past which a long string is read in place (`LONG_STRING_UNITS`). */
|
|
200
|
+
longUnits?: number;
|
|
184
201
|
}
|
|
185
202
|
/**
|
|
186
203
|
* A span's own attributes with each candidate placed: binary media becomes a digest, while
|