@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.
- package/CLI.md +21 -14
- package/EVALUATIONS.md +1 -1
- package/README.md +112 -19
- package/dist/blobs.d.ts +164 -0
- package/dist/blobs.js +703 -0
- package/dist/cli/eval.js +3 -3
- package/dist/client.d.ts +15 -5
- package/dist/client.js +63 -13
- package/dist/config.d.ts +20 -0
- package/dist/config.js +21 -1
- package/dist/environment/client.d.ts +1 -1
- package/dist/environment/client.js +3 -3
- package/dist/evals/client.d.ts +1 -1
- package/dist/evals/client.js +4 -4
- package/dist/evals/local-worker.d.ts +1 -1
- package/dist/evals/runner.d.ts +1 -1
- package/dist/evals/runner.js +2 -2
- package/dist/evals/simulation.d.ts +1 -1
- package/dist/evals/verdicts.d.ts +10 -7
- package/dist/evals/verdicts.js +52 -16
- package/dist/inline-files.d.ts +44 -1
- package/dist/inline-files.js +65 -6
- package/dist/limits.d.ts +32 -0
- package/dist/limits.js +54 -0
- package/dist/privacy.d.ts +27 -5
- package/dist/privacy.js +140 -39
- package/dist/provider-tools.js +1 -1
- package/dist/safety.d.ts +2 -0
- package/dist/safety.js +10 -4
- package/dist/snapshot.d.ts +30 -8
- package/dist/snapshot.js +280 -49
- package/dist/tool-definitions.d.ts +4 -0
- package/dist/tool-definitions.js +11 -0
- package/dist/transport.d.ts +26 -0
- package/dist/transport.js +579 -177
- package/dist/types.d.ts +8 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/inline-files.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/limits.d.ts
ADDED
|
@@ -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
|
|
11
|
-
* Hue's receiver writes it for the values it cuts, and
|
|
12
|
-
* before export, under the same name, so a reader learns
|
|
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
|
|
69
|
-
* Hue's receiver writes it for the values it cuts, and
|
|
70
|
-
* before export, under the same name, so a reader learns
|
|
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
|
|
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 >
|
|
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
|
-
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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 <=
|
|
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
|
|
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 +=
|
|
137
|
-
return truncateUtf8(result,
|
|
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 >
|
|
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 <=
|
|
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]) =>
|
|
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 =
|
|
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 =
|
|
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)) >
|
|
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");
|
package/dist/provider-tools.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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 >
|
|
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")
|
package/dist/snapshot.d.ts
CHANGED
|
@@ -1,12 +1,34 @@
|
|
|
1
1
|
import type { ReadableSpan } from "@opentelemetry/sdk-trace";
|
|
2
2
|
import type { ReadableLogRecord, ReadWriteLogRecord } from "@opentelemetry/sdk-logs";
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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>;
|