@hue-run/sdk 0.16.0 → 0.17.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.
package/dist/client.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { createHash } from "node:crypto";
3
+ import { isMediaName } from "./inline-files.js";
3
4
  import { context, isSpanContextValid, ROOT_CONTEXT, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
4
5
  import { defaultTextMapGetter, defaultTextMapSetter } from "@opentelemetry/api";
5
6
  import { SeverityNumber } from "@opentelemetry/api-logs";
@@ -10,7 +11,7 @@ import { defaultResource, resourceFromAttributes } from "@opentelemetry/resource
10
11
  import { HUE_SCOPE } from "./config.js";
11
12
  import { hostedServerAddresses, hostedToolActivity, hostedToolProvider, providerErrorDescription, } from "./provider-tools.js";
12
13
  import { toolCatalogSummary } from "./tool-definitions.js";
13
- import { contentLimitsOf, encodeBoundedContent, encodeContent, noopSpan, safeSpan, } from "./safety.js";
14
+ import { contentLimitsOf, encodeBoundedContent, encodeContent, noopSpan, safeSpan, spanSource, } from "./safety.js";
14
15
  import { TRUNCATED_KEY, withTruncatedKeys } from "./privacy.js";
15
16
  import { MAX_BLOB_BYTES } from "./blobs.js";
16
17
  import { createHueTransport, exportBudgetMillis, HueExportError, } from "./transport.js";
@@ -662,7 +663,7 @@ export class HueClient {
662
663
  * Adds a `hue.file` event to the active (or given) span for a file the work read, received or
663
664
  * produced: `hue.file.sha256`, `hue.file.role`, `hue.file.media_type`, `hue.file.size` when known
664
665
  * and, when `captureContent` is true, `hue.file.name`. `data` is hashed and measured locally and
665
- * never exported. The event is metadata, so it is recorded in both capture modes. An invalid
666
+ * uploaded only with `upload: true`. The event is metadata, so it is recorded in both capture modes. An invalid
666
667
  * record, or one without an active span, is omitted and counted, never thrown; an invalid name
667
668
  * alone is omitted and counted while the rest is recorded.
668
669
  */
@@ -680,26 +681,30 @@ export class HueClient {
680
681
  throw new TypeError("Invalid media type");
681
682
  let sha256 = typeof file.sha256 === "string" ? file.sha256.toLowerCase() : file.sha256;
682
683
  let byteSize = file.byteSize;
684
+ let bytes;
683
685
  if (data !== undefined) {
684
- const bytes = typeof data === "string"
685
- ? (() => {
686
- // Buffer.byteLength measures UTF-8 without allocating the copy that hashing would
687
- // otherwise require. Reject before Buffer.from/createHash can retain large input.
688
- if (data.length > MAX_FILE_DATA_BYTES ||
689
- Buffer.byteLength(data, "utf8") > MAX_FILE_DATA_BYTES)
690
- throw new RangeError("File data exceeds Hue's 25 MiB limit");
691
- return Buffer.from(data, "utf8");
692
- })()
693
- : data instanceof Uint8Array
694
- ? data.byteLength <= MAX_FILE_DATA_BYTES
695
- ? data
696
- : (() => {
686
+ bytes =
687
+ typeof data === "string"
688
+ ? (() => {
689
+ // Buffer.byteLength measures UTF-8 without allocating the copy that hashing would
690
+ // otherwise require. Reject before Buffer.from/createHash can retain large input.
691
+ if (data.length > MAX_FILE_DATA_BYTES ||
692
+ Buffer.byteLength(data, "utf8") > MAX_FILE_DATA_BYTES)
697
693
  throw new RangeError("File data exceeds Hue's 25 MiB limit");
698
- })()
699
- : undefined;
694
+ return Buffer.from(data, "utf8");
695
+ })()
696
+ : data instanceof Uint8Array
697
+ ? data.byteLength <= MAX_FILE_DATA_BYTES
698
+ ? data
699
+ : (() => {
700
+ throw new RangeError("File data exceeds Hue's 25 MiB limit");
701
+ })()
702
+ : undefined;
700
703
  if (!bytes)
701
704
  throw new TypeError("File data must be bytes or a string");
702
- const digest = createHash("sha256").update(bytes).digest("hex");
705
+ const digest = bytes.byteLength > 0 && file.upload === true && this.transport.holdsValues()
706
+ ? this.transport.digestMedia(bytes, span.spanContext().traceId).sha256
707
+ : createHash("sha256").update(bytes).digest("hex");
703
708
  // A caller-supplied digest or size must describe the same bytes.
704
709
  if ((sha256 !== undefined && sha256 !== digest) ||
705
710
  (byteSize !== undefined && byteSize !== bytes.byteLength))
@@ -719,12 +724,55 @@ export class HueClient {
719
724
  if (byteSize !== undefined)
720
725
  attributes["hue.file.size"] = byteSize;
721
726
  if (this.captureContent && name !== undefined) {
722
- if (isLabel(name))
727
+ if (isMediaName(name))
723
728
  attributes["hue.file.name"] = name;
724
729
  else
725
730
  this.instrumentationFailed();
726
731
  }
727
- span.addEvent("hue.file", attributes);
732
+ // Let OpenTelemetry use the span's wall-clock anchor, then keep this event's exact
733
+ // timestamp for upload admission. A dropped event cannot authorize an upload.
734
+ const source = spanSource(span);
735
+ const previous = this.fileEventTimes.get(source);
736
+ const after = (time) => {
737
+ if (previous &&
738
+ (time[0] < previous[0] || (time[0] === previous[0] && time[1] <= previous[1]))) {
739
+ time[0] = previous[0];
740
+ time[1] = previous[1] + 1;
741
+ if (time[1] === 1_000_000_000) {
742
+ time[0]++;
743
+ time[1] = 0;
744
+ }
745
+ }
746
+ return time;
747
+ };
748
+ let eventTime;
749
+ if (Array.isArray(source.events)) {
750
+ const previousEvent = source.events.at(-1);
751
+ span.addEvent("hue.file", attributes);
752
+ const event = source.events.at(-1);
753
+ eventTime = event && event !== previousEvent ? after(event.time) : undefined;
754
+ }
755
+ else {
756
+ // A span that does not expose its events (an API-only wrapper in attach mode): stamp the
757
+ // event with the wall clock and keep that exact time for upload admission.
758
+ const now = Date.now();
759
+ eventTime = after([Math.floor(now / 1000), (now % 1000) * 1_000_000]);
760
+ span.addEvent("hue.file", attributes, [eventTime[0], eventTime[1]]);
761
+ }
762
+ if (eventTime)
763
+ this.fileEventTimes.set(source, eventTime);
764
+ if (file.upload === true && this.transport.offloadsValues()) {
765
+ if (!this.transport.stageMedia(span, bytes && eventTime
766
+ ? {
767
+ bytes,
768
+ digest: { sha256, size: bytes.byteLength },
769
+ contentType: mediaType,
770
+ eventTime,
771
+ ...(isMediaName(name) ? { name } : {}),
772
+ }
773
+ : undefined))
774
+ this.instrumentationFailed();
775
+ }
728
776
  }
729
777
  catch {
730
778
  this.instrumentationFailed();
@@ -732,6 +780,8 @@ export class HueClient {
732
780
  }
733
781
  /** The keys of each span's content values that were cut to Hue's value cap, for `hue.truncated`. */
734
782
  truncatedKeys = new WeakMap();
783
+ /** Distinguishes requests whose helper events otherwise have identical metadata. */
784
+ fileEventTimes = new WeakMap();
735
785
  /** A content value's bounds: Hue's value cap as the receiver last advertised it. */
736
786
  contentLimits() {
737
787
  return contentLimitsOf(this.transport.receiverLimits().valueBytes);
@@ -30,6 +30,18 @@ export const outputContentTypes = [
30
30
  "image/png",
31
31
  "image/jpeg",
32
32
  "image/webp",
33
+ "image/gif",
34
+ "image/avif",
35
+ "audio/mpeg",
36
+ "audio/wav",
37
+ "audio/ogg",
38
+ "audio/webm",
39
+ "audio/mp4",
40
+ "audio/flac",
41
+ "audio/opus",
42
+ "audio/aac",
43
+ "video/mp4",
44
+ "video/webm",
33
45
  ];
34
46
  /** Thrown when the target's declared files cannot be used; recorded as the target's error. */
35
47
  export class OutputFileError extends Error {
@@ -1,6 +1,6 @@
1
- /** Inline file content larger than this many UTF-8 bytes is exported as its digest instead. */
1
+ /** Text media above this decoded byte limit is offloaded; binary media always uses a digest. */
2
2
  export declare const INLINE_FILE_LIMIT: number;
3
- /** The longest message text, in UTF-8 bytes, inspected for inline files. */
3
+ /** The longest message text, in UTF-16 code units, inspected for inline files. */
4
4
  export declare const MAX_INLINE_FILE_TEXT: number;
5
5
  /** Message text one record inspects for inline files in all, in UTF-16 code units. */
6
6
  export declare const INLINE_FILE_TEXT_PER_RECORD: number;
@@ -20,38 +20,47 @@ export interface InlineFileBytes {
20
20
  * `data:` URL whose data does not decode is taken as UTF-8 too.
21
21
  */
22
22
  export declare function inlineFileBytes(content: string): InlineFileBytes;
23
- /** The key holding a part's inline content: GenAI `blob` parts and AI SDK 6 `file` parts. */
24
- export declare function contentKey(part: Record<string, unknown>): "content" | "data" | undefined;
25
- /**
26
- * Replaces inline files longer than {@link INLINE_FILE_LIMIT} in a recorded message attribute
27
- * with their SHA-256 and byte size, so a span that inlines a large file exports the file's
28
- * identity instead of being rejected for its size. The part keeps its other fields (`type`,
29
- * `mime_type`, `modality`, `mediaType`, …). Other attributes, shorter messages and values that
30
- * are not JSON are returned unchanged.
31
- */
32
- export declare function hashInlineFiles(key: string, value: unknown,
33
- /** Counts the files replaced by their digest, when given. */
34
- count?: {
35
- files: number;
36
- }): unknown;
37
- /** One inline file part found in a parsed message: the part object, the key holding its inline
38
- * content, that content, and the part's content as a JSON Pointer (RFC 6901) into the message. */
23
+ /** A part and the exact leaf that contains its bytes. Nested provider metadata stays in place. */
39
24
  export interface InlineFilePart {
40
25
  part: Record<string, unknown>;
41
- key: "content" | "data";
26
+ owner: Record<string, unknown>;
27
+ key: string;
42
28
  content: string;
43
29
  pointer: string;
30
+ partPointer: string;
31
+ contentType?: string;
32
+ name?: string;
33
+ base64?: boolean;
44
34
  }
45
- /**
46
- * The inline file parts of a parsed message whose content text is longer than
47
- * {@link INLINE_FILE_LIMIT} UTF-8 bytes, in document order: the caller decodes each and keeps the
48
- * ones whose file bytes are over the limit. The parts are the parsed value's own objects, so the
49
- * caller may replace their content in place.
50
- */
35
+ export declare function isMediaName(value: unknown): value is string;
36
+ /** Whether the receiver can preview these bytes as text. */
37
+ export declare function isTextMediaType(contentType: string): boolean;
38
+ /** Cheap shared prefilter before bounded parsing of span message attributes. */
39
+ export declare function mayInlineFiles(key: string, value: unknown): boolean;
40
+ /** A printable MIME type with optional parameters, accepted by the upload contract. */
41
+ export declare function isMediaContentType(value: unknown): value is string;
42
+ /** Detect the content pointer, MIME, name, and encoding contract of a provider media part. */
43
+ export declare function contentPath(part: Record<string, unknown>, pointer?: string): InlineFilePart | undefined;
44
+ /** Inline media in document order. The caller applies the decoded text threshold. */
51
45
  export declare function largeInlineFileParts(value: unknown): InlineFilePart[];
52
- /** The digest an inline file part is exported as when its bytes are not uploaded: the part's
53
- * other fields with the file's SHA-256 and byte size, as {@link hashInlineFiles} writes it. */
54
- export declare function digestPart(part: Record<string, unknown>, key: "content" | "data", digest: {
46
+ export declare function mediaBytes(file: InlineFilePart): InlineFileBytes;
47
+ export declare function mediaContentType(file: InlineFilePart, decoded: InlineFileBytes): string;
48
+ export interface FileDigest {
55
49
  sha256: string;
56
50
  size: number;
57
- }): void;
51
+ }
52
+ /** Bounded payload identity memo, shared by admission and export, cleared at shutdown. */
53
+ export declare class MediaDigestMemo {
54
+ private entries;
55
+ private units;
56
+ clear(): void;
57
+ digest(file: InlineFilePart, decoded?: InlineFileBytes, traceId?: string): FileDigest;
58
+ bytes(bytes: Uint8Array, traceId: string): FileDigest;
59
+ }
60
+ /** Digest placement removes only the leaf and adds identity to the provider part. */
61
+ export declare function digestPart(file: InlineFilePart, digest: FileDigest): void;
62
+ export declare function hashInlineFiles(key: string, value: unknown, count?: {
63
+ files: number;
64
+ }, memo?: MediaDigestMemo, traceId?: string): unknown;
65
+ /** Nested message attributes retain small media and digest larger media without upload state. */
66
+ export declare function hashNestedInlineFiles(key: string, value: unknown): unknown;
@@ -1,12 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { MAX_BODY_BYTES } from "./config.js";
3
- /** Inline file content larger than this many UTF-8 bytes is exported as its digest instead. */
3
+ /** Text media above this decoded byte limit is offloaded; binary media always uses a digest. */
4
4
  export const INLINE_FILE_LIMIT = 64 * 1024;
5
- /** The longest message text, in UTF-8 bytes, inspected for inline files. */
5
+ /** The longest message text, in UTF-16 code units, inspected for inline files. */
6
6
  export const MAX_INLINE_FILE_TEXT = 8 * MAX_BODY_BYTES;
7
7
  /** Message text one record inspects for inline files in all, in UTF-16 code units. */
8
8
  export const INLINE_FILE_TEXT_PER_RECORD = 2 * MAX_INLINE_FILE_TEXT;
9
- /** Message attributes whose JSON can inline files: GenAI blob parts and AI SDK 6 file parts. */
9
+ /** The span message attributes whose JSON may contain inline media. */
10
10
  const messageKeys = new Set([
11
11
  "gen_ai.input.messages",
12
12
  "gen_ai.output.messages",
@@ -69,76 +69,200 @@ export function inlineFileBytes(content) {
69
69
  ...(mediaType ? { urlMediaType: mediaType } : {}),
70
70
  };
71
71
  }
72
- function fileBytes(content) {
73
- return inlineFileBytes(content).bytes;
72
+ const audioTypes = {
73
+ wav: "audio/wav",
74
+ mp3: "audio/mpeg",
75
+ flac: "audio/flac",
76
+ ogg: "audio/ogg",
77
+ opus: "audio/opus",
78
+ aac: "audio/aac",
79
+ m4a: "audio/mp4",
80
+ webm: "audio/webm",
81
+ pcm16: "audio/pcm",
82
+ };
83
+ const documentTypes = {
84
+ pdf: "application/pdf",
85
+ csv: "text/csv",
86
+ doc: "application/msword",
87
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
88
+ xls: "application/vnd.ms-excel",
89
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
90
+ html: "text/html",
91
+ txt: "text/plain",
92
+ md: "text/markdown",
93
+ };
94
+ function object(value) {
95
+ return value !== null && typeof value === "object" && !Array.isArray(value)
96
+ ? value
97
+ : undefined;
74
98
  }
75
- /** The key holding a part's inline content: GenAI `blob` parts and AI SDK 6 `file` parts. */
76
- export function contentKey(part) {
77
- return part.type === "blob" ? "content" : part.type === "file" ? "data" : undefined;
99
+ function string(value) {
100
+ return typeof value === "string" ? value : undefined;
78
101
  }
79
- function hashNode(value, state, depth) {
80
- if (depth > 256)
81
- throw new Error("Message exceeds the supported nesting limit");
82
- if (Array.isArray(value))
83
- return value.map((item) => hashNode(item, state, depth + 1));
84
- if (value === null || typeof value !== "object")
85
- return value;
86
- const part = value;
87
- const key = contentKey(part);
88
- const inline = key === undefined ? undefined : part[key];
89
- if (key !== undefined && typeof inline === "string") {
90
- const bytes = fileBytes(inline);
91
- if (bytes.byteLength > INLINE_FILE_LIMIT) {
92
- const { [key]: _omitted, ...rest } = part;
93
- state.changed = true;
94
- state.files++;
95
- return {
96
- ...rest,
97
- sha256: createHash("sha256").update(bytes).digest("hex"),
98
- size: bytes.byteLength,
99
- };
100
- }
101
- }
102
- return Object.fromEntries(Object.entries(part).map(([name, item]) => [name, hashNode(item, state, depth + 1)]));
103
- }
104
- /**
105
- * Replaces inline files longer than {@link INLINE_FILE_LIMIT} in a recorded message attribute
106
- * with their SHA-256 and byte size, so a span that inlines a large file exports the file's
107
- * identity instead of being rejected for its size. The part keeps its other fields (`type`,
108
- * `mime_type`, `modality`, `mediaType`, …). Other attributes, shorter messages and values that
109
- * are not JSON are returned unchanged.
110
- */
111
- export function hashInlineFiles(key, value,
112
- /** Counts the files replaced by their digest, when given. */
113
- count) {
114
- if (!messageKeys.has(key) ||
115
- typeof value !== "string" ||
116
- Buffer.byteLength(value, "utf8") <= INLINE_FILE_LIMIT ||
117
- Buffer.byteLength(value, "utf8") > MAX_INLINE_FILE_TEXT ||
118
- !(value.includes('"blob"') || value.includes('"file"')))
119
- return value;
120
- try {
121
- const parsed = JSON.parse(value);
122
- const state = { changed: false, files: 0 };
123
- const hashed = hashNode(parsed, state, 0);
124
- if (count)
125
- count.files += state.files;
126
- return state.changed ? JSON.stringify(hashed) : value;
127
- }
128
- catch {
129
- // Not JSON, or nested too deeply to inspect: export decides the value's fate as before.
130
- return value;
131
- }
102
+ export function isMediaName(value) {
103
+ return (typeof value === "string" &&
104
+ value.trim().length > 0 &&
105
+ value.length <= 256 &&
106
+ value.isWellFormed() &&
107
+ !Array.from(value).some((character) => {
108
+ const code = character.charCodeAt(0);
109
+ return code < 32 || code === 127;
110
+ }));
132
111
  }
133
112
  function pointerSegment(segment) {
134
113
  return segment.replaceAll("~", "~0").replaceAll("/", "~1");
135
114
  }
136
- /**
137
- * The inline file parts of a parsed message whose content text is longer than
138
- * {@link INLINE_FILE_LIMIT} UTF-8 bytes, in document order: the caller decodes each and keeps the
139
- * ones whose file bytes are over the limit. The parts are the parsed value's own objects, so the
140
- * caller may replace their content in place.
141
- */
115
+ /** Whether the receiver can preview these bytes as text. */
116
+ export function isTextMediaType(contentType) {
117
+ const type = contentType.split(";", 1)[0].trim().toLowerCase();
118
+ return (type.startsWith("text/") ||
119
+ type === "application/json" ||
120
+ type.endsWith("+json") ||
121
+ type === "application/xml" ||
122
+ type.endsWith("+xml") ||
123
+ type === "application/yaml" ||
124
+ type === "application/x-yaml" ||
125
+ type === "application/javascript" ||
126
+ type === "application/x-javascript" ||
127
+ type === "application/x-www-form-urlencoded");
128
+ }
129
+ /** Cheap shared prefilter before bounded parsing of span message attributes. */
130
+ export function mayInlineFiles(key, value) {
131
+ return (isMessageKey(key) &&
132
+ typeof value === "string" &&
133
+ [
134
+ "blob",
135
+ "file",
136
+ "image",
137
+ "document",
138
+ "media",
139
+ "inlineData",
140
+ "inline_data",
141
+ "input_audio",
142
+ "\\u",
143
+ ].some((marker) => value.includes(marker)));
144
+ }
145
+ /** A printable MIME type with optional parameters, accepted by the upload contract. */
146
+ export function isMediaContentType(value) {
147
+ return (typeof value === "string" &&
148
+ value.length >= 3 &&
149
+ value.length <= 255 &&
150
+ /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9!#$&^_.+-]+(?: *;[ -~]*)?$/.test(value));
151
+ }
152
+ /** Detect the content pointer, MIME, name, and encoding contract of a provider media part. */
153
+ export function contentPath(part, pointer = "") {
154
+ let owner = part;
155
+ let key;
156
+ let path = "";
157
+ let contentType = string(part.mime_type ?? part.mimeType ?? part.mediaType ?? part.media_type);
158
+ let name = string(part.name ?? part.filename);
159
+ let encoded = false;
160
+ let dataUrlOnly = false;
161
+ const source = object(part.source);
162
+ const inline = object(part.inlineData ?? part.inline_data);
163
+ const audio = object(part.input_audio);
164
+ const chatFile = object(part.file);
165
+ const bedrock = object(part.image ?? part.document);
166
+ if (inline) {
167
+ owner = inline;
168
+ key = "data";
169
+ path = part.inlineData ? "/inlineData" : "/inline_data";
170
+ contentType = string(inline.mimeType ?? inline.mime_type);
171
+ encoded = true;
172
+ name = undefined;
173
+ }
174
+ else if (audio && part.type === "input_audio") {
175
+ owner = audio;
176
+ key = "data";
177
+ path = "/input_audio";
178
+ contentType = audioTypes[string(audio.format) ?? ""];
179
+ encoded = true;
180
+ name = undefined;
181
+ }
182
+ else if (chatFile && part.type === "file") {
183
+ owner = chatFile;
184
+ key = "file_data";
185
+ path = "/file";
186
+ name = string(chatFile.filename);
187
+ dataUrlOnly = true;
188
+ }
189
+ else if (bedrock && object(bedrock.source)) {
190
+ const isImage = object(part.image) !== undefined;
191
+ owner = object(bedrock.source);
192
+ key = "bytes";
193
+ path = isImage ? "/image/source" : "/document/source";
194
+ const format = string(bedrock.format) ?? "";
195
+ contentType =
196
+ isImage && ["png", "jpeg", "gif", "webp"].includes(format)
197
+ ? `image/${format}`
198
+ : documentTypes[format];
199
+ name = isImage ? undefined : string(bedrock.name);
200
+ encoded = true;
201
+ }
202
+ else if (source && ["image", "document"].includes(string(part.type) ?? "")) {
203
+ if (source.type !== "base64")
204
+ return undefined;
205
+ owner = source;
206
+ key = "data";
207
+ path = "/source";
208
+ contentType = string(source.media_type);
209
+ name = string(part.title);
210
+ encoded = true;
211
+ }
212
+ else if (part.type === "image_url") {
213
+ const image = object(part.image_url);
214
+ owner = image ?? part;
215
+ key = image ? "url" : "image_url";
216
+ path = image ? "/image_url" : "";
217
+ contentType = undefined;
218
+ name = undefined;
219
+ dataUrlOnly = true;
220
+ }
221
+ else if (part.type === "input_image") {
222
+ key = "image_url";
223
+ contentType = undefined;
224
+ name = undefined;
225
+ dataUrlOnly = true;
226
+ }
227
+ else if (part.type === "input_file") {
228
+ key = "file_data";
229
+ contentType = undefined;
230
+ name = string(part.filename);
231
+ dataUrlOnly = true;
232
+ }
233
+ else if (part.type === "image") {
234
+ key = "image";
235
+ name = undefined;
236
+ }
237
+ else if (part.type === "media") {
238
+ key = "data";
239
+ name = undefined;
240
+ }
241
+ else
242
+ key = part.type === "blob" ? "content" : part.type === "file" ? "data" : undefined;
243
+ if (!key)
244
+ return undefined;
245
+ const content = owner[key];
246
+ if (typeof content !== "string" || /^(?:https?:|s3:|gs:|file:|blob:)/i.test(content))
247
+ return undefined;
248
+ if (dataUrlOnly && !content.startsWith("data:"))
249
+ return undefined;
250
+ // A declared base64 source must actually decode; malformed payloads stay as recorded.
251
+ if (encoded && !(content.length % 4 === 0 && base64.test(content)))
252
+ return undefined;
253
+ return {
254
+ part,
255
+ owner,
256
+ key,
257
+ content,
258
+ pointer: `${pointer}${path}/${key}`,
259
+ partPointer: pointer,
260
+ ...(contentType ? { contentType } : {}),
261
+ ...(isMediaName(name) ? { name } : {}),
262
+ ...(encoded ? { base64: true } : {}),
263
+ };
264
+ }
265
+ /** Inline media in document order. The caller applies the decoded text threshold. */
142
266
  export function largeInlineFileParts(value) {
143
267
  const found = [];
144
268
  const visit = (node, pointer, depth) => {
@@ -148,17 +272,12 @@ export function largeInlineFileParts(value) {
148
272
  node.forEach((item, index) => visit(item, `${pointer}/${index}`, depth + 1));
149
273
  return;
150
274
  }
151
- if (node === null || typeof node !== "object")
275
+ const part = object(node);
276
+ if (!part)
152
277
  return;
153
- const part = node;
154
- const key = contentKey(part);
155
- const inline = key === undefined ? undefined : part[key];
156
- // Decoding only shrinks the text, so text of at most the limit holds no file over it.
157
- if (key !== undefined &&
158
- typeof inline === "string" &&
159
- inline.length * 3 > INLINE_FILE_LIMIT &&
160
- Buffer.byteLength(inline, "utf8") > INLINE_FILE_LIMIT) {
161
- found.push({ part, key, content: inline, pointer: `${pointer}/${key}` });
278
+ const file = contentPath(part, pointer);
279
+ if (file) {
280
+ found.push(file);
162
281
  return;
163
282
  }
164
283
  for (const [name, item] of Object.entries(part))
@@ -167,10 +286,96 @@ export function largeInlineFileParts(value) {
167
286
  visit(value, "", 0);
168
287
  return found;
169
288
  }
170
- /** The digest an inline file part is exported as when its bytes are not uploaded: the part's
171
- * other fields with the file's SHA-256 and byte size, as {@link hashInlineFiles} writes it. */
172
- export function digestPart(part, key, digest) {
173
- delete part[key];
174
- part.sha256 = digest.sha256;
175
- part.size = digest.size;
289
+ export function mediaBytes(file) {
290
+ if (file.base64)
291
+ return { bytes: Buffer.from(file.content, "base64"), decoded: true };
292
+ if (isMediaContentType(file.contentType) &&
293
+ isTextMediaType(file.contentType) &&
294
+ !file.content.startsWith("data:"))
295
+ return { bytes: Buffer.from(file.content, "utf8"), decoded: false };
296
+ return inlineFileBytes(file.content);
297
+ }
298
+ export function mediaContentType(file, decoded) {
299
+ return ((isMediaContentType(file.contentType) ? file.contentType : undefined) ??
300
+ (isMediaContentType(decoded.urlMediaType) ? decoded.urlMediaType : undefined) ??
301
+ (decoded.decoded ? "application/octet-stream" : "text/plain; charset=utf-8"));
302
+ }
303
+ /** Bounded payload identity memo, shared by admission and export, cleared at shutdown. */
304
+ export class MediaDigestMemo {
305
+ entries = new Map();
306
+ units = 0;
307
+ clear() {
308
+ this.entries.clear();
309
+ this.units = 0;
310
+ }
311
+ digest(file, decoded, traceId = "") {
312
+ return this.bytes((decoded ?? mediaBytes(file)).bytes, traceId);
313
+ }
314
+ bytes(bytes, traceId) {
315
+ const digest = {
316
+ sha256: createHash("sha256").update(bytes).digest("hex"),
317
+ size: bytes.byteLength,
318
+ };
319
+ const key = `${traceId}:${digest.sha256}`;
320
+ const known = this.entries.get(key);
321
+ if (known)
322
+ return known;
323
+ const limit = 32 * 1024 * 1024;
324
+ if (key.length <= limit) {
325
+ while (this.entries.size >= 4096 || this.units + key.length > limit) {
326
+ const first = this.entries.keys().next().value;
327
+ this.units -= first.length;
328
+ this.entries.delete(first);
329
+ }
330
+ this.entries.set(key, digest);
331
+ this.units += key.length;
332
+ }
333
+ return digest;
334
+ }
335
+ }
336
+ /** Digest placement removes only the leaf and adds identity to the provider part. */
337
+ export function digestPart(file, digest) {
338
+ delete file.owner[file.key];
339
+ file.part.sha256 = digest.sha256;
340
+ file.part.size = digest.size;
341
+ }
342
+ export function hashInlineFiles(key, value, count, memo, traceId = "") {
343
+ return hashFiles(key, value, false, count, memo, traceId);
344
+ }
345
+ /** Nested message attributes retain small media and digest larger media without upload state. */
346
+ export function hashNestedInlineFiles(key, value) {
347
+ return hashFiles(key, value, true);
348
+ }
349
+ function hashFiles(key, value, nested, count, memo, traceId = "") {
350
+ if (typeof value !== "string" ||
351
+ !mayInlineFiles(key, value) ||
352
+ value.length > MAX_INLINE_FILE_TEXT)
353
+ return value;
354
+ try {
355
+ const parsed = JSON.parse(value);
356
+ const files = largeInlineFileParts(parsed);
357
+ let changed = false;
358
+ for (const file of files) {
359
+ const decoded = mediaBytes(file);
360
+ if (!decoded.bytes.byteLength)
361
+ continue;
362
+ if ((nested || isTextMediaType(mediaContentType(file, decoded))) &&
363
+ decoded.bytes.byteLength <= INLINE_FILE_LIMIT)
364
+ continue;
365
+ const digest = memo
366
+ ? memo.digest(file, decoded, traceId)
367
+ : {
368
+ sha256: createHash("sha256").update(decoded.bytes).digest("hex"),
369
+ size: decoded.bytes.byteLength,
370
+ };
371
+ digestPart(file, digest);
372
+ changed = true;
373
+ if (count)
374
+ count.files++;
375
+ }
376
+ return changed ? JSON.stringify(parsed) : value;
377
+ }
378
+ catch {
379
+ return value;
380
+ }
176
381
  }
package/dist/privacy.d.ts CHANGED
@@ -33,7 +33,7 @@ export interface RedactionLimits {
33
33
  valueBytes?: number;
34
34
  /** The record's value strings cut when it was queued. */
35
35
  cut?: ReadonlySet<string>;
36
- /** The span's own messages whose large inline files were shrunk to their digest when it was
36
+ /** The span's own messages whose inline media were shrunk to their digest when it was
37
37
  * queued, with how many files each: reported as not uploaded when values may be. */
38
38
  digested?: ReadonlyMap<string, DigestedMessage> | undefined;
39
39
  /**