@hue-run/sdk 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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 message attributes admission inspected within the record's allowance:
43
- * export inspects only these. Absent for a record admission did not copy, which export
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, largeInlineFileParts, MAX_INLINE_FILE_TEXT, } from "./inline-files.js";
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
- const parts = async (index, key, text) => {
598
- // Both bounds count UTF-16 code units, as admission does.
599
- if (!mayInlineFiles(key, text) || text.length > MAX_INLINE_FILE_TEXT)
600
- return text;
601
- if (collector.inspected) {
602
- // A message admission skipped stays as recorded.
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(found.map((file, position) => {
621
+ await Promise.all(files.map((file, position) => {
624
622
  const decoded = mediaBytes(file);
625
- if (!decoded.bytes.byteLength)
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 partKey = `${key}#${textual ? file.pointer : file.partPointer}`;
629
+ const prefix = keepsPrefix(file, textual);
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 (textual)
643
- file.owner[file.key] = blobPrefix(file.content);
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
- digestPart(file, digest);
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 ? JSON.stringify(parsed) : text;
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 = `event:hue.file:${file.digest.sha256}`;
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
- if (!collector.inspected || collector.inspected.has(key))
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
- countFallback(tally, "failed", traceId);
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
- countFallback(tally, "failed", traceId);
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
  }
package/dist/client.d.ts CHANGED
@@ -127,6 +127,24 @@ export declare class HueClient {
127
127
  /** `gen_ai.request.model` for the record; defaults to the enclosing `model()` span's value. */
128
128
  model?: string;
129
129
  }, explicitContext?: Context): void;
130
+ /** Writes each attachment as its digest part, `null` for an invalid one, collecting them. */
131
+ private placer;
132
+ /**
133
+ * The files of the attachments a value placed that are uploaded: an invalid attachment, and
134
+ * an invalid name with content capture, count as instrumentation failures; nothing is uploaded
135
+ * without content capture or with setup credentials; a value that was `cut`, or a part deeper
136
+ * than Hue reads, keeps its digest part and counts as not uploaded.
137
+ */
138
+ private attachmentFiles;
139
+ /** Stages the attachments a span's `attribute` now holds, replacing its earlier ones. */
140
+ private stageAttachments;
141
+ /**
142
+ * Span attributes as recorded: a value that is an {@link Attachment}, an object or array holding
143
+ * one, or JSON data OpenTelemetry cannot hold as an attribute (an object, or an array that is not
144
+ * of one primitive type), becomes its JSON text with each attachment as its digest part (an
145
+ * invalid attachment alone is omitted); every other value is left to OpenTelemetry as given.
146
+ */
147
+ private attributeValues;
130
148
  /**
131
149
  * Records the tools a model provider executed itself while producing `response`, which no
132
150
  * `hue.tool()` call saw: OpenAI Responses `mcp_call`, `web_search_call`, `file_search_call` and