@hue-run/sdk 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLI.md +18 -11
- package/EVALUATIONS.md +53 -2
- package/README.md +295 -45
- package/dist/attachment.d.ts +82 -0
- package/dist/attachment.js +225 -0
- package/dist/blobs.d.ts +73 -17
- package/dist/blobs.js +247 -96
- package/dist/cli/eval-direct.d.ts +3 -2
- package/dist/cli/eval-direct.js +49 -6
- package/dist/client.d.ts +21 -1
- package/dist/client.js +217 -32
- package/dist/evals/client.d.ts +14 -1
- package/dist/evals/client.js +73 -9
- package/dist/evals/files.d.ts +11 -1
- package/dist/evals/files.js +172 -20
- package/dist/evals/media.d.ts +217 -0
- package/dist/evals/media.js +907 -0
- package/dist/evals/runner.js +130 -106
- package/dist/evals/types.d.ts +48 -0
- package/dist/evals.d.ts +2 -0
- package/dist/evals.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/inline-files.d.ts +160 -28
- package/dist/inline-files.js +809 -86
- package/dist/json-edits.d.ts +24 -0
- package/dist/json-edits.js +187 -0
- package/dist/privacy.d.ts +14 -1
- package/dist/privacy.js +86 -25
- package/dist/safety.d.ts +7 -2
- package/dist/safety.js +22 -4
- package/dist/snapshot.d.ts +7 -4
- package/dist/snapshot.js +177 -38
- package/dist/transport.d.ts +18 -0
- package/dist/transport.js +544 -38
- package/dist/types.d.ts +28 -7
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edits to recorded JSON text that keep every byte they do not change. Placing a file in a value
|
|
3
|
+
* read from JSON text (an attribute, or a tool call's JSON-encoded arguments) replaces only the
|
|
4
|
+
* file's own value, or the members of the part that held it; the rest of the text, its key order,
|
|
5
|
+
* number spellings and whitespace included, is kept as it was recorded. Mirrors the Python SDK's
|
|
6
|
+
* `_json_edits.py`, so both SDKs write the same bytes.
|
|
7
|
+
*/
|
|
8
|
+
/** One change at a JSON pointer of a document: its value replaced by `value` (JSON text), or,
|
|
9
|
+
* for an object, the member `remove` dropped and each `set` member's value replaced in place or
|
|
10
|
+
* appended. */
|
|
11
|
+
export type JsonEdit = {
|
|
12
|
+
pointer: string;
|
|
13
|
+
value: string;
|
|
14
|
+
} | {
|
|
15
|
+
pointer: string;
|
|
16
|
+
remove?: string;
|
|
17
|
+
set?: readonly [string, string][];
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* `text` with `edits` applied, every byte outside the changed values kept. Each object edit
|
|
21
|
+
* keeps the object's other members, the separators between them and its whitespace; appended
|
|
22
|
+
* members are written compactly (`,"key":value`) before the closing brace.
|
|
23
|
+
*/
|
|
24
|
+
export declare function applyJsonEdits(text: string, edits: readonly JsonEdit[]): string;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edits to recorded JSON text that keep every byte they do not change. Placing a file in a value
|
|
3
|
+
* read from JSON text (an attribute, or a tool call's JSON-encoded arguments) replaces only the
|
|
4
|
+
* file's own value, or the members of the part that held it; the rest of the text, its key order,
|
|
5
|
+
* number spellings and whitespace included, is kept as it was recorded. Mirrors the Python SDK's
|
|
6
|
+
* `_json_edits.py`, so both SDKs write the same bytes.
|
|
7
|
+
*/
|
|
8
|
+
const segment = (key) => key.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
9
|
+
/** The spans of the values at `wanted` pointers of valid JSON `text`, with the members of each
|
|
10
|
+
* object among them. A duplicate key's last member is the one a pointer names. */
|
|
11
|
+
function locate(text, wanted) {
|
|
12
|
+
const prefixes = new Set();
|
|
13
|
+
for (const pointer of wanted)
|
|
14
|
+
for (let at = pointer.length; at >= 0; at = pointer.lastIndexOf("/", at - 1)) {
|
|
15
|
+
prefixes.add(pointer.slice(0, at));
|
|
16
|
+
if (at === 0)
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
const found = new Map();
|
|
20
|
+
const space = (index) => {
|
|
21
|
+
let at = index;
|
|
22
|
+
for (;;) {
|
|
23
|
+
const code = text.charCodeAt(at);
|
|
24
|
+
if (code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d)
|
|
25
|
+
at++;
|
|
26
|
+
else
|
|
27
|
+
return at;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const stringEnd = (index) => {
|
|
31
|
+
let at = index + 1;
|
|
32
|
+
for (;;) {
|
|
33
|
+
const code = text.charCodeAt(at);
|
|
34
|
+
if (code === 0x22)
|
|
35
|
+
return at + 1;
|
|
36
|
+
at += code === 0x5c ? 2 : 1;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
/** The end of the value starting at `index`, and an object's members when asked. */
|
|
40
|
+
const valueEnd = (index, members) => {
|
|
41
|
+
const code = text.charCodeAt(index);
|
|
42
|
+
if (code === 0x22)
|
|
43
|
+
return stringEnd(index);
|
|
44
|
+
if (code === 0x7b || code === 0x5b) {
|
|
45
|
+
const object = code === 0x7b;
|
|
46
|
+
let at = space(index + 1);
|
|
47
|
+
if (text.charCodeAt(at) === (object ? 0x7d : 0x5d))
|
|
48
|
+
return at + 1;
|
|
49
|
+
for (;;) {
|
|
50
|
+
let key = "";
|
|
51
|
+
let start = at;
|
|
52
|
+
if (object) {
|
|
53
|
+
const keyEnd = stringEnd(at);
|
|
54
|
+
if (members)
|
|
55
|
+
key = JSON.parse(text.slice(at, keyEnd));
|
|
56
|
+
at = space(space(keyEnd) + 1);
|
|
57
|
+
}
|
|
58
|
+
const valueStart = at;
|
|
59
|
+
const end = valueEnd(at);
|
|
60
|
+
if (members)
|
|
61
|
+
members.push({ key, start, valueStart, end });
|
|
62
|
+
at = space(end);
|
|
63
|
+
if (text.charCodeAt(at) === 0x2c) {
|
|
64
|
+
at = space(at + 1);
|
|
65
|
+
start = at;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
return at + 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
let at = index;
|
|
72
|
+
for (;;) {
|
|
73
|
+
const code = text.charCodeAt(at);
|
|
74
|
+
// A number's characters, or a literal's letters.
|
|
75
|
+
if ((code >= 0x30 && code <= 0x39) ||
|
|
76
|
+
code === 0x2d ||
|
|
77
|
+
code === 0x2b ||
|
|
78
|
+
code === 0x2e ||
|
|
79
|
+
(code >= 0x61 && code <= 0x7a) ||
|
|
80
|
+
code === 0x45)
|
|
81
|
+
at++;
|
|
82
|
+
else
|
|
83
|
+
return at;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const visit = (index, pointer) => {
|
|
87
|
+
const code = text.charCodeAt(index);
|
|
88
|
+
const descend = code === 0x7b || code === 0x5b;
|
|
89
|
+
const members = descend ? [] : undefined;
|
|
90
|
+
const end = valueEnd(index, members);
|
|
91
|
+
if (wanted.has(pointer))
|
|
92
|
+
found.set(pointer, code === 0x7b ? { start: index, end, members } : { start: index, end });
|
|
93
|
+
if (!members)
|
|
94
|
+
return;
|
|
95
|
+
const last = new Map();
|
|
96
|
+
members.forEach((member, position) => last.set(code === 0x7b ? member.key : String(position), member));
|
|
97
|
+
for (const [key, member] of last) {
|
|
98
|
+
const child = `${pointer}/${segment(key)}`;
|
|
99
|
+
if (prefixes.has(child))
|
|
100
|
+
visit(member.valueStart, child);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
visit(space(0), "");
|
|
104
|
+
return found;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* `text` with `edits` applied, every byte outside the changed values kept. Each object edit
|
|
108
|
+
* keeps the object's other members, the separators between them and its whitespace; appended
|
|
109
|
+
* members are written compactly (`,"key":value`) before the closing brace.
|
|
110
|
+
*/
|
|
111
|
+
export function applyJsonEdits(text, edits) {
|
|
112
|
+
if (!edits.length)
|
|
113
|
+
return text;
|
|
114
|
+
const values = new Map();
|
|
115
|
+
const objects = new Map();
|
|
116
|
+
for (const edit of edits) {
|
|
117
|
+
if ("value" in edit)
|
|
118
|
+
values.set(edit.pointer, edit.value);
|
|
119
|
+
else {
|
|
120
|
+
const object = objects.get(edit.pointer) ?? { remove: new Set(), set: new Map() };
|
|
121
|
+
if (edit.remove !== undefined)
|
|
122
|
+
object.remove.add(edit.remove);
|
|
123
|
+
for (const [key, value] of edit.set ?? [])
|
|
124
|
+
object.set.set(key, value);
|
|
125
|
+
objects.set(edit.pointer, object);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const located = locate(text, new Set([...values.keys(), ...objects.keys()]));
|
|
129
|
+
const pieces = [];
|
|
130
|
+
for (const [pointer, value] of values) {
|
|
131
|
+
const span = located.get(pointer);
|
|
132
|
+
if (span)
|
|
133
|
+
pieces.push({ start: span.start, end: span.end, render: () => value });
|
|
134
|
+
}
|
|
135
|
+
for (const [pointer, edit] of objects) {
|
|
136
|
+
const span = located.get(pointer);
|
|
137
|
+
if (!span?.members)
|
|
138
|
+
continue;
|
|
139
|
+
const members = span.members;
|
|
140
|
+
// The last member of each key set in place keeps its position; the others are appended.
|
|
141
|
+
const lastOf = new Map();
|
|
142
|
+
for (const member of members)
|
|
143
|
+
lastOf.set(member.key, member);
|
|
144
|
+
for (const [key, value] of edit.set) {
|
|
145
|
+
const member = lastOf.get(key);
|
|
146
|
+
if (member && !edit.remove.has(key))
|
|
147
|
+
pieces.push({ start: member.valueStart, end: member.end, render: () => value });
|
|
148
|
+
}
|
|
149
|
+
pieces.push({
|
|
150
|
+
start: span.start,
|
|
151
|
+
end: span.end,
|
|
152
|
+
render: () => {
|
|
153
|
+
const kept = members.filter((member) => !edit.remove.has(member.key));
|
|
154
|
+
const first = members[0]?.start ?? span.end - 1;
|
|
155
|
+
const close = members.length ? members.at(-1).end : span.end - 1;
|
|
156
|
+
let out = text.slice(span.start, first);
|
|
157
|
+
kept.forEach((member, position) => {
|
|
158
|
+
const index = members.indexOf(member);
|
|
159
|
+
// The separator that preceded this member, comma included, unless it now comes first.
|
|
160
|
+
if (position > 0)
|
|
161
|
+
out += text.slice(members[index - 1].end, member.start);
|
|
162
|
+
out += render(member.start, member.end);
|
|
163
|
+
});
|
|
164
|
+
let appended = "";
|
|
165
|
+
for (const [key, value] of edit.set)
|
|
166
|
+
if (!lastOf.has(key) || edit.remove.has(key))
|
|
167
|
+
appended += `${kept.length || appended ? "," : ""}${JSON.stringify(key)}:${value}`;
|
|
168
|
+
return out + appended + text.slice(close, span.end);
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
pieces.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
173
|
+
/** The text from `start` to `end` with the pieces within it rendered; a piece inside another
|
|
174
|
+
* is rendered by the outer one. */
|
|
175
|
+
const render = (start, end) => {
|
|
176
|
+
let out = "";
|
|
177
|
+
let at = start;
|
|
178
|
+
for (const piece of pieces) {
|
|
179
|
+
if (piece.start < at || piece.end > end)
|
|
180
|
+
continue;
|
|
181
|
+
out += text.slice(at, piece.start) + piece.render();
|
|
182
|
+
at = piece.end;
|
|
183
|
+
}
|
|
184
|
+
return out + text.slice(at, end);
|
|
185
|
+
};
|
|
186
|
+
return render(0, text.length);
|
|
187
|
+
}
|
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
|
|
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
|
/**
|
|
@@ -44,6 +44,19 @@ export interface RedactionLimits {
|
|
|
44
44
|
*/
|
|
45
45
|
offload?: OffloadCollector;
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* A log record's body, placed by the upload step, cut as {@link redactLog} cuts one: each string
|
|
49
|
+
* over the cap cut to it and bytes over it replaced by the receiver's marker, and a structured
|
|
50
|
+
* body still over the cap replaced by the marker. Whether anything was cut, for `hue.truncated`.
|
|
51
|
+
*/
|
|
52
|
+
export declare function capLogBody(body: unknown, valueBytes: number): {
|
|
53
|
+
body: unknown;
|
|
54
|
+
cut: boolean;
|
|
55
|
+
};
|
|
56
|
+
/** Whether a log record is a GenAI event, whose structured body carries messages. */
|
|
57
|
+
export declare function isGenAiLog(log: ReadableLogRecord): boolean;
|
|
58
|
+
/** Whether a log body is structured (an object or array), so a pointer can address inside it. */
|
|
59
|
+
export declare function isStructuredBody(body: unknown): boolean;
|
|
47
60
|
export type ResourceCache = WeakMap<Resource, Resource>;
|
|
48
61
|
export declare function redactSpan(span: ReadableSpan, options: HueOptions, cache: ResourceCache, limits?: RedactionLimits): ReadableSpan;
|
|
49
62
|
export declare function redactLog(log: ReadableLogRecord, options: HueOptions, cache: ResourceCache, limits?: RedactionLimits): ReadableLogRecord;
|
package/dist/privacy.js
CHANGED
|
@@ -2,7 +2,7 @@ import { SpanStatusCode } from "@opentelemetry/api";
|
|
|
2
2
|
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
3
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,
|
|
5
|
+
import { hashInlineFiles, isMediaKey, mayInlineFiles } 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. */
|
|
@@ -142,22 +142,20 @@ function redactedText(value, path, options, cap) {
|
|
|
142
142
|
throw new Error("Redaction produced unsupported text");
|
|
143
143
|
return result;
|
|
144
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
145
|
/** Marks a value the upload step does not place. */
|
|
152
146
|
const NOT_HELD = Symbol("not held");
|
|
153
147
|
/**
|
|
154
148
|
* One of a span's own attribute values, when its values may be uploaded: text through the
|
|
155
149
|
* credential scrub and the redactor, whole, and collected when it is over the cap or may inline
|
|
156
|
-
*
|
|
150
|
+
* media; bytes over the cap collected as they are. {@link NOT_HELD} for any other value,
|
|
157
151
|
* and for text the queue cut when the record was admitted, which is never the whole value.
|
|
158
152
|
*/
|
|
159
153
|
function offloadValue(key, value, path, options, budget) {
|
|
160
154
|
const collector = budget.offload;
|
|
155
|
+
// A log record uploads only the media in its inspected attributes: anything else is redacted
|
|
156
|
+
// and cut as before.
|
|
157
|
+
if (collector.log && (typeof value !== "string" || !isMediaKey(key) || budget.cut?.has(value)))
|
|
158
|
+
return NOT_HELD;
|
|
161
159
|
if (value instanceof Uint8Array) {
|
|
162
160
|
if (value.byteLength <= budget.valueBytes)
|
|
163
161
|
return NOT_HELD;
|
|
@@ -168,7 +166,7 @@ function offloadValue(key, value, path, options, budget) {
|
|
|
168
166
|
}
|
|
169
167
|
if (typeof value !== "string")
|
|
170
168
|
return NOT_HELD;
|
|
171
|
-
// Each
|
|
169
|
+
// Each inline media part the queue shrank to its digest is reported as not uploaded; what is
|
|
172
170
|
// left of the message is placed like any other value, and reported too if it is cut.
|
|
173
171
|
const digested = budget.digested?.get(key);
|
|
174
172
|
const files = digested?.text === value ? digested.files : 0;
|
|
@@ -184,6 +182,13 @@ function offloadValue(key, value, path, options, budget) {
|
|
|
184
182
|
if (++budget.nodes > MAX_RECORD_NODES)
|
|
185
183
|
throw new Error("Telemetry value exceeds the supported nesting limit");
|
|
186
184
|
const result = redactedText(scrubbed, path, options, budget.valueBytes);
|
|
185
|
+
if (collector.log && !mayInlineFiles(key, result)) {
|
|
186
|
+
if (Buffer.byteLength(result) <= budget.valueBytes)
|
|
187
|
+
return result;
|
|
188
|
+
if (!budget.truncated.includes(key))
|
|
189
|
+
budget.truncated.push(key);
|
|
190
|
+
return truncateUtf8(result, budget.valueBytes);
|
|
191
|
+
}
|
|
187
192
|
if (mayInlineFiles(key, result) ||
|
|
188
193
|
(result.length * 3 > budget.valueBytes && Buffer.byteLength(result) > budget.valueBytes))
|
|
189
194
|
collector.candidates.push({ key, value: result });
|
|
@@ -196,18 +201,19 @@ function offloadValue(key, value, path, options, budget) {
|
|
|
196
201
|
* is exported whole with the rest of its evidence, and a reader knows the one value is partial.
|
|
197
202
|
* Dropping the record for a large value lost every call, result and reply it recorded.
|
|
198
203
|
*/
|
|
199
|
-
function redactValue(value, path, options, budget, depth = 0, listAs = path) {
|
|
204
|
+
function redactValue(value, path, options, budget, depth = 0, listAs = path, whole = false) {
|
|
200
205
|
if (++budget.nodes > MAX_RECORD_NODES || depth > 32)
|
|
201
206
|
throw new Error("Telemetry value exceeds the supported nesting limit");
|
|
202
207
|
if (typeof value === "string") {
|
|
203
|
-
|
|
208
|
+
// `whole`: a log record's body left for the upload step, which cuts what it does not upload.
|
|
209
|
+
const cap = whole && budget.cut?.has(value) !== true ? Infinity : budget.valueBytes;
|
|
204
210
|
// The redactor sees the recorded text past the cap, so a secret that crosses the cap is still
|
|
205
211
|
// its to recognize; what it returns is cut to the cap afterwards. Text the queue cut when the
|
|
206
212
|
// record was admitted (far past the cap) reached the redactor without its continuation, so
|
|
207
213
|
// the end of the answer, which may hold the start of a secret it could not recognize, is
|
|
208
214
|
// never exported.
|
|
209
215
|
const cutWhenQueued = budget.cut?.has(value) === true;
|
|
210
|
-
let result = redactedText(value, path, options, cap);
|
|
216
|
+
let result = redactedText(value, path, options, Math.min(cap, budget.valueBytes));
|
|
211
217
|
if (cutWhenQueued && options.redact)
|
|
212
218
|
result = withoutTail(result, REDACTION_CONTEXT_UNITS);
|
|
213
219
|
const bytes = Buffer.byteLength(result);
|
|
@@ -220,7 +226,11 @@ function redactValue(value, path, options, budget, depth = 0, listAs = path) {
|
|
|
220
226
|
if (!budget.truncated.includes(listAs))
|
|
221
227
|
budget.truncated.push(listAs);
|
|
222
228
|
budget.bytes += cap;
|
|
223
|
-
|
|
229
|
+
const cut = truncateUtf8(result, cap);
|
|
230
|
+
// A log body's string the queue cut is no whole file: the upload step leaves it as cut.
|
|
231
|
+
if (whole && cutWhenQueued && budget.offload)
|
|
232
|
+
(budget.offload.bodyCut ??= new Set()).add(cut);
|
|
233
|
+
return cut;
|
|
224
234
|
}
|
|
225
235
|
// A value the queue replaced by the receiver's marker (a tool definition or recorded request
|
|
226
236
|
// too long to scrub when it was queued) is listed as cut.
|
|
@@ -232,7 +242,7 @@ function redactValue(value, path, options, budget, depth = 0, listAs = path) {
|
|
|
232
242
|
if (Array.isArray(value)) {
|
|
233
243
|
if (value.length > MAX_RECORD_NODES)
|
|
234
244
|
throw new Error("Telemetry array exceeds the complexity limit");
|
|
235
|
-
return value.map((item, index) => redactValue(item, `${path}.${index}`, options, budget, depth + 1, listAs));
|
|
245
|
+
return value.map((item, index) => redactValue(item, `${path}.${index}`, options, budget, depth + 1, listAs, whole));
|
|
236
246
|
}
|
|
237
247
|
if (value instanceof Uint8Array) {
|
|
238
248
|
if (value.byteLength <= budget.valueBytes) {
|
|
@@ -247,10 +257,51 @@ function redactValue(value, path, options, budget, depth = 0, listAs = path) {
|
|
|
247
257
|
if (value !== null && typeof value === "object")
|
|
248
258
|
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
249
259
|
key,
|
|
250
|
-
redactValue(item, `${path}.${key}`, options, budget, depth + 1, listAs),
|
|
260
|
+
redactValue(item, `${path}.${key}`, options, budget, depth + 1, listAs, whole),
|
|
251
261
|
]));
|
|
252
262
|
return value;
|
|
253
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* A log record's body, placed by the upload step, cut as {@link redactLog} cuts one: each string
|
|
266
|
+
* over the cap cut to it and bytes over it replaced by the receiver's marker, and a structured
|
|
267
|
+
* body still over the cap replaced by the marker. Whether anything was cut, for `hue.truncated`.
|
|
268
|
+
*/
|
|
269
|
+
export function capLogBody(body, valueBytes) {
|
|
270
|
+
let cut = false;
|
|
271
|
+
const visit = (value) => {
|
|
272
|
+
if (typeof value === "string") {
|
|
273
|
+
if (Buffer.byteLength(value) <= valueBytes)
|
|
274
|
+
return value;
|
|
275
|
+
cut = true;
|
|
276
|
+
return truncateUtf8(value, valueBytes);
|
|
277
|
+
}
|
|
278
|
+
if (value instanceof Uint8Array) {
|
|
279
|
+
if (value.byteLength <= valueBytes)
|
|
280
|
+
return value;
|
|
281
|
+
cut = true;
|
|
282
|
+
return truncatedMarker(value.byteLength);
|
|
283
|
+
}
|
|
284
|
+
if (Array.isArray(value))
|
|
285
|
+
return value.map(visit);
|
|
286
|
+
if (value !== null && typeof value === "object")
|
|
287
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, visit(item)]));
|
|
288
|
+
return value;
|
|
289
|
+
};
|
|
290
|
+
const size = structuredBytes(body);
|
|
291
|
+
const result = visit(body);
|
|
292
|
+
if (result !== null && typeof result === "object" && structuredBytes(result) > valueBytes)
|
|
293
|
+
return { body: truncatedMarker(size), cut: true };
|
|
294
|
+
return { body: result, cut };
|
|
295
|
+
}
|
|
296
|
+
/** Whether a log record is a GenAI event, whose structured body carries messages. */
|
|
297
|
+
export function isGenAiLog(log) {
|
|
298
|
+
const name = log.eventName ?? log.attributes["event.name"];
|
|
299
|
+
return typeof name === "string" && name.startsWith("gen_ai.");
|
|
300
|
+
}
|
|
301
|
+
/** Whether a log body is structured (an object or array), so a pointer can address inside it. */
|
|
302
|
+
export function isStructuredBody(body) {
|
|
303
|
+
return body !== null && typeof body === "object" && !(body instanceof Uint8Array);
|
|
304
|
+
}
|
|
254
305
|
/**
|
|
255
306
|
* A record's attributes through the redactor. `listPrefix` is how a cut value of these
|
|
256
307
|
* attributes is listed under the record's `hue.truncated`: the key alone for the record's own
|
|
@@ -271,7 +322,7 @@ function attributes(source, options, path, budget, listPrefix = "", own = false)
|
|
|
271
322
|
return [
|
|
272
323
|
[
|
|
273
324
|
key,
|
|
274
|
-
redactValue(scrubToolCredentials(key, hashInlineFiles(key, value)), `${path}.${key}`, options, budget, 0, `${listPrefix}${key}`),
|
|
325
|
+
redactValue(scrubToolCredentials(key, own === true ? hashInlineFiles(key, value) : value), `${path}.${key}`, options, budget, 0, `${listPrefix}${key}`),
|
|
275
326
|
],
|
|
276
327
|
];
|
|
277
328
|
}));
|
|
@@ -377,17 +428,27 @@ export function redactSpan(span, options, cache, limits = {}) {
|
|
|
377
428
|
}
|
|
378
429
|
export function redactLog(log, options, cache, limits = {}) {
|
|
379
430
|
const budget = budgetOf(limits);
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
//
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
431
|
+
const offload = limits.offload;
|
|
432
|
+
let body;
|
|
433
|
+
if (offload && options.captureContent && isGenAiLog(log) && isStructuredBody(log.body)) {
|
|
434
|
+
// Left whole for the upload step, which uploads its media and cuts what is left over the cap.
|
|
435
|
+
body = redactValue(log.body, "body", options, budget, 0, "body", true);
|
|
436
|
+
offload.body = { value: body };
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
body = options.captureContent ? redactValue(log.body, "body", options, budget) : undefined;
|
|
440
|
+
if (body !== undefined && typeof body !== "string") {
|
|
441
|
+
// A structured body over the cap is replaced by the receiver's marker, as the receiver
|
|
442
|
+
// would replace it; the marker names the body's size as recorded, before any text part
|
|
443
|
+
// was cut.
|
|
444
|
+
if (Buffer.byteLength(JSON.stringify(body)) > budget.valueBytes) {
|
|
445
|
+
body = truncatedMarker(structuredBytes(log.body));
|
|
446
|
+
if (!budget.truncated.includes("body"))
|
|
447
|
+
budget.truncated.push("body");
|
|
448
|
+
}
|
|
388
449
|
}
|
|
389
450
|
}
|
|
390
|
-
const redactedAttributes = attributes(log.attributes, options, "attributes", budget);
|
|
451
|
+
const redactedAttributes = attributes(log.attributes, options, "attributes", budget, "", offload ? "log" : false);
|
|
391
452
|
const resource = redactResource(log.resource, options, cache, budget);
|
|
392
453
|
const scopeAttributes = log.instrumentationScope.attributes
|
|
393
454
|
? attributes(log.instrumentationScope.attributes, options, "scope.attributes", budget, "scope.")
|
package/dist/safety.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type Span } from "@opentelemetry/api";
|
|
2
|
+
import { Attachment } from "./attachment.js";
|
|
2
3
|
export declare function noopSpan(): Span;
|
|
3
4
|
export declare function safeSpan(source: Span, failed: () => void): Span;
|
|
5
|
+
export declare function spanSource(span: Span): Span;
|
|
4
6
|
/** The bounds `encodeContent` enforces while it copies a value. */
|
|
5
7
|
export interface EncodeLimits {
|
|
6
8
|
bytes: number;
|
|
@@ -9,8 +11,11 @@ export interface EncodeLimits {
|
|
|
9
11
|
}
|
|
10
12
|
/** One content attribute's bounds under a receiver's value cap of `valueBytes`. */
|
|
11
13
|
export declare function contentLimitsOf(valueBytes: number): EncodeLimits;
|
|
14
|
+
/** Writes an {@link Attachment} met at `pointer`, `depth` levels deep: the JSON value written in
|
|
15
|
+
* its place. Without one, an attachment is no JSON data. */
|
|
16
|
+
export type PlaceAttachment = (attachment: Attachment, pointer: string, depth: number) => unknown;
|
|
12
17
|
/** Validate a bounded data tree without invoking toJSON or property getters. */
|
|
13
|
-
export declare function encodeContent(value: unknown, limits?: EncodeLimits): string;
|
|
18
|
+
export declare function encodeContent(value: unknown, limits?: EncodeLimits, place?: PlaceAttachment): string;
|
|
14
19
|
/**
|
|
15
20
|
* The value as JSON text within `limits.bytes`, and whether it was cut to fit. A value over the
|
|
16
21
|
* cap is cut to a UTF-8 prefix of its JSON text at the cap, as Hue's receiver cuts a value over
|
|
@@ -21,7 +26,7 @@ export declare function encodeContent(value: unknown, limits?: EncodeLimits): st
|
|
|
21
26
|
* among its members is never a shorter document that reads whole: the prefix ends at the cap,
|
|
22
27
|
* inside whatever member the cap fell in.
|
|
23
28
|
*/
|
|
24
|
-
export declare function encodeBoundedContent(value: unknown, limits?: EncodeLimits, cut?: boolean): {
|
|
29
|
+
export declare function encodeBoundedContent(value: unknown, limits?: EncodeLimits, cut?: boolean, place?: PlaceAttachment): {
|
|
25
30
|
text: string;
|
|
26
31
|
truncated: boolean;
|
|
27
32
|
};
|
package/dist/safety.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { INVALID_SPAN_CONTEXT, trace } from "@opentelemetry/api";
|
|
2
2
|
import { types as utilTypes } from "node:util";
|
|
3
|
+
import { Attachment, pointerSegment } from "./attachment.js";
|
|
3
4
|
import { MAX_CONTENT_BYTES, MAX_RECORD_NODES } from "./config.js";
|
|
4
5
|
export function noopSpan() {
|
|
5
6
|
return trace.wrapSpanContext(INVALID_SPAN_CONTEXT);
|
|
@@ -96,8 +97,14 @@ class SafeSpan {
|
|
|
96
97
|
this.write(() => this.source.recordException(...args));
|
|
97
98
|
}
|
|
98
99
|
}
|
|
100
|
+
const spanSources = new WeakMap();
|
|
99
101
|
export function safeSpan(source, failed) {
|
|
100
|
-
|
|
102
|
+
const span = new SafeSpan(source, failed);
|
|
103
|
+
spanSources.set(span, spanSource(source));
|
|
104
|
+
return span;
|
|
105
|
+
}
|
|
106
|
+
export function spanSource(span) {
|
|
107
|
+
return spanSources.get(span) ?? span;
|
|
101
108
|
}
|
|
102
109
|
/** One content attribute's bounds: the value cap, and as many values as the Python SDK's content
|
|
103
110
|
* snapshot allows, so a structured value of small members is cut at the cap rather than refused
|
|
@@ -108,8 +115,8 @@ export function contentLimitsOf(valueBytes) {
|
|
|
108
115
|
return { ...contentLimits, bytes: valueBytes };
|
|
109
116
|
}
|
|
110
117
|
/** Validate a bounded data tree without invoking toJSON or property getters. */
|
|
111
|
-
export function encodeContent(value, limits = contentLimits) {
|
|
112
|
-
const { text, truncated } = encodeBoundedContent(value, limits, false);
|
|
118
|
+
export function encodeContent(value, limits = contentLimits, place) {
|
|
119
|
+
const { text, truncated } = encodeBoundedContent(value, limits, false, place);
|
|
113
120
|
if (truncated)
|
|
114
121
|
throw new RangeError("Content limit exceeded");
|
|
115
122
|
return text;
|
|
@@ -124,12 +131,14 @@ export function encodeContent(value, limits = contentLimits) {
|
|
|
124
131
|
* among its members is never a shorter document that reads whole: the prefix ends at the cap,
|
|
125
132
|
* inside whatever member the cap fell in.
|
|
126
133
|
*/
|
|
127
|
-
export function encodeBoundedContent(value, limits = contentLimits, cut = true) {
|
|
134
|
+
export function encodeBoundedContent(value, limits = contentLimits, cut = true, place) {
|
|
128
135
|
let nodes = 0;
|
|
129
136
|
let bytes = 0;
|
|
130
137
|
let truncated = false;
|
|
131
138
|
const parts = [];
|
|
132
139
|
const ancestors = new Set();
|
|
140
|
+
// The keys from the root to the value being written, kept only to place attachments.
|
|
141
|
+
const path = [];
|
|
133
142
|
const write = (text) => {
|
|
134
143
|
parts.push(text);
|
|
135
144
|
bytes += Buffer.byteLength(text);
|
|
@@ -166,6 +175,11 @@ export function encodeBoundedContent(value, limits = contentLimits, cut = true)
|
|
|
166
175
|
// The native check also handles revoked proxies without invoking their traps.
|
|
167
176
|
if (utilTypes.isProxy(item))
|
|
168
177
|
throw new TypeError("JSON proxies are unsupported");
|
|
178
|
+
if (place && item instanceof Attachment) {
|
|
179
|
+
const pointer = path.length ? `/${path.map(pointerSegment).join("/")}` : "";
|
|
180
|
+
write(JSON.stringify(place(item, pointer, depth) ?? null));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
169
183
|
const array = Array.isArray(item);
|
|
170
184
|
if (!array && ![Object.prototype, null].includes(Object.getPrototypeOf(item)))
|
|
171
185
|
throw new TypeError("Expected JSON data");
|
|
@@ -187,7 +201,11 @@ export function encodeBoundedContent(value, limits = contentLimits, cut = true)
|
|
|
187
201
|
throw new TypeError("Expected JSON data property");
|
|
188
202
|
write(`${first ? "" : ","}${array ? "" : `${JSON.stringify(key)}:`}`);
|
|
189
203
|
first = false;
|
|
204
|
+
if (place)
|
|
205
|
+
path.push(key);
|
|
190
206
|
visit(descriptor.value, depth + 1);
|
|
207
|
+
if (place)
|
|
208
|
+
path.pop();
|
|
191
209
|
}
|
|
192
210
|
write(array ? "]" : "}");
|
|
193
211
|
ancestors.delete(item);
|
package/dist/snapshot.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReadableSpan } from "@opentelemetry/sdk-trace";
|
|
2
2
|
import type { ReadableLogRecord, ReadWriteLogRecord } from "@opentelemetry/sdk-logs";
|
|
3
|
+
import { type MediaDigestMemo } from "./inline-files.js";
|
|
3
4
|
/**
|
|
4
5
|
* How a span's own values over the receiver's value cap are held whole for export to upload:
|
|
5
6
|
* the cap, the bytes the held-value budget has left, and whether nothing else is held (a single
|
|
@@ -12,7 +13,7 @@ export interface HoldLimits {
|
|
|
12
13
|
budget: number;
|
|
13
14
|
first: boolean;
|
|
14
15
|
}
|
|
15
|
-
/** A span's own message whose
|
|
16
|
+
/** A span's own message whose inline media shrank to their digest when it was queued: the
|
|
16
17
|
* text queued, and how many files it lost. */
|
|
17
18
|
export interface DigestedMessage {
|
|
18
19
|
text: string;
|
|
@@ -20,7 +21,7 @@ export interface DigestedMessage {
|
|
|
20
21
|
}
|
|
21
22
|
/** What admission keeps of a record: its copy, the bytes charged for it, whether resource
|
|
22
23
|
* 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
|
|
24
|
+
* whole for upload, and the span's own messages whose inline media it shrank to their
|
|
24
25
|
* digest (with how many files each). */
|
|
25
26
|
export interface RecordSnapshot<T> {
|
|
26
27
|
record: T;
|
|
@@ -29,6 +30,8 @@ export interface RecordSnapshot<T> {
|
|
|
29
30
|
cut: ReadonlySet<string>;
|
|
30
31
|
held: number;
|
|
31
32
|
digested: ReadonlyMap<string, DigestedMessage>;
|
|
33
|
+
/** The span's own message attributes admission inspected or left to export to inspect. */
|
|
34
|
+
inspected: ReadonlySet<string>;
|
|
32
35
|
}
|
|
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>;
|
|
36
|
+
export declare function snapshotSpan(source: ReadableSpan, limit: number, captureContent?: boolean, valueUnits?: number, hold?: HoldLimits, memo?: MediaDigestMemo): RecordSnapshot<ReadableSpan>;
|
|
37
|
+
export declare function snapshotLog(source: ReadableLogRecord, limit: number, captureContent?: boolean, valueUnits?: number, hold?: HoldLimits, memo?: MediaDigestMemo): RecordSnapshot<ReadWriteLogRecord>;
|