@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 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.1`, `@opentelemetry/api@1.9.1` and
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.1 import langfuse --env-path .env.langfuse --from 2026-01-01 --dry-run
383
- npx -y @hue-run/sdk@0.18.1 import langfuse --env-path .env.langfuse --from 2026-01-01 --media
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.1.tgz \
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.1.tgz \
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.1 eval --case "Refund an eligible charge" --command "node agent.js"
648
- npx -y @hue-run/sdk@0.18.1 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.1 eval --set "Billing regressions" --scorer-version <id> --command "node agent.js" --save-version
650
- npx -y @hue-run/sdk@0.18.1 eval --worker --command "node agent.js" --agent-key support-agent --revision <new-agent-revision>
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.1 eval --case "Refund an eligible charge" ./hue-agent.ts --content
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; a longer value uses the ordinary value limit. Inspection parses the
316
- value and decodes each file while the record is queued and again when it is exported, holding
317
- several copies of the value at once; larger files belong in an `Attachment` or in
318
- `hue.recordFile` with `upload` set, whose bytes are not parsed out of text. Resources, links and
319
- span events never upload inline media: in their recorded messages, media up to 64 KiB stays
320
- verbatim and larger media is replaced by its digest, as in 0.16, and log records that cannot
321
- upload (not correlated with a span, or while uploads are unavailable) keep the same rule. With
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` (read from the file system when the attachment is created), a `contentType` and
349
- optionally a `name`. The bytes are read and hashed when the attachment is created and are limited
350
- to 25 MiB, the `recordFile` limit: they are held in memory from then until export, within the
351
- 128 MiB budget that queued spans share for values awaiting upload.
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. Larger data is omitted and counted as an instrumentation failure;
396
- pass `sha256` and `byteSize` when you already have them. Upload requires supplied bytes, content
397
- capture and an ordinary project key. It runs during export and adds a `hue.blobs` entry to the
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.1 import langfuse --env-path .env.langfuse --from 2026-01-01 --dry-run
501
- npx -y @hue-run/sdk@0.18.1 import langfuse --env-path .env.langfuse --from 2026-01-01 --media
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; a
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. The explicit helper limit is 25 MiB; whole attributes retain their 1 GB limit. Each
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
@@ -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 when the attachment is created (Node.js file system). */
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 an attachment that could not be read or is invalid. */
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` and `path` are limited to
36
- * 25 MiB. Creating one never throws: an attachment without exactly one of `data` and `path`, with
37
- * unreadable data, larger data or an invalid `contentType` records `null` where it is placed and
38
- * counts an instrumentation failure; an invalid `name` alone is omitted and counted.
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. */
@@ -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
- /** A file's bytes, read through one descriptor and never past the limit. */
23
- function fileBytes(path) {
24
- const descriptor = openSync(path, "r");
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
- if (fstatSync(descriptor).size > MAX_FILE_DATA_BYTES)
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
- // 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;
49
+ const hash = createHash("sha256");
50
+ const slice = Buffer.allocUnsafe(FILE_SLICE);
51
+ let size = 0;
32
52
  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);
53
+ const read = readSync(descriptor, slice, 0, FILE_SLICE, null);
38
54
  if (read === 0)
39
55
  break;
40
- parts.push(buffer.subarray(0, read));
41
- total += read;
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 Buffer.concat(parts, total);
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 path === "string")
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` 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.
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 bytes;
205
+ let read;
101
206
  try {
102
- bytes = isMediaContentType(contentType) ? attachmentBytes(options) : undefined;
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
- bytes = undefined;
211
+ read = undefined;
107
212
  }
108
- const sha256 = bytes ? createHash("sha256").update(bytes).digest("hex") : undefined;
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 (!state.bytes || !state.sha256)
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.bytes.byteLength,
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: Uint8Array;
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
- export declare function digestOf(value: string | Uint8Array): Promise<{
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