@hue-run/sdk 0.17.0 → 0.18.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/dist/snapshot.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createTraceState } from "@opentelemetry/api";
2
2
  import { types as utilTypes } from "node:util";
3
3
  import { resourceFromAttributes } from "@opentelemetry/resources";
4
- import { hashInlineFiles, hashNestedInlineFiles, INLINE_FILE_TEXT_PER_RECORD, MAX_INLINE_FILE_TEXT, isMessageKey, mayInlineFiles, } from "./inline-files.js";
4
+ import { hashInlineFiles, hashNestedInlineFiles, INLINE_FILE_TEXT_PER_RECORD, MAX_INLINE_FILE_TEXT, isMediaKey, isMessageKey, literalKeys, mayInlineFiles, readingOf, } from "./inline-files.js";
5
5
  import { scrubsToolCredentials, scrubToolCredentials } from "./tool-definitions.js";
6
6
  import { MAX_RECORD_NODES } from "./config.js";
7
7
  import { truncatedMarker } from "./privacy.js";
@@ -20,6 +20,25 @@ function cutToUnits(value, units) {
20
20
  // A slice can keep the whole original string alive; the copy holds only what is kept.
21
21
  return Buffer.from(value.slice(0, end), "utf16le").toString("utf16le");
22
22
  }
23
+ /** Whether a long string may be a file's inline content: a `data:` URL or base64 text. */
24
+ function mayBeFileContent(text) {
25
+ return text.startsWith("data:") || (text.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(text));
26
+ }
27
+ /** Whether a log record is a correlated GenAI event with a structured body, whose media may be
28
+ * uploaded under its trace. */
29
+ function offloadsLogBody(source) {
30
+ const name = source.eventName ?? source.attributes?.["event.name"];
31
+ const body = source.body;
32
+ const traceId = source.spanContext?.traceId;
33
+ return (typeof name === "string" &&
34
+ name.startsWith("gen_ai.") &&
35
+ body !== null &&
36
+ typeof body === "object" &&
37
+ !utilTypes.isUint8Array(body) &&
38
+ typeof traceId === "string" &&
39
+ /^[0-9a-f]{32}$/.test(traceId) &&
40
+ !/^0+$/.test(traceId));
41
+ }
23
42
  /** The record's byte or value budget ran out: a span's events and links past it are left out and
24
43
  * counted as dropped, where anything else loses the record. */
25
44
  class BudgetExceeded extends RangeError {
@@ -42,13 +61,25 @@ class Snapshot {
42
61
  copied = new Map();
43
62
  attributesCopied = new Map();
44
63
  ownAttributesCopied = new Map();
64
+ /** Message texts as the queue copies them, their media as their digest: a span's own, and
65
+ * nested or a log record's (small binary media kept). */
45
66
  hashed = new Map();
46
67
  nestedHashed = new Map();
68
+ /** Inspected texts as they would be exported with every file as its digest, by how their
69
+ * attribute is read: what the queue is charged for a value held whole for upload. */
70
+ charged = new Map();
47
71
  inspected = 0;
48
72
  /** Bytes of values held whole for upload, charged to the held-value budget. */
49
73
  held = 0;
50
74
  /** Set while the span's own attributes are copied: only they may be held for upload. */
51
75
  own = false;
76
+ /** The own attributes holding literal text, while they are copied. */
77
+ literal = new Set();
78
+ /** Set for a log record: only its inspected attributes' media and its body's are held. */
79
+ log = false;
80
+ /** Set while a GenAI log record's structured body is copied: a long string that may be a
81
+ * file's content is held whole for upload. */
82
+ holdingBody = false;
52
83
  /** The span's own messages whose inline media shrank to their digest when queued, by
53
84
  * attribute: the text queued (cut or not), and how many files it lost. */
54
85
  digested = new Map();
@@ -90,14 +121,25 @@ class Snapshot {
90
121
  if (!this.hold || !this.own)
91
122
  return undefined;
92
123
  if (typeof value === "string") {
124
+ // Literal text (`input.value` declared `text/plain`) is no JSON Hue reads files from: it is
125
+ // never inspected for media, but a span's value over the cap is still held whole to upload.
126
+ const literal = this.literal.has(key);
127
+ // A log record's value is never uploaded whole: only the media in its inspected attributes.
128
+ if (this.log && (literal || !isMediaKey(key)))
129
+ return undefined;
93
130
  if (value.length > this.valueUnits) {
131
+ if (this.log && (value.length > MAX_INLINE_FILE_TEXT || !mayInlineFiles(key, value)))
132
+ return undefined;
94
133
  // The queue must take the cut the value falls back to, as it would have taken it before.
95
134
  const fallback = this.valueUnits * 2 + 16;
96
135
  if (this.bytes + fallback > this.limit || !this.holds(value.length * 2))
97
136
  return undefined;
98
137
  this.charge(fallback);
99
- // Export parses the held message; its inspection allowance is taken here, in order.
100
- if (value.length <= MAX_INLINE_FILE_TEXT &&
138
+ // Export parses the held message; its inspection allowance is taken here, in order. A
139
+ // log record's export inspects within its own allowance.
140
+ if (!this.log &&
141
+ !literal &&
142
+ value.length <= MAX_INLINE_FILE_TEXT &&
101
143
  mayInlineFiles(key, value) &&
102
144
  this.inspected + value.length <= INLINE_FILE_TEXT_PER_RECORD) {
103
145
  this.inspected += value.length;
@@ -105,19 +147,19 @@ class Snapshot {
105
147
  }
106
148
  return { value };
107
149
  }
108
- // Within the queued length: a recorded message holding an inline media part is held whole
109
- // for export to upload the file, within the held-value budget, and the queue is charged
110
- // its copy with the files as their digest, as before. Past the budget, the files shrink to
111
- // their digest when queued, as before uploads existed.
112
- if (!isMessageKey(key))
150
+ // Within the queued length: an inspected attribute holding inline media is held whole for
151
+ // export to upload the files, within the held-value budget, and the queue is charged its
152
+ // copy with the files as their digest. Past the budget, a recorded message's files shrink to
153
+ // their digest when queued, as before uploads existed; any other value is queued as it is.
154
+ if (!isMediaKey(key) || literal)
113
155
  return undefined;
114
- const digested = this.inlineFiles(key, value);
156
+ const digested = this.inlineFiles(key, value, true);
115
157
  if (digested === value || !this.holds(value.length * 2))
116
158
  return undefined;
117
159
  this.copy(digested, 1, "value", true);
118
160
  return { value };
119
161
  }
120
- if (!utilTypes.isUint8Array(value))
162
+ if (this.log || !utilTypes.isUint8Array(value))
121
163
  return undefined;
122
164
  const length = typedArrayByteLength.call(value);
123
165
  // Bytes over Hue's upload limit could only be exported cut: not copied whole for that.
@@ -198,39 +240,58 @@ class Snapshot {
198
240
  /** The span's own attribute map, whose values over the cap may be held whole for upload. */
199
241
  ownAttributes(value) {
200
242
  this.own = true;
243
+ this.literal = literalKeys(value);
201
244
  try {
202
245
  return this.attributes(value);
203
246
  }
204
247
  finally {
205
248
  this.own = false;
249
+ this.literal = new Set();
206
250
  }
207
251
  }
208
- /** A message attribute with its inline media hashed. Own and nested attributes cache
209
- * separately because small binary media stays inline only in nested attributes. A record
210
- * inspects at most {@link INLINE_FILE_TEXT_PER_RECORD} code units in all; beyond that,
211
- * messages are charged as recorded. */
212
- inlineFiles(key, value) {
213
- if (typeof value !== "string" ||
252
+ /**
253
+ * An attribute with its inline media hashed. As queued (`charge` false), only a recorded message
254
+ * shrinks: a span's own with every binary file and text file over the inline limit as its
255
+ * digest, a nested one's or a log record's keeping small binary media. With `charge`, any
256
+ * inspected attribute as export would place it with every file as its digest, what the queue is
257
+ * charged for a value held whole for upload. Own, nested and charged copies cache separately. A
258
+ * record inspects at most {@link INLINE_FILE_TEXT_PER_RECORD} code units in all; beyond that,
259
+ * values are charged as recorded. Nothing is inspected in metadata-only mode.
260
+ */
261
+ inlineFiles(key, value, charge = false) {
262
+ if (!this.captureContent ||
263
+ typeof value !== "string" ||
214
264
  value.length > MAX_INLINE_FILE_TEXT ||
215
- !mayInlineFiles(key, value))
265
+ !mayInlineFiles(key, value) ||
266
+ (!charge && !isMessageKey(key)))
216
267
  return value;
217
- const hashed = this.own ? this.hashed : this.nestedHashed;
268
+ const own = this.own && !this.log;
269
+ // A span's own message is queued as it is charged: one copy serves both.
270
+ const shared = own && isMessageKey(key);
271
+ let hashed;
272
+ if (charge && !shared) {
273
+ const reading = readingOf(key);
274
+ hashed = this.charged.get(reading) ?? new Map();
275
+ this.charged.set(reading, hashed);
276
+ }
277
+ else
278
+ hashed = own ? this.hashed : this.nestedHashed;
218
279
  if (hashed.has(value)) {
219
- if (this.own)
280
+ if (own)
220
281
  this.inspectedKeys.add(key);
221
282
  return hashed.get(value);
222
283
  }
223
284
  if (this.inspected + value.length > INLINE_FILE_TEXT_PER_RECORD)
224
285
  return value;
225
286
  this.inspected += value.length;
226
- if (this.own)
287
+ if (own)
227
288
  this.inspectedKeys.add(key);
228
289
  const count = { files: 0 };
229
- const result = this.own
230
- ? hashInlineFiles(key, value, count, this.memo, this.traceId)
290
+ const result = charge || own
291
+ ? hashInlineFiles(key, value, count, this.memo, this.traceId, true)
231
292
  : hashNestedInlineFiles(key, value);
232
293
  hashed.set(value, result);
233
- if (this.own && count.files)
294
+ if (shared && count.files)
234
295
  this.hashedFiles.set(value, count.files);
235
296
  return result;
236
297
  }
@@ -276,6 +337,19 @@ class Snapshot {
276
337
  value(value, depth = 1) {
277
338
  return this.copy(value, depth, "value", true);
278
339
  }
340
+ /** A GenAI log record's structured body: strings are cut to the queued value length as in
341
+ * {@link value}, except that a longer string that may be a file's content (a `data:` URL or
342
+ * base64) within the inspection limit is held whole for export to upload, within the
343
+ * held-value budget, the queue charged what its cut would have cost. */
344
+ heldBody(value) {
345
+ this.holdingBody = this.hold !== undefined;
346
+ try {
347
+ return this.copy(value, 1, "value", true);
348
+ }
349
+ finally {
350
+ this.holdingBody = false;
351
+ }
352
+ }
279
353
  copy(value, depth = 0, shape = "value", content = false) {
280
354
  if (++this.nodes > MAX_RECORD_NODES)
281
355
  throw new BudgetExceeded("Telemetry complexity limit exceeded");
@@ -285,6 +359,13 @@ class Snapshot {
285
359
  if (typeof value === "string") {
286
360
  let text = value;
287
361
  if (content && text.length > this.valueUnits) {
362
+ if (this.holdingBody && text.length <= MAX_INLINE_FILE_TEXT && mayBeFileContent(text)) {
363
+ const fallback = this.valueUnits * 2 + 16;
364
+ if (this.bytes + fallback <= this.limit && this.holds(text.length * 2)) {
365
+ this.charge(fallback);
366
+ return text;
367
+ }
368
+ }
288
369
  text = cutToUnits(text, this.valueUnits);
289
370
  this.cut.add(text);
290
371
  }
@@ -361,7 +442,11 @@ class Snapshot {
361
442
  copy[key] = copied;
362
443
  // A span's own message whose inline media shrank to their digest is named as it
363
444
  // was queued, cut or not, so export reports each file as not uploaded.
364
- const files = this.own && shape === "attributes" && typeof source === "string"
445
+ const files = this.own &&
446
+ !this.log &&
447
+ shape === "attributes" &&
448
+ typeof source === "string" &&
449
+ isMessageKey(key)
365
450
  ? this.hashedFiles.get(source)
366
451
  : undefined;
367
452
  if (files && typeof copied === "string")
@@ -462,8 +547,13 @@ export function snapshotSpan(source, limit, captureContent = true, valueUnits =
462
547
  inspected: snapshot.inspectedKeys,
463
548
  };
464
549
  }
465
- export function snapshotLog(source, limit, captureContent = true, valueUnits = Infinity) {
466
- const snapshot = new Snapshot(limit, captureContent, valueUnits);
550
+ export function snapshotLog(source, limit, captureContent = true, valueUnits = Infinity, hold, memo) {
551
+ // A log record's media upload under its own trace: an uncorrelated record holds none.
552
+ const traceId = source.spanContext?.traceId;
553
+ const correlated = typeof traceId === "string" && /^[0-9a-f]{32}$/.test(traceId) && !/^0+$/.test(traceId);
554
+ const holding = correlated ? hold : undefined;
555
+ const snapshot = new Snapshot(limit, captureContent, valueUnits, holding, memo, traceId ?? "");
556
+ snapshot.log = true;
467
557
  // Only our batching processor sees this copy. Its writer methods deliberately
468
558
  // cannot mutate the admitted snapshot or invalidate its charged byte count.
469
559
  const record = {
@@ -476,8 +566,12 @@ export function snapshotLog(source, limit, captureContent = true, valueUnits = I
476
566
  instrumentationScope: source.instrumentationScope,
477
567
  droppedAttributesCount: source.droppedAttributesCount,
478
568
  }),
479
- body: snapshot.value(source.body),
480
- attributes: snapshot.attributes(source.attributes),
569
+ body: holding && captureContent && offloadsLogBody(source)
570
+ ? snapshot.heldBody(source.body)
571
+ : snapshot.value(source.body),
572
+ attributes: holding
573
+ ? snapshot.ownAttributes(source.attributes)
574
+ : snapshot.attributes(source.attributes),
481
575
  spanContext: snapshot.context(source.spanContext),
482
576
  resource: snapshot.resource(source.resource),
483
577
  setAttribute() {
@@ -504,7 +598,7 @@ export function snapshotLog(source, limit, captureContent = true, valueUnits = I
504
598
  bytes: snapshot.bytes,
505
599
  unresolvedResource: snapshot.unresolvedResource,
506
600
  cut: snapshot.cut,
507
- held: 0,
601
+ held: snapshot.held,
508
602
  digested: new Map(),
509
603
  inspected: new Set(),
510
604
  };
@@ -57,6 +57,9 @@ export declare class HueTransport {
57
57
  * over the request limit), reported on the trace's root span as `hue.sdk.dropped_records` when
58
58
  * the root is exported, so Hue reads the trace as incomplete by that many records. */
59
59
  private droppedByTrace;
60
+ /** Whether a trace's root carries `hue.sdk.dropped_records`; off for a replay of recorded
61
+ * spans, which must send the same root on every run. */
62
+ private marksDroppedRecords;
60
63
  private instrumentationFailures;
61
64
  private diagnosticPending;
62
65
  private lastDiagnosticAt;
@@ -87,6 +90,8 @@ export declare class HueTransport {
87
90
  private held;
88
91
  private mediaMemo;
89
92
  private media;
93
+ private logParts;
94
+ private logMedia;
90
95
  private mediaOwners;
91
96
  private mediaTimer?;
92
97
  private mediaFinalizer;
@@ -104,6 +109,11 @@ export declare class HueTransport {
104
109
  private announceLiveSpans;
105
110
  private stopLiveTimer;
106
111
  private stopLiveSpans;
112
+ /** Holds the staged attachments of a log record being admitted with the queued record, within
113
+ * the held-value budget; those the budget refuses are counted as not uploaded. */
114
+ private admitLogParts;
115
+ /** Registers a span whose staged files are released if it is never exported. */
116
+ private ownMedia;
107
117
  private mediaIdentity;
108
118
  private releaseMedia;
109
119
  private protectMedia;