@hue-run/sdk 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,8 @@ import { createHash } from "node:crypto";
2
2
  import { MAX_BODY_BYTES } from "./config.js";
3
3
  /** Inline file content larger than this many UTF-8 bytes is exported as its digest instead. */
4
4
  export const INLINE_FILE_LIMIT = 64 * 1024;
5
- const MAX_INLINE_FILE_TEXT = 8 * MAX_BODY_BYTES;
5
+ /** The longest message text, in UTF-8 bytes, inspected for inline files. */
6
+ export const MAX_INLINE_FILE_TEXT = 8 * MAX_BODY_BYTES;
6
7
  /** Message text one record inspects for inline files in all, in UTF-16 code units. */
7
8
  export const INLINE_FILE_TEXT_PER_RECORD = 2 * MAX_INLINE_FILE_TEXT;
8
9
  /** Message attributes whose JSON can inline files: GenAI blob parts and AI SDK 6 file parts. */
@@ -52,7 +53,7 @@ function percentDecoded(payload) {
52
53
  * the base64 alphabet decoded, and anything else, such as a text file's own text, as UTF-8. A
53
54
  * `data:` URL whose data does not decode is taken as UTF-8 too.
54
55
  */
55
- function fileBytes(content) {
56
+ export function inlineFileBytes(content) {
56
57
  const url = dataUrl.exec(content);
57
58
  const payload = url ? content.slice(url[0].length) : content;
58
59
  const decoded = url && !base64Header(url[1])
@@ -60,10 +61,19 @@ function fileBytes(content) {
60
61
  : payload.length % 4 === 0 && base64.test(payload)
61
62
  ? Buffer.from(payload, "base64")
62
63
  : undefined;
63
- return decoded ?? Buffer.from(content, "utf8");
64
+ const header = url?.[1];
65
+ const mediaType = header === undefined ? undefined : header.split(";")[0].trim();
66
+ return {
67
+ bytes: decoded ?? Buffer.from(content, "utf8"),
68
+ decoded: decoded !== undefined,
69
+ ...(mediaType ? { urlMediaType: mediaType } : {}),
70
+ };
71
+ }
72
+ function fileBytes(content) {
73
+ return inlineFileBytes(content).bytes;
64
74
  }
65
75
  /** The key holding a part's inline content: GenAI `blob` parts and AI SDK 6 `file` parts. */
66
- function contentKey(part) {
76
+ export function contentKey(part) {
67
77
  return part.type === "blob" ? "content" : part.type === "file" ? "data" : undefined;
68
78
  }
69
79
  function hashNode(value, state, depth) {
@@ -81,6 +91,7 @@ function hashNode(value, state, depth) {
81
91
  if (bytes.byteLength > INLINE_FILE_LIMIT) {
82
92
  const { [key]: _omitted, ...rest } = part;
83
93
  state.changed = true;
94
+ state.files++;
84
95
  return {
85
96
  ...rest,
86
97
  sha256: createHash("sha256").update(bytes).digest("hex"),
@@ -97,7 +108,9 @@ function hashNode(value, state, depth) {
97
108
  * `mime_type`, `modality`, `mediaType`, …). Other attributes, shorter messages and values that
98
109
  * are not JSON are returned unchanged.
99
110
  */
100
- export function hashInlineFiles(key, value) {
111
+ export function hashInlineFiles(key, value,
112
+ /** Counts the files replaced by their digest, when given. */
113
+ count) {
101
114
  if (!messageKeys.has(key) ||
102
115
  typeof value !== "string" ||
103
116
  Buffer.byteLength(value, "utf8") <= INLINE_FILE_LIMIT ||
@@ -106,8 +119,10 @@ export function hashInlineFiles(key, value) {
106
119
  return value;
107
120
  try {
108
121
  const parsed = JSON.parse(value);
109
- const state = { changed: false };
122
+ const state = { changed: false, files: 0 };
110
123
  const hashed = hashNode(parsed, state, 0);
124
+ if (count)
125
+ count.files += state.files;
111
126
  return state.changed ? JSON.stringify(hashed) : value;
112
127
  }
113
128
  catch {
@@ -115,3 +130,47 @@ export function hashInlineFiles(key, value) {
115
130
  return value;
116
131
  }
117
132
  }
133
+ function pointerSegment(segment) {
134
+ return segment.replaceAll("~", "~0").replaceAll("/", "~1");
135
+ }
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
+ */
142
+ export function largeInlineFileParts(value) {
143
+ const found = [];
144
+ const visit = (node, pointer, depth) => {
145
+ if (depth > 256)
146
+ throw new Error("Message exceeds the supported nesting limit");
147
+ if (Array.isArray(node)) {
148
+ node.forEach((item, index) => visit(item, `${pointer}/${index}`, depth + 1));
149
+ return;
150
+ }
151
+ if (node === null || typeof node !== "object")
152
+ 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}` });
162
+ return;
163
+ }
164
+ for (const [name, item] of Object.entries(part))
165
+ visit(item, `${pointer}/${pointerSegment(name)}`, depth + 1);
166
+ };
167
+ visit(value, "", 0);
168
+ return found;
169
+ }
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;
176
+ }
@@ -0,0 +1,32 @@
1
+ import type { IncomingHttpHeaders } from "node:http";
2
+ /**
3
+ * What a Hue receiver accepts. Every OTLP acknowledgement, whatever its status, advertises these
4
+ * (`Hue-Max-Request-Bytes`, `Hue-Max-Decoded-Bytes`, `Hue-Max-Value-Bytes`); until one arrives an
5
+ * exporter assumes the receiver's limits as of this release.
6
+ */
7
+ export interface ReceiverLimits {
8
+ /** Bytes of one request on the wire, after gzip. */
9
+ readonly requestBytes: number;
10
+ /** Bytes of one request after decompression. */
11
+ readonly decodedBytes: number;
12
+ /** Bytes of one attribute value or log body the receiver keeps inline. */
13
+ readonly valueBytes: number;
14
+ }
15
+ /** The limits assumed before a receiver advertises its own. */
16
+ export declare const DEFAULT_RECEIVER_LIMITS: ReceiverLimits;
17
+ /**
18
+ * The ordinary size of one request before gzip. Advertised limits are ceilings, not targets: a
19
+ * receiver decodes a larger request more slowly, so batches stay at this size, or the advertised
20
+ * decoded limit when that is lower, and only a single record that needs more is sent alone in a
21
+ * larger request, up to the advertised ceilings.
22
+ */
23
+ export declare const BATCH_TARGET_BYTES: number;
24
+ /**
25
+ * `current` with every limit a response advertised adopted, clamped to its range; `current`
26
+ * itself when the response advertised none, or the same values. A lower advertised limit is
27
+ * adopted as readily as a higher one: the exporter never exceeds what the receiver last said.
28
+ */
29
+ export declare function advertisedLimits(current: ReceiverLimits, headers: IncomingHttpHeaders): ReceiverLimits;
30
+ /** Whether `bytes` before gzip are certain to stay within `limit` after it: deflate's worst case
31
+ * (stored blocks) adds a few bytes per 16 KiB, plus gzip's header and trailer. */
32
+ export declare function fitsWithoutCompression(bytes: number, limit: number): boolean;
package/dist/limits.js ADDED
@@ -0,0 +1,54 @@
1
+ import { MAX_BODY_BYTES, MAX_CONTENT_BYTES, MAX_DECODED_BYTES } from "./config.js";
2
+ const KIB = 1024;
3
+ const MIB = 1024 * KIB;
4
+ /** The limits assumed before a receiver advertises its own. */
5
+ export const DEFAULT_RECEIVER_LIMITS = Object.freeze({
6
+ requestBytes: MAX_BODY_BYTES,
7
+ decodedBytes: MAX_DECODED_BYTES,
8
+ valueBytes: MAX_CONTENT_BYTES,
9
+ });
10
+ /**
11
+ * The ordinary size of one request before gzip. Advertised limits are ceilings, not targets: a
12
+ * receiver decodes a larger request more slowly, so batches stay at this size, or the advertised
13
+ * decoded limit when that is lower, and only a single record that needs more is sent alone in a
14
+ * larger request, up to the advertised ceilings.
15
+ */
16
+ export const BATCH_TARGET_BYTES = 4 * MIB;
17
+ /** Each advertised limit's header and the range it is clamped to, so a faulty value cannot make
18
+ * requests unboundedly large or too small to carry a record. */
19
+ const ADVERTISED = [
20
+ { field: "requestBytes", header: "hue-max-request-bytes", min: MIB, max: 64 * MIB },
21
+ { field: "decodedBytes", header: "hue-max-decoded-bytes", min: MIB, max: 64 * MIB },
22
+ { field: "valueBytes", header: "hue-max-value-bytes", min: 256 * KIB, max: 16 * MIB },
23
+ ];
24
+ /** A decimal integer header value, else undefined. */
25
+ function decimal(value) {
26
+ if (typeof value !== "string")
27
+ return undefined;
28
+ const text = value.trim();
29
+ if (!/^[0-9]{1,20}$/.test(text))
30
+ return undefined;
31
+ return Number(text);
32
+ }
33
+ /**
34
+ * `current` with every limit a response advertised adopted, clamped to its range; `current`
35
+ * itself when the response advertised none, or the same values. A lower advertised limit is
36
+ * adopted as readily as a higher one: the exporter never exceeds what the receiver last said.
37
+ */
38
+ export function advertisedLimits(current, headers) {
39
+ let next = current;
40
+ for (const { field, header, min, max } of ADVERTISED) {
41
+ const value = decimal(headers[header]);
42
+ if (value === undefined)
43
+ continue;
44
+ const clamped = Math.min(max, Math.max(min, value));
45
+ if (clamped !== next[field])
46
+ next = { ...next, [field]: clamped };
47
+ }
48
+ return next === current ? current : Object.freeze(next);
49
+ }
50
+ /** Whether `bytes` before gzip are certain to stay within `limit` after it: deflate's worst case
51
+ * (stored blocks) adds a few bytes per 16 KiB, plus gzip's header and trailer. */
52
+ export function fitsWithoutCompression(bytes, limit) {
53
+ return bytes + Math.ceil(bytes / 1024) + 64 <= limit;
54
+ }
package/dist/privacy.d.ts CHANGED
@@ -3,13 +3,16 @@ import type { ReadableLogRecord } from "@opentelemetry/sdk-logs";
3
3
  import { type Resource } from "@opentelemetry/resources";
4
4
  import type { HueOptions } from "./types.js";
5
5
  import { truncateUtf8 } from "./safety.js";
6
+ import type { OffloadCollector } from "./blobs.js";
7
+ import type { DigestedMessage } from "./snapshot.js";
6
8
  export { truncateUtf8 };
7
9
  /** Attribute keys (and their dotted children) removed in metadata-only mode. */
8
10
  export declare const contentPrefixes: string[];
9
11
  export declare function isContentKey(key: string): boolean;
10
- /** The record attribute listing the keys whose values were cut to fit Hue's 256 KiB value cap:
11
- * Hue's receiver writes it for the values it cuts, and the SDK writes it for the values it cuts
12
- * before export, under the same name, so a reader learns of a cut value from one place. */
12
+ /** The record attribute listing the keys whose values were cut to fit Hue's value cap (1 MiB
13
+ * unless the receiver advertises another): Hue's receiver writes it for the values it cuts, and
14
+ * the SDK writes it for the values it cuts before export, under the same name, so a reader learns
15
+ * of a cut value from one place. */
13
16
  export declare const TRUNCATED_KEY = "hue.truncated";
14
17
  /** The size of a structured value the marker replaced. */
15
18
  export declare const TRUNCATED_BYTES_KEY = "hue.truncated_bytes";
@@ -20,8 +23,27 @@ export declare const DROPPED_RECORDS_KEY = "hue.sdk.dropped_records";
20
23
  /** Hue's receiver's own marker for a structured value it replaced: nothing of the value, its
21
24
  * size, and the flag a reader tests. */
22
25
  export declare function truncatedMarker(bytes: number): Record<string, unknown>;
26
+ /** Whether a value is the receiver's marker for a value that was shed or replaced. */
27
+ export declare function isTruncatedMarker(value: unknown): boolean;
23
28
  /** `listed` with `keys` added, each once, in order. */
24
29
  export declare function withTruncatedKeys(listed: unknown, keys: readonly string[]): string[];
30
+ /** How one record is redacted: the receiver's value cap, and the value strings the queue cut. */
31
+ export interface RedactionLimits {
32
+ /** The value cap in UTF-8 bytes. Default 1 MiB, the receiver's limit before it advertises one. */
33
+ valueBytes?: number;
34
+ /** The record's value strings cut when it was queued. */
35
+ cut?: ReadonlySet<string>;
36
+ /** The span's own messages whose large inline files were shrunk to their digest when it was
37
+ * queued, with how many files each: reported as not uploaded when values may be. */
38
+ digested?: ReadonlyMap<string, DigestedMessage> | undefined;
39
+ /**
40
+ * Set when a span's values over the cap may be uploaded: each of its own string or byte
41
+ * attribute values still over the cap after the redactor, and each recorded message that may
42
+ * inline a large file, is left whole (uncut, unlisted) and collected here for the upload step
43
+ * to place; a value the queue cut when it was admitted is named in `cut`.
44
+ */
45
+ offload?: OffloadCollector;
46
+ }
25
47
  export type ResourceCache = WeakMap<Resource, Resource>;
26
- export declare function redactSpan(span: ReadableSpan, options: HueOptions, cache: ResourceCache): ReadableSpan;
27
- export declare function redactLog(log: ReadableLogRecord, options: HueOptions, cache: ResourceCache): ReadableLogRecord;
48
+ export declare function redactSpan(span: ReadableSpan, options: HueOptions, cache: ResourceCache, limits?: RedactionLimits): ReadableSpan;
49
+ export declare function redactLog(log: ReadableLogRecord, options: HueOptions, cache: ResourceCache, limits?: RedactionLimits): ReadableLogRecord;
package/dist/privacy.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { SpanStatusCode } from "@opentelemetry/api";
2
2
  import { resourceFromAttributes } from "@opentelemetry/resources";
3
- import { MAX_CONTENT_BYTES } from "./config.js";
3
+ import { MAX_CONTENT_BYTES, MAX_RECORD_NODES, REDACTION_CONTEXT_UNITS } from "./config.js";
4
4
  import { scrubToolCredentials, withToolCatalogSummary } from "./tool-definitions.js";
5
- import { hashInlineFiles } from "./inline-files.js";
5
+ import { hashInlineFiles, INLINE_FILE_LIMIT, isMessageKey } from "./inline-files.js";
6
6
  import { truncateUtf8 } from "./safety.js";
7
7
  export { truncateUtf8 };
8
8
  /** Attribute keys (and their dotted children) removed in metadata-only mode. */
@@ -65,9 +65,10 @@ export const contentPrefixes = [
65
65
  export function isContentKey(key) {
66
66
  return contentPrefixes.some((prefix) => key === prefix || key.startsWith(`${prefix}.`));
67
67
  }
68
- /** The record attribute listing the keys whose values were cut to fit Hue's 256 KiB value cap:
69
- * Hue's receiver writes it for the values it cuts, and the SDK writes it for the values it cuts
70
- * before export, under the same name, so a reader learns of a cut value from one place. */
68
+ /** The record attribute listing the keys whose values were cut to fit Hue's value cap (1 MiB
69
+ * unless the receiver advertises another): Hue's receiver writes it for the values it cuts, and
70
+ * the SDK writes it for the values it cuts before export, under the same name, so a reader learns
71
+ * of a cut value from one place. */
71
72
  export const TRUNCATED_KEY = "hue.truncated";
72
73
  /** The size of a structured value the marker replaced. */
73
74
  export const TRUNCATED_BYTES_KEY = "hue.truncated_bytes";
@@ -89,6 +90,13 @@ function structuredBytes(value) {
89
90
  export function truncatedMarker(bytes) {
90
91
  return { [TRUNCATED_KEY]: true, [TRUNCATED_BYTES_KEY]: bytes };
91
92
  }
93
+ /** Whether a value is the receiver's marker for a value that was shed or replaced. */
94
+ export function isTruncatedMarker(value) {
95
+ return (value !== null &&
96
+ typeof value === "object" &&
97
+ !Array.isArray(value) &&
98
+ value[TRUNCATED_KEY] === true);
99
+ }
92
100
  /** `listed` with `keys` added, each once, in order. */
93
101
  export function withTruncatedKeys(listed, keys) {
94
102
  const result = Array.isArray(listed)
@@ -99,50 +107,135 @@ export function withTruncatedKeys(listed, keys) {
99
107
  result.push(key);
100
108
  return result;
101
109
  }
110
+ function budgetOf(limits) {
111
+ return {
112
+ bytes: 0,
113
+ nodes: 0,
114
+ truncated: [],
115
+ valueBytes: limits.valueBytes ?? MAX_CONTENT_BYTES,
116
+ ...(limits.cut?.size ? { cut: limits.cut } : {}),
117
+ ...(limits.digested?.size ? { digested: limits.digested } : {}),
118
+ ...(limits.offload ? { offload: limits.offload } : {}),
119
+ };
120
+ }
121
+ /** `text` without its last `units` code units, never ending on the first half of a pair. */
122
+ function withoutTail(text, units) {
123
+ let end = Math.max(0, text.length - units);
124
+ if (end > 0 && /[\uD800-\uDBFF]/.test(text[end - 1]))
125
+ end--;
126
+ return text.slice(0, end);
127
+ }
128
+ /** Text through the redactor, whole: the answer validated, not yet cut to the cap. */
129
+ function redactedText(value, path, options, cap) {
130
+ const result = options.redact ? options.redact(value, path) : value;
131
+ // JavaScript can supply an async redactor despite the synchronous contract.
132
+ // Observe its rejection before dropping the invalid record.
133
+ if (result && typeof result === "object")
134
+ void Promise.resolve(result).catch(() => { });
135
+ // A redactor may shorten or keep its text, never grow it past the cap: an answer longer than
136
+ // both the cap and the text it was given is the redactor's own, refused by its length before
137
+ // anything scans it, since scanning would materialize text the record never held.
138
+ if (typeof result !== "string" ||
139
+ result.length > Math.max(cap, value.length) ||
140
+ !result.isWellFormed() ||
141
+ result.includes("\u0000"))
142
+ throw new Error("Redaction produced unsupported text");
143
+ return result;
144
+ }
145
+ /** Whether a recorded message's text may inline a file over the inline file limit. */
146
+ function mayInlineFiles(key, text) {
147
+ return (isMessageKey(key) &&
148
+ text.length * 3 > INLINE_FILE_LIMIT &&
149
+ (text.includes('"blob"') || text.includes('"file"')));
150
+ }
151
+ /** Marks a value the upload step does not place. */
152
+ const NOT_HELD = Symbol("not held");
153
+ /**
154
+ * One of a span's own attribute values, when its values may be uploaded: text through the
155
+ * credential scrub and the redactor, whole, and collected when it is over the cap or may inline
156
+ * a large file; bytes over the cap collected as they are. {@link NOT_HELD} for any other value,
157
+ * and for text the queue cut when the record was admitted, which is never the whole value.
158
+ */
159
+ function offloadValue(key, value, path, options, budget) {
160
+ const collector = budget.offload;
161
+ if (value instanceof Uint8Array) {
162
+ if (value.byteLength <= budget.valueBytes)
163
+ return NOT_HELD;
164
+ if (++budget.nodes > MAX_RECORD_NODES)
165
+ throw new Error("Telemetry value exceeds the supported nesting limit");
166
+ collector.candidates.push({ key, value });
167
+ return value;
168
+ }
169
+ if (typeof value !== "string")
170
+ return NOT_HELD;
171
+ // Each large inline file the queue shrank to its digest is reported as not uploaded; what is
172
+ // left of the message is placed like any other value, and reported too if it is cut.
173
+ const digested = budget.digested?.get(key);
174
+ const files = digested?.text === value ? digested.files : 0;
175
+ for (let file = 0; file < files; file++)
176
+ collector.cut.push(key);
177
+ if (budget.cut?.has(value)) {
178
+ collector.cut.push(key);
179
+ return NOT_HELD;
180
+ }
181
+ const scrubbed = scrubToolCredentials(key, value);
182
+ if (typeof scrubbed !== "string")
183
+ return NOT_HELD;
184
+ if (++budget.nodes > MAX_RECORD_NODES)
185
+ throw new Error("Telemetry value exceeds the supported nesting limit");
186
+ const result = redactedText(scrubbed, path, options, budget.valueBytes);
187
+ if (mayInlineFiles(key, result) ||
188
+ (result.length * 3 > budget.valueBytes && Buffer.byteLength(result) > budget.valueBytes))
189
+ collector.candidates.push({ key, value: result });
190
+ return result;
191
+ }
102
192
  /**
103
- * One value through the redactor and Hue's value cap. Text over 256 KiB is cut to a UTF-8 prefix
193
+ * One value through the redactor and Hue's value cap. Text over the cap is cut to a UTF-8 prefix
104
194
  * and bytes over it are replaced by the receiver's marker, as the receiver itself does to a
105
195
  * value over its cap, and the value's key (`listAs`) is listed under `hue.truncated`: the span
106
196
  * is exported whole with the rest of its evidence, and a reader knows the one value is partial.
107
197
  * Dropping the record for a large value lost every call, result and reply it recorded.
108
198
  */
109
199
  function redactValue(value, path, options, budget, depth = 0, listAs = path) {
110
- if (++budget.nodes > 16384 || depth > 32)
200
+ if (++budget.nodes > MAX_RECORD_NODES || depth > 32)
111
201
  throw new Error("Telemetry value exceeds the supported nesting limit");
112
202
  if (typeof value === "string") {
113
- // The redactor sees the whole recorded text, so a secret that crosses the cap is still its
114
- // to recognize; what it returns is cut to the cap afterwards.
115
- const result = options.redact ? options.redact(value, path) : value;
116
- // JavaScript can supply an async redactor despite the synchronous contract.
117
- // Observe its rejection before dropping the invalid record.
118
- if (result && typeof result === "object")
119
- void Promise.resolve(result).catch(() => { });
120
- // A redactor may shorten or keep its text, never grow it past the cap: an answer longer than
121
- // both the cap and the text it was given is the redactor's own, refused by its length before
122
- // anything scans it, since scanning would materialize text the record never held.
123
- if (typeof result !== "string" ||
124
- result.length > Math.max(MAX_CONTENT_BYTES, value.length) ||
125
- !result.isWellFormed() ||
126
- result.includes("\u0000"))
127
- throw new Error("Redaction produced unsupported text");
203
+ const cap = budget.valueBytes;
204
+ // The redactor sees the recorded text past the cap, so a secret that crosses the cap is still
205
+ // its to recognize; what it returns is cut to the cap afterwards. Text the queue cut when the
206
+ // record was admitted (far past the cap) reached the redactor without its continuation, so
207
+ // the end of the answer, which may hold the start of a secret it could not recognize, is
208
+ // never exported.
209
+ const cutWhenQueued = budget.cut?.has(value) === true;
210
+ let result = redactedText(value, path, options, cap);
211
+ if (cutWhenQueued && options.redact)
212
+ result = withoutTail(result, REDACTION_CONTEXT_UNITS);
128
213
  const bytes = Buffer.byteLength(result);
129
- if (bytes <= MAX_CONTENT_BYTES) {
214
+ if (bytes <= cap && !cutWhenQueued) {
130
215
  budget.bytes += bytes;
131
216
  return result;
132
217
  }
133
- // Within the cap's code units but over its bytes (multibyte text): cut to the bytes.
218
+ // Within the cap's code units but over its bytes (multibyte text), or cut when queued: cut to
219
+ // the bytes, and listed either way.
134
220
  if (!budget.truncated.includes(listAs))
135
221
  budget.truncated.push(listAs);
136
- budget.bytes += MAX_CONTENT_BYTES;
137
- return truncateUtf8(result, MAX_CONTENT_BYTES);
222
+ budget.bytes += cap;
223
+ return truncateUtf8(result, cap);
224
+ }
225
+ // A value the queue replaced by the receiver's marker (a tool definition or recorded request
226
+ // too long to scrub when it was queued) is listed as cut.
227
+ if (depth === 0 && isTruncatedMarker(value)) {
228
+ if (!budget.truncated.includes(listAs))
229
+ budget.truncated.push(listAs);
230
+ return value;
138
231
  }
139
232
  if (Array.isArray(value)) {
140
- if (value.length > 16384)
233
+ if (value.length > MAX_RECORD_NODES)
141
234
  throw new Error("Telemetry array exceeds the complexity limit");
142
235
  return value.map((item, index) => redactValue(item, `${path}.${index}`, options, budget, depth + 1, listAs));
143
236
  }
144
237
  if (value instanceof Uint8Array) {
145
- if (value.byteLength <= MAX_CONTENT_BYTES) {
238
+ if (value.byteLength <= budget.valueBytes) {
146
239
  budget.bytes += value.byteLength;
147
240
  return value;
148
241
  }
@@ -163,17 +256,25 @@ function redactValue(value, path, options, budget, depth = 0, listAs = path) {
163
256
  * attributes is listed under the record's `hue.truncated`: the key alone for the record's own
164
257
  * attributes, `event:<name>:<key>` for an event's, as Hue's receiver lists the values it cuts.
165
258
  */
166
- function attributes(source, options, path, budget, listPrefix = "") {
259
+ function attributes(source, options, path, budget, listPrefix = "", own = false) {
167
260
  // Metadata-only export summarizes the tool definitions it removes by name and digest.
168
261
  const summarized = options.captureContent ? source : withToolCatalogSummary(source);
169
- return Object.fromEntries(Object.entries(summarized).flatMap(([key, value]) => !options.captureContent && isContentKey(key)
170
- ? []
171
- : [
262
+ return Object.fromEntries(Object.entries(summarized).flatMap(([key, value]) => {
263
+ if (!options.captureContent && isContentKey(key))
264
+ return [];
265
+ // A span's own value that may be uploaded is placed by the upload step.
266
+ if (own && budget.offload) {
267
+ const held = offloadValue(key, value, `${path}.${key}`, options, budget);
268
+ if (held !== NOT_HELD)
269
+ return [[key, held]];
270
+ }
271
+ return [
172
272
  [
173
273
  key,
174
274
  redactValue(scrubToolCredentials(key, hashInlineFiles(key, value)), `${path}.${key}`, options, budget, 0, `${listPrefix}${key}`),
175
275
  ],
176
- ]));
276
+ ];
277
+ }));
177
278
  }
178
279
  /** The record's attributes with the keys the redaction cut listed under `hue.truncated`, merged
179
280
  * with any the application listed itself; unchanged when nothing was cut. */
@@ -221,8 +322,8 @@ function hostedMcpCall(attributes) {
221
322
  serverLabel.isWellFormed();
222
323
  return { ...(valid ? { serverName: serverLabel } : {}), failed: error != null };
223
324
  }
224
- export function redactSpan(span, options, cache) {
225
- const budget = { bytes: 0, nodes: 0, truncated: [] };
325
+ export function redactSpan(span, options, cache, limits = {}) {
326
+ const budget = budgetOf(limits);
226
327
  // Derived before metadata-only stripping so the identity survives without the result itself.
227
328
  const hosted = hostedMcpCall(span.attributes);
228
329
  const source = {
@@ -253,7 +354,7 @@ export function redactSpan(span, options, cache) {
253
354
  }));
254
355
  const resource = redactResource(span.resource, options, cache, budget);
255
356
  // Last, so every cut of the record, its events', links' and resource's included, is listed.
256
- const redactedAttributes = withTruncated(attributes(source, options, "attributes", budget), budget);
357
+ const redactedAttributes = withTruncated(attributes(source, options, "attributes", budget, "", true), budget);
257
358
  return {
258
359
  name: span.name,
259
360
  kind: span.kind,
@@ -274,13 +375,13 @@ export function redactSpan(span, options, cache) {
274
375
  droppedLinksCount: span.droppedLinksCount,
275
376
  };
276
377
  }
277
- export function redactLog(log, options, cache) {
278
- const budget = { bytes: 0, nodes: 0, truncated: [] };
378
+ export function redactLog(log, options, cache, limits = {}) {
379
+ const budget = budgetOf(limits);
279
380
  let body = options.captureContent ? redactValue(log.body, "body", options, budget) : undefined;
280
381
  if (body !== undefined && typeof body !== "string") {
281
382
  // A structured body over the cap is replaced by the receiver's marker, as the receiver would
282
383
  // replace it; the marker names the body's size as recorded, before any text part was cut.
283
- if (Buffer.byteLength(JSON.stringify(body)) > MAX_CONTENT_BYTES) {
384
+ if (Buffer.byteLength(JSON.stringify(body)) > budget.valueBytes) {
284
385
  body = truncatedMarker(structuredBytes(log.body));
285
386
  if (!budget.truncated.includes("body"))
286
387
  budget.truncated.push("body");
@@ -1,6 +1,6 @@
1
1
  import { MAX_CONTENT_BYTES } from "./config.js";
2
2
  import { scrubCredentialText } from "./tool-definitions.js";
3
- const MAX_PROVIDER_ITEMS = 128;
3
+ const MAX_PROVIDER_ITEMS = 1024;
4
4
  const MAX_PROVIDER_DEFINITIONS = 512;
5
5
  const MAX_PROVIDER_SERVERS = 512;
6
6
  function isItem(value) {
package/dist/safety.d.ts CHANGED
@@ -7,6 +7,8 @@ export interface EncodeLimits {
7
7
  nodes: number;
8
8
  depth: number;
9
9
  }
10
+ /** One content attribute's bounds under a receiver's value cap of `valueBytes`. */
11
+ export declare function contentLimitsOf(valueBytes: number): EncodeLimits;
10
12
  /** Validate a bounded data tree without invoking toJSON or property getters. */
11
13
  export declare function encodeContent(value: unknown, limits?: EncodeLimits): string;
12
14
  /**
package/dist/safety.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { INVALID_SPAN_CONTEXT, trace } from "@opentelemetry/api";
2
2
  import { types as utilTypes } from "node:util";
3
- import { MAX_CONTENT_BYTES } from "./config.js";
3
+ import { MAX_CONTENT_BYTES, MAX_RECORD_NODES } from "./config.js";
4
4
  export function noopSpan() {
5
5
  return trace.wrapSpanContext(INVALID_SPAN_CONTEXT);
6
6
  }
@@ -99,8 +99,14 @@ class SafeSpan {
99
99
  export function safeSpan(source, failed) {
100
100
  return new SafeSpan(source, failed);
101
101
  }
102
- /** One content attribute's bounds. */
103
- const contentLimits = { bytes: MAX_CONTENT_BYTES, nodes: 16384, depth: 32 };
102
+ /** One content attribute's bounds: the value cap, and as many values as the Python SDK's content
103
+ * snapshot allows, so a structured value of small members is cut at the cap rather than refused
104
+ * for its count before reaching it. */
105
+ const contentLimits = { bytes: MAX_CONTENT_BYTES, nodes: 65_536, depth: 32 };
106
+ /** One content attribute's bounds under a receiver's value cap of `valueBytes`. */
107
+ export function contentLimitsOf(valueBytes) {
108
+ return { ...contentLimits, bytes: valueBytes };
109
+ }
104
110
  /** Validate a bounded data tree without invoking toJSON or property getters. */
105
111
  export function encodeContent(value, limits = contentLimits) {
106
112
  const { text, truncated } = encodeBoundedContent(value, limits, false);
@@ -212,7 +218,7 @@ export function estimateRecordBytes(value, limit) {
212
218
  let nodes = 0;
213
219
  const seen = new Set();
214
220
  const visit = (item, depth) => {
215
- if (++nodes > 16384 || depth > 32)
221
+ if (++nodes > MAX_RECORD_NODES || depth > 32)
216
222
  throw new RangeError("Telemetry complexity limit exceeded");
217
223
  bytes += 16;
218
224
  if (typeof item === "string")
@@ -1,12 +1,34 @@
1
1
  import type { ReadableSpan } from "@opentelemetry/sdk-trace";
2
2
  import type { ReadableLogRecord, ReadWriteLogRecord } from "@opentelemetry/sdk-logs";
3
- export declare function snapshotSpan(source: ReadableSpan, limit: number, captureContent?: boolean): {
4
- record: ReadableSpan;
3
+ /**
4
+ * How a span's own values over the receiver's value cap are held whole for export to upload:
5
+ * the cap, the bytes the held-value budget has left, and whether nothing else is held (a single
6
+ * value larger than the whole budget is held then). A string is charged two bytes a code unit,
7
+ * bytes as themselves. The queue's byte budget is charged what the value would have cost cut
8
+ * when queued, so a value whose upload fails is exported cut exactly as before.
9
+ */
10
+ export interface HoldLimits {
11
+ valueBytes: number;
12
+ budget: number;
13
+ first: boolean;
14
+ }
15
+ /** A span's own message whose large inline files shrank to their digest when it was queued: the
16
+ * text queued, and how many files it lost. */
17
+ export interface DigestedMessage {
18
+ text: string;
19
+ files: number;
20
+ }
21
+ /** What admission keeps of a record: its copy, the bytes charged for it, whether resource
22
+ * attributes were still unresolved, the value strings it cut, the bytes of the values it held
23
+ * whole for upload, and the span's own messages whose large inline files it shrank to their
24
+ * digest (with how many files each). */
25
+ export interface RecordSnapshot<T> {
26
+ record: T;
5
27
  bytes: number;
6
28
  unresolvedResource: boolean;
7
- };
8
- export declare function snapshotLog(source: ReadableLogRecord, limit: number, captureContent?: boolean): {
9
- record: ReadWriteLogRecord;
10
- bytes: number;
11
- unresolvedResource: boolean;
12
- };
29
+ cut: ReadonlySet<string>;
30
+ held: number;
31
+ digested: ReadonlyMap<string, DigestedMessage>;
32
+ }
33
+ export declare function snapshotSpan(source: ReadableSpan, limit: number, captureContent?: boolean, valueUnits?: number, hold?: HoldLimits): RecordSnapshot<ReadableSpan>;
34
+ export declare function snapshotLog(source: ReadableLogRecord, limit: number, captureContent?: boolean, valueUnits?: number): RecordSnapshot<ReadWriteLogRecord>;