@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/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, INLINE_FILE_LIMIT, INLINE_FILE_TEXT_PER_RECORD, isMessageKey, } 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 {
@@ -30,6 +49,8 @@ class Snapshot {
30
49
  captureContent;
31
50
  valueUnits;
32
51
  hold;
52
+ memo;
53
+ traceId;
33
54
  bytes = 512;
34
55
  unresolvedResource = false;
35
56
  /** The value strings this snapshot cut to `valueUnits`; export recognizes them, so the
@@ -38,28 +59,48 @@ class Snapshot {
38
59
  nodes = 0;
39
60
  ancestors = new Set();
40
61
  copied = new Map();
62
+ attributesCopied = new Map();
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). */
41
66
  hashed = new Map();
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();
42
71
  inspected = 0;
43
72
  /** Bytes of values held whole for upload, charged to the held-value budget. */
44
73
  held = 0;
45
74
  /** Set while the span's own attributes are copied: only they may be held for upload. */
46
75
  own = false;
47
- /** The span's own messages whose large inline files shrank to their digest when queued, by
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;
83
+ /** The span's own messages whose inline media shrank to their digest when queued, by
48
84
  * attribute: the text queued (cut or not), and how many files it lost. */
49
85
  digested = new Map();
50
86
  /** How many files each message text {@link inlineFiles} hashed replaced by their digest. */
51
87
  hashedFiles = new Map();
88
+ /** The span's own message attributes inspected within the record's allowance, whether here or
89
+ * left to export for a message held whole: export inspects only these. */
90
+ inspectedKeys = new Set();
52
91
  /**
53
92
  * In metadata-only mode the recorded messages, which export strips, are not copied at all. A
54
93
  * value string (an attribute's, an event's or link's attribute's, a log body or a status
55
94
  * message) longer than `valueUnits` is cut to that length: export cuts it to the value cap
56
95
  * anyway, and charging it whole could drop the record before that cut ever ran.
57
96
  */
58
- constructor(limit, captureContent = true, valueUnits = Infinity, hold) {
97
+ constructor(limit, captureContent = true, valueUnits = Infinity, hold, memo, traceId = "") {
59
98
  this.limit = limit;
60
99
  this.captureContent = captureContent;
61
100
  this.valueUnits = valueUnits;
62
101
  this.hold = hold;
102
+ this.memo = memo;
103
+ this.traceId = traceId;
63
104
  }
64
105
  /** Whether the held-value budget takes `bytes` more, charging them when it does. */
65
106
  holds(bytes) {
@@ -74,33 +115,51 @@ class Snapshot {
74
115
  * longer than the queued value length or bytes over the cap, within the held-value budget, and
75
116
  * a recorded message that may inline a large file kept as it is (export uploads the file).
76
117
  * Undefined for a value copied as before; text over the length the budget cannot hold is then
77
- * cut and its large inline files shrink to their digest, as before uploads existed.
118
+ * cut and its inline media shrink to their digest, as before uploads existed.
78
119
  */
79
120
  heldValue(key, value) {
80
121
  if (!this.hold || !this.own)
81
122
  return undefined;
82
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;
83
130
  if (value.length > this.valueUnits) {
131
+ if (this.log && (value.length > MAX_INLINE_FILE_TEXT || !mayInlineFiles(key, value)))
132
+ return undefined;
84
133
  // The queue must take the cut the value falls back to, as it would have taken it before.
85
134
  const fallback = this.valueUnits * 2 + 16;
86
135
  if (this.bytes + fallback > this.limit || !this.holds(value.length * 2))
87
136
  return undefined;
88
137
  this.charge(fallback);
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 &&
143
+ mayInlineFiles(key, value) &&
144
+ this.inspected + value.length <= INLINE_FILE_TEXT_PER_RECORD) {
145
+ this.inspected += value.length;
146
+ this.inspectedKeys.add(key);
147
+ }
89
148
  return { value };
90
149
  }
91
- // Within the queued length: a recorded message holding a large inline file is held whole
92
- // for export to upload the file, within the held-value budget, and the queue is charged
93
- // its copy with the files as their digest, as before. Past the budget, the files shrink to
94
- // their digest when queued, as before uploads existed.
95
- 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)
96
155
  return undefined;
97
- const digested = this.inlineFiles(key, value);
156
+ const digested = this.inlineFiles(key, value, true);
98
157
  if (digested === value || !this.holds(value.length * 2))
99
158
  return undefined;
100
159
  this.copy(digested, 1, "value", true);
101
160
  return { value };
102
161
  }
103
- if (!utilTypes.isUint8Array(value))
162
+ if (this.log || !utilTypes.isUint8Array(value))
104
163
  return undefined;
105
164
  const length = typedArrayByteLength.call(value);
106
165
  // Bytes over Hue's upload limit could only be exported cut: not copied whole for that.
@@ -119,7 +178,12 @@ class Snapshot {
119
178
  throw new BudgetExceeded("Telemetry byte budget exceeded");
120
179
  }
121
180
  mark() {
122
- return { bytes: this.bytes, nodes: this.nodes, copied: this.copied.size };
181
+ return {
182
+ bytes: this.bytes,
183
+ nodes: this.nodes,
184
+ copied: this.copied.size,
185
+ attributesCopied: this.attributesCopied.size,
186
+ };
123
187
  }
124
188
  /** Back to `mark`, forgetting the values copied since: a later copy must not reuse a partial
125
189
  * copy of an item that was left out. */
@@ -131,6 +195,10 @@ class Snapshot {
131
195
  for (const key of [...this.copied.keys()])
132
196
  if (index++ >= mark.copied)
133
197
  this.copied.delete(key);
198
+ index = 0;
199
+ for (const key of [...this.attributesCopied.keys()])
200
+ if (index++ >= mark.attributesCopied)
201
+ this.attributesCopied.delete(key);
134
202
  }
135
203
  /**
136
204
  * As many of a span's newest `items` (its events or links) as the record's budget still holds,
@@ -163,7 +231,7 @@ class Snapshot {
163
231
  return { kept, dropped: count - kept.length };
164
232
  }
165
233
  /**
166
- * An attribute map. A large inline file in a message attribute shrinks to its digest before it
234
+ * An attribute map. A inline media part in a message attribute shrinks to its digest before it
167
235
  * is charged: export replaces it anyway, and charging the file could drop the whole record.
168
236
  */
169
237
  attributes(value) {
@@ -172,29 +240,58 @@ class Snapshot {
172
240
  /** The span's own attribute map, whose values over the cap may be held whole for upload. */
173
241
  ownAttributes(value) {
174
242
  this.own = true;
243
+ this.literal = literalKeys(value);
175
244
  try {
176
245
  return this.attributes(value);
177
246
  }
178
247
  finally {
179
248
  this.own = false;
249
+ this.literal = new Set();
180
250
  }
181
251
  }
182
- /** A message attribute with its large inline files hashed. The same text is hashed once per
183
- * record, and a record inspects at most {@link INLINE_FILE_TEXT_PER_RECORD} of it in all;
184
- * beyond that, messages are charged as recorded. */
185
- inlineFiles(key, value) {
186
- // A UTF-16 unit is at most three UTF-8 bytes, so shorter text cannot hold a file to hash.
187
- if (typeof value !== "string" || !isMessageKey(key) || value.length * 3 <= INLINE_FILE_LIMIT)
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" ||
264
+ value.length > MAX_INLINE_FILE_TEXT ||
265
+ !mayInlineFiles(key, value) ||
266
+ (!charge && !isMessageKey(key)))
188
267
  return value;
189
- if (this.hashed.has(value))
190
- return this.hashed.get(value);
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;
279
+ if (hashed.has(value)) {
280
+ if (own)
281
+ this.inspectedKeys.add(key);
282
+ return hashed.get(value);
283
+ }
191
284
  if (this.inspected + value.length > INLINE_FILE_TEXT_PER_RECORD)
192
285
  return value;
193
286
  this.inspected += value.length;
287
+ if (own)
288
+ this.inspectedKeys.add(key);
194
289
  const count = { files: 0 };
195
- const result = hashInlineFiles(key, value, count);
196
- this.hashed.set(value, result);
197
- if (count.files)
290
+ const result = charge || own
291
+ ? hashInlineFiles(key, value, count, this.memo, this.traceId, true)
292
+ : hashNestedInlineFiles(key, value);
293
+ hashed.set(value, result);
294
+ if (shared && count.files)
198
295
  this.hashedFiles.set(value, count.files);
199
296
  return result;
200
297
  }
@@ -240,6 +337,19 @@ class Snapshot {
240
337
  value(value, depth = 1) {
241
338
  return this.copy(value, depth, "value", true);
242
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
+ }
243
353
  copy(value, depth = 0, shape = "value", content = false) {
244
354
  if (++this.nodes > MAX_RECORD_NODES)
245
355
  throw new BudgetExceeded("Telemetry complexity limit exceeded");
@@ -249,6 +359,13 @@ class Snapshot {
249
359
  if (typeof value === "string") {
250
360
  let text = value;
251
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
+ }
252
369
  text = cutToUnits(text, this.valueUnits);
253
370
  this.cut.add(text);
254
371
  }
@@ -266,8 +383,15 @@ class Snapshot {
266
383
  throw new TypeError("Telemetry proxies are unsupported");
267
384
  if (this.ancestors.has(value))
268
385
  throw new TypeError("Cyclic telemetry value");
269
- if (this.copied.has(value))
270
- return this.copied.get(value);
386
+ // One object can be reused as own attributes, nested attributes and an ordinary log body.
387
+ // Their copies differ: only attributes inspect media, and only own attributes upload it.
388
+ const copies = shape === "attributes"
389
+ ? this.own
390
+ ? this.ownAttributesCopied
391
+ : this.attributesCopied
392
+ : this.copied;
393
+ if (copies.has(value))
394
+ return copies.get(value);
271
395
  if (utilTypes.isUint8Array(value)) {
272
396
  // Own accessors/subclasses cannot disguise the retained byte count.
273
397
  // A length-tracking SharedArrayBuffer view can grow on another thread
@@ -277,14 +401,14 @@ class Snapshot {
277
401
  this.charge(length);
278
402
  const copy = new Uint8Array(length);
279
403
  typedArraySet.call(copy, value);
280
- this.copied.set(value, copy);
404
+ copies.set(value, copy);
281
405
  return copy;
282
406
  }
283
407
  const array = Array.isArray(value);
284
408
  if (!array && ![Object.prototype, null].includes(Object.getPrototypeOf(value)))
285
409
  throw new TypeError("Telemetry must contain data objects");
286
410
  const copy = array ? [] : Object.create(null);
287
- this.copied.set(value, copy);
411
+ copies.set(value, copy);
288
412
  this.ancestors.add(value);
289
413
  if (array) {
290
414
  if (value.length > MAX_RECORD_NODES)
@@ -316,9 +440,13 @@ class Snapshot {
316
440
  ? this.scrubbedBeforeCut(key, this.inlineFiles(key, source))
317
441
  : source, depth + 1, shape === "event" && key === "attributes" ? "attributes" : "value", content || shape === "attributes");
318
442
  copy[key] = copied;
319
- // A span's own message whose large inline files shrank to their digest is named as it
443
+ // A span's own message whose inline media shrank to their digest is named as it
320
444
  // was queued, cut or not, so export reports each file as not uploaded.
321
- 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)
322
450
  ? this.hashedFiles.get(source)
323
451
  : undefined;
324
452
  if (files && typeof copied === "string")
@@ -360,7 +488,7 @@ class Snapshot {
360
488
  if (value == null || Object.hasOwn(attributes, key))
361
489
  continue;
362
490
  this.charge(key.length * 2 + 16);
363
- attributes[key] = this.copy(this.scrubbedBeforeCut(key, value), 0, "value", true);
491
+ attributes[key] = this.copy(this.scrubbedBeforeCut(key, this.inlineFiles(key, value)), 0, "value", true);
364
492
  }
365
493
  return resourceFromAttributes(attributes, { schemaUrl: this.copy(source.schemaUrl) });
366
494
  }
@@ -368,8 +496,8 @@ class Snapshot {
368
496
  function contextReader(context) {
369
497
  return () => context;
370
498
  }
371
- export function snapshotSpan(source, limit, captureContent = true, valueUnits = Infinity, hold) {
372
- const snapshot = new Snapshot(limit, captureContent, valueUnits, hold);
499
+ export function snapshotSpan(source, limit, captureContent = true, valueUnits = Infinity, hold, memo) {
500
+ const snapshot = new Snapshot(limit, captureContent, valueUnits, hold, memo, source.spanContext().traceId);
373
501
  const context = snapshot.context(source.spanContext());
374
502
  const fields = snapshot.copy({
375
503
  name: source.name,
@@ -416,10 +544,16 @@ export function snapshotSpan(source, limit, captureContent = true, valueUnits =
416
544
  cut: snapshot.cut,
417
545
  held: snapshot.held,
418
546
  digested: snapshot.digested,
547
+ inspected: snapshot.inspectedKeys,
419
548
  };
420
549
  }
421
- export function snapshotLog(source, limit, captureContent = true, valueUnits = Infinity) {
422
- 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;
423
557
  // Only our batching processor sees this copy. Its writer methods deliberately
424
558
  // cannot mutate the admitted snapshot or invalidate its charged byte count.
425
559
  const record = {
@@ -432,8 +566,12 @@ export function snapshotLog(source, limit, captureContent = true, valueUnits = I
432
566
  instrumentationScope: source.instrumentationScope,
433
567
  droppedAttributesCount: source.droppedAttributesCount,
434
568
  }),
435
- body: snapshot.value(source.body),
436
- 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),
437
575
  spanContext: snapshot.context(source.spanContext),
438
576
  resource: snapshot.resource(source.resource),
439
577
  setAttribute() {
@@ -460,7 +598,8 @@ export function snapshotLog(source, limit, captureContent = true, valueUnits = I
460
598
  bytes: snapshot.bytes,
461
599
  unresolvedResource: snapshot.unresolvedResource,
462
600
  cut: snapshot.cut,
463
- held: 0,
601
+ held: snapshot.held,
464
602
  digested: new Map(),
603
+ inspected: new Set(),
465
604
  };
466
605
  }
@@ -79,11 +79,19 @@ export declare class HueTransport {
79
79
  private holdsReleased;
80
80
  private admissionCuts;
81
81
  private admissionDigests;
82
+ private admissionInspected;
82
83
  /** Uploads values over the inline limit apart from their spans; absent when disabled. */
83
84
  private uploader?;
84
85
  /** Bytes of values over the inline limit queued spans hold whole for upload, and each span's. */
85
86
  private heldBytes;
86
87
  private held;
88
+ private mediaMemo;
89
+ private media;
90
+ private logParts;
91
+ private logMedia;
92
+ private mediaOwners;
93
+ private mediaTimer?;
94
+ private mediaFinalizer;
87
95
  private uploaded;
88
96
  private uploadFallbacks;
89
97
  constructor(options: HueOptions);
@@ -98,6 +106,16 @@ export declare class HueTransport {
98
106
  private announceLiveSpans;
99
107
  private stopLiveTimer;
100
108
  private stopLiveSpans;
109
+ /** Holds the staged attachments of a log record being admitted with the queued record, within
110
+ * the held-value budget; those the budget refuses are counted as not uploaded. */
111
+ private admitLogParts;
112
+ /** Registers a span whose staged files are released if it is never exported. */
113
+ private ownMedia;
114
+ private mediaIdentity;
115
+ private releaseMedia;
116
+ private protectMedia;
117
+ private releaseMediaIdentity;
118
+ private sweepMedia;
101
119
  /** Bounded: a trace whose root never arrives (an abandoned run) gives way to newer ones. */
102
120
  private countDropped;
103
121
  /** Cumulative counters and current queue gauges. */