@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.
@@ -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 large inline files were shrunk to their digest when it was
36
+ /** The span's own messages whose inline media were shrunk to their digest when it was
37
37
  * queued, with how many files each: reported as not uploaded when values may be. */
38
38
  digested?: ReadonlyMap<string, DigestedMessage> | undefined;
39
39
  /**
@@ -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, INLINE_FILE_LIMIT, isMessageKey } from "./inline-files.js";
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
- * a large file; bytes over the cap collected as they are. {@link NOT_HELD} for any other value,
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 large inline file the queue shrank to its digest is reported as not uploaded; what is
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
- const cap = budget.valueBytes;
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
- return truncateUtf8(result, cap);
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
- let body = options.captureContent ? redactValue(log.body, "body", options, budget) : undefined;
381
- if (body !== undefined && typeof body !== "string") {
382
- // A structured body over the cap is replaced by the receiver's marker, as the receiver would
383
- // replace it; the marker names the body's size as recorded, before any text part was cut.
384
- if (Buffer.byteLength(JSON.stringify(body)) > budget.valueBytes) {
385
- body = truncatedMarker(structuredBytes(log.body));
386
- if (!budget.truncated.includes("body"))
387
- budget.truncated.push("body");
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
- return new SafeSpan(source, failed);
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);
@@ -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 large inline files shrank to their digest when it was queued: the
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 large inline files it shrank to their
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>;