@hue-run/sdk 0.12.0 → 0.13.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 +14 -7
- package/ENVIRONMENTS.md +18 -4
- package/EVALUATIONS.md +3 -1
- package/README.md +29 -13
- package/dist/cli/eval.js +31 -8
- package/dist/client.d.ts +8 -0
- package/dist/client.js +23 -2
- package/dist/environment/types.d.ts +8 -1
- package/dist/environment/world.d.ts +8 -0
- package/dist/environment/world.js +17 -0
- package/dist/environment.d.ts +1 -1
- package/dist/environment.js +1 -1
- package/dist/evals/failure.d.ts +35 -0
- package/dist/evals/failure.js +252 -0
- package/dist/evals/local-worker.d.ts +1 -1
- package/dist/evals/runner.d.ts +21 -2
- package/dist/evals/runner.js +73 -13
- package/dist/evals/simulation.d.ts +1 -1
- package/dist/evals/types.d.ts +18 -4
- package/dist/evals.d.ts +1 -1
- package/dist/privacy.d.ts +18 -0
- package/dist/privacy.js +147 -57
- package/dist/safety.d.ts +18 -0
- package/dist/safety.js +76 -23
- package/dist/transport.d.ts +5 -0
- package/dist/transport.js +277 -57
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/privacy.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { SpanStatusCode } from "@opentelemetry/api";
|
|
2
2
|
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
3
|
-
import {
|
|
3
|
+
import { MAX_CONTENT_BYTES } from "./config.js";
|
|
4
4
|
import { scrubToolCredentials, withToolCatalogSummary } from "./tool-definitions.js";
|
|
5
5
|
import { hashInlineFiles } from "./inline-files.js";
|
|
6
|
+
import { truncateUtf8 } from "./safety.js";
|
|
7
|
+
export { truncateUtf8 };
|
|
6
8
|
/** Attribute keys (and their dotted children) removed in metadata-only mode. */
|
|
7
9
|
export const contentPrefixes = [
|
|
8
10
|
"gen_ai.input.messages",
|
|
@@ -48,56 +50,120 @@ export const contentPrefixes = [
|
|
|
48
50
|
"ai.embeddings",
|
|
49
51
|
"traceloop.entity.input",
|
|
50
52
|
"traceloop.entity.output",
|
|
53
|
+
// Langfuse content; its model name, usage, cost, type, session, user and metadata keys stay.
|
|
54
|
+
"langfuse.observation.input",
|
|
55
|
+
"langfuse.observation.output",
|
|
56
|
+
"langfuse.observation.status_message",
|
|
57
|
+
"langfuse.observation.model.parameters",
|
|
58
|
+
"langfuse.trace.input",
|
|
59
|
+
"langfuse.trace.output",
|
|
51
60
|
"tool.parameters",
|
|
52
61
|
"exception.message",
|
|
53
62
|
"exception.stacktrace",
|
|
54
63
|
];
|
|
55
64
|
// Kept as a function so metadata-only filtering is applied consistently to every exporter location.
|
|
56
|
-
function isContentKey(key) {
|
|
65
|
+
export function isContentKey(key) {
|
|
57
66
|
return contentPrefixes.some((prefix) => key === prefix || key.startsWith(`${prefix}.`));
|
|
58
67
|
}
|
|
59
|
-
|
|
68
|
+
/** The record attribute listing the keys whose values were cut to fit Hue's 256 KiB value cap:
|
|
69
|
+
* Hue's receiver writes it for the values it cuts, and the SDK writes it for the values it cuts
|
|
70
|
+
* before export, under the same name, so a reader learns of a cut value from one place. */
|
|
71
|
+
export const TRUNCATED_KEY = "hue.truncated";
|
|
72
|
+
/** The size of a structured value the marker replaced. */
|
|
73
|
+
export const TRUNCATED_BYTES_KEY = "hue.truncated_bytes";
|
|
74
|
+
/** The root span attribute counting this trace's records the SDK could not export at all (a
|
|
75
|
+
* record over the request limit even with every content value shed, or one its queue had no
|
|
76
|
+
* room for): what Hue stores of the trace is incomplete by that many records. */
|
|
77
|
+
export const DROPPED_RECORDS_KEY = "hue.sdk.dropped_records";
|
|
78
|
+
/** The serialized size of a structured value as recorded, 0 for one JSON cannot serialize. */
|
|
79
|
+
function structuredBytes(value) {
|
|
80
|
+
try {
|
|
81
|
+
return Buffer.byteLength(JSON.stringify(value) ?? "");
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Hue's receiver's own marker for a structured value it replaced: nothing of the value, its
|
|
88
|
+
* size, and the flag a reader tests. */
|
|
89
|
+
export function truncatedMarker(bytes) {
|
|
90
|
+
return { [TRUNCATED_KEY]: true, [TRUNCATED_BYTES_KEY]: bytes };
|
|
91
|
+
}
|
|
92
|
+
/** `listed` with `keys` added, each once, in order. */
|
|
93
|
+
export function withTruncatedKeys(listed, keys) {
|
|
94
|
+
const result = Array.isArray(listed)
|
|
95
|
+
? listed.filter((item) => typeof item === "string")
|
|
96
|
+
: [];
|
|
97
|
+
for (const key of keys)
|
|
98
|
+
if (!result.includes(key))
|
|
99
|
+
result.push(key);
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* One value through the redactor and Hue's value cap. Text over 256 KiB is cut to a UTF-8 prefix
|
|
104
|
+
* and bytes over it are replaced by the receiver's marker, as the receiver itself does to a
|
|
105
|
+
* value over its cap, and the value's key (`listAs`) is listed under `hue.truncated`: the span
|
|
106
|
+
* is exported whole with the rest of its evidence, and a reader knows the one value is partial.
|
|
107
|
+
* Dropping the record for a large value lost every call, result and reply it recorded.
|
|
108
|
+
*/
|
|
109
|
+
function redactValue(value, path, options, budget, depth = 0, listAs = path) {
|
|
60
110
|
if (++budget.nodes > 16384 || depth > 32)
|
|
61
111
|
throw new Error("Telemetry value exceeds the supported nesting limit");
|
|
62
112
|
if (typeof value === "string") {
|
|
113
|
+
// The redactor sees the whole recorded text, so a secret that crosses the cap is still its
|
|
114
|
+
// to recognize; what it returns is cut to the cap afterwards.
|
|
63
115
|
const result = options.redact ? options.redact(value, path) : value;
|
|
64
116
|
// JavaScript can supply an async redactor despite the synchronous contract.
|
|
65
117
|
// Observe its rejection before dropping the invalid record.
|
|
66
118
|
if (result && typeof result === "object")
|
|
67
119
|
void Promise.resolve(result).catch(() => { });
|
|
120
|
+
// A redactor may shorten or keep its text, never grow it past the cap: an answer longer than
|
|
121
|
+
// both the cap and the text it was given is the redactor's own, refused by its length before
|
|
122
|
+
// anything scans it, since scanning would materialize text the record never held.
|
|
68
123
|
if (typeof result !== "string" ||
|
|
69
|
-
result.length > MAX_CONTENT_BYTES ||
|
|
124
|
+
result.length > Math.max(MAX_CONTENT_BYTES, value.length) ||
|
|
70
125
|
!result.isWellFormed() ||
|
|
71
126
|
result.includes("\u0000"))
|
|
72
127
|
throw new Error("Redaction produced unsupported text");
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
128
|
+
const bytes = Buffer.byteLength(result);
|
|
129
|
+
if (bytes <= MAX_CONTENT_BYTES) {
|
|
130
|
+
budget.bytes += bytes;
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
// Within the cap's code units but over its bytes (multibyte text): cut to the bytes.
|
|
134
|
+
if (!budget.truncated.includes(listAs))
|
|
135
|
+
budget.truncated.push(listAs);
|
|
136
|
+
budget.bytes += MAX_CONTENT_BYTES;
|
|
137
|
+
return truncateUtf8(result, MAX_CONTENT_BYTES);
|
|
79
138
|
}
|
|
80
139
|
if (Array.isArray(value)) {
|
|
81
140
|
if (value.length > 16384)
|
|
82
141
|
throw new Error("Telemetry array exceeds the complexity limit");
|
|
83
|
-
return value.map((item, index) => redactValue(item, `${path}.${index}`, options, budget, depth + 1));
|
|
142
|
+
return value.map((item, index) => redactValue(item, `${path}.${index}`, options, budget, depth + 1, listAs));
|
|
84
143
|
}
|
|
85
144
|
if (value instanceof Uint8Array) {
|
|
86
|
-
if (value.byteLength
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
145
|
+
if (value.byteLength <= MAX_CONTENT_BYTES) {
|
|
146
|
+
budget.bytes += value.byteLength;
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
// Bytes are never cut: a shortened encoding is a different value.
|
|
150
|
+
if (!budget.truncated.includes(listAs))
|
|
151
|
+
budget.truncated.push(listAs);
|
|
152
|
+
return truncatedMarker(value.byteLength);
|
|
92
153
|
}
|
|
93
154
|
if (value !== null && typeof value === "object")
|
|
94
155
|
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
95
156
|
key,
|
|
96
|
-
redactValue(item, `${path}.${key}`, options, budget, depth + 1),
|
|
157
|
+
redactValue(item, `${path}.${key}`, options, budget, depth + 1, listAs),
|
|
97
158
|
]));
|
|
98
159
|
return value;
|
|
99
160
|
}
|
|
100
|
-
|
|
161
|
+
/**
|
|
162
|
+
* A record's attributes through the redactor. `listPrefix` is how a cut value of these
|
|
163
|
+
* attributes is listed under the record's `hue.truncated`: the key alone for the record's own
|
|
164
|
+
* attributes, `event:<name>:<key>` for an event's, as Hue's receiver lists the values it cuts.
|
|
165
|
+
*/
|
|
166
|
+
function attributes(source, options, path, budget, listPrefix = "") {
|
|
101
167
|
// Metadata-only export summarizes the tool definitions it removes by name and digest.
|
|
102
168
|
const summarized = options.captureContent ? source : withToolCatalogSummary(source);
|
|
103
169
|
return Object.fromEntries(Object.entries(summarized).flatMap(([key, value]) => !options.captureContent && isContentKey(key)
|
|
@@ -105,15 +171,25 @@ function attributes(source, options, path, budget) {
|
|
|
105
171
|
: [
|
|
106
172
|
[
|
|
107
173
|
key,
|
|
108
|
-
redactValue(scrubToolCredentials(key, hashInlineFiles(key, value)), `${path}.${key}`, options, budget),
|
|
174
|
+
redactValue(scrubToolCredentials(key, hashInlineFiles(key, value)), `${path}.${key}`, options, budget, 0, `${listPrefix}${key}`),
|
|
109
175
|
],
|
|
110
176
|
]));
|
|
111
177
|
}
|
|
178
|
+
/** The record's attributes with the keys the redaction cut listed under `hue.truncated`, merged
|
|
179
|
+
* with any the application listed itself; unchanged when nothing was cut. */
|
|
180
|
+
function withTruncated(source, budget) {
|
|
181
|
+
if (!budget.truncated.length)
|
|
182
|
+
return source;
|
|
183
|
+
return { ...source, [TRUNCATED_KEY]: withTruncatedKeys(source[TRUNCATED_KEY], budget.truncated) };
|
|
184
|
+
}
|
|
112
185
|
function redactResource(resource, options, cache, budget) {
|
|
113
186
|
let result = cache.get(resource);
|
|
114
187
|
if (!result) {
|
|
115
|
-
|
|
116
|
-
|
|
188
|
+
const cutsBefore = budget.truncated.length;
|
|
189
|
+
result = resourceFromAttributes(attributes(resource.attributes, options, "resource.attributes", budget, "resource."), { schemaUrl: resource.schemaUrl });
|
|
190
|
+
// A resource whose value was cut is redacted for every record, so each lists the cut.
|
|
191
|
+
if (budget.truncated.length === cutsBefore)
|
|
192
|
+
cache.set(resource, result);
|
|
117
193
|
}
|
|
118
194
|
return result;
|
|
119
195
|
}
|
|
@@ -146,7 +222,7 @@ function hostedMcpCall(attributes) {
|
|
|
146
222
|
return { ...(valid ? { serverName: serverLabel } : {}), failed: error != null };
|
|
147
223
|
}
|
|
148
224
|
export function redactSpan(span, options, cache) {
|
|
149
|
-
const budget = { bytes: 0, nodes: 0 };
|
|
225
|
+
const budget = { bytes: 0, nodes: 0, truncated: [] };
|
|
150
226
|
// Derived before metadata-only stripping so the identity survives without the result itself.
|
|
151
227
|
const hosted = hostedMcpCall(span.attributes);
|
|
152
228
|
const source = {
|
|
@@ -154,6 +230,30 @@ export function redactSpan(span, options, cache) {
|
|
|
154
230
|
...(hosted.failed ? { "error.type": "mcp_error" } : {}),
|
|
155
231
|
...span.attributes,
|
|
156
232
|
};
|
|
233
|
+
const status = {
|
|
234
|
+
code: hosted.failed && span.status.code === SpanStatusCode.UNSET
|
|
235
|
+
? SpanStatusCode.ERROR
|
|
236
|
+
: span.status.code,
|
|
237
|
+
...(options.captureContent &&
|
|
238
|
+
span.status.message !== undefined &&
|
|
239
|
+
!(hosted.failed && span.status.code === SpanStatusCode.UNSET)
|
|
240
|
+
? { message: String(redactValue(span.status.message, "status.message", options, budget)) }
|
|
241
|
+
: {}),
|
|
242
|
+
};
|
|
243
|
+
const events = span.events
|
|
244
|
+
.filter((event) => options.captureContent ||
|
|
245
|
+
!/^gen_ai\.(?:system|user|assistant|tool|choice)/.test(event.name))
|
|
246
|
+
.map((event) => ({
|
|
247
|
+
...event,
|
|
248
|
+
attributes: attributes(event.attributes ?? {}, options, `events.${event.name}`, budget, `event:${event.name}:`),
|
|
249
|
+
}));
|
|
250
|
+
const links = span.links.map((link) => ({
|
|
251
|
+
...link,
|
|
252
|
+
attributes: attributes(link.attributes ?? {}, options, "links.attributes", budget, "link:"),
|
|
253
|
+
}));
|
|
254
|
+
const resource = redactResource(span.resource, options, cache, budget);
|
|
255
|
+
// Last, so every cut of the record, its events', links' and resource's included, is listed.
|
|
256
|
+
const redactedAttributes = withTruncated(attributes(source, options, "attributes", budget), budget);
|
|
157
257
|
return {
|
|
158
258
|
name: span.name,
|
|
159
259
|
kind: span.kind,
|
|
@@ -163,29 +263,11 @@ export function redactSpan(span, options, cache) {
|
|
|
163
263
|
endTime: span.endTime,
|
|
164
264
|
duration: span.duration,
|
|
165
265
|
ended: span.ended,
|
|
166
|
-
status
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
span.status.message !== undefined &&
|
|
172
|
-
!(hosted.failed && span.status.code === SpanStatusCode.UNSET)
|
|
173
|
-
? { message: String(redactValue(span.status.message, "status.message", options, budget)) }
|
|
174
|
-
: {}),
|
|
175
|
-
},
|
|
176
|
-
attributes: attributes(source, options, "attributes", budget),
|
|
177
|
-
events: span.events
|
|
178
|
-
.filter((event) => options.captureContent ||
|
|
179
|
-
!/^gen_ai\.(?:system|user|assistant|tool|choice)/.test(event.name))
|
|
180
|
-
.map((event) => ({
|
|
181
|
-
...event,
|
|
182
|
-
attributes: attributes(event.attributes ?? {}, options, `events.${event.name}`, budget),
|
|
183
|
-
})),
|
|
184
|
-
links: span.links.map((link) => ({
|
|
185
|
-
...link,
|
|
186
|
-
attributes: attributes(link.attributes ?? {}, options, "links.attributes", budget),
|
|
187
|
-
})),
|
|
188
|
-
resource: redactResource(span.resource, options, cache, budget),
|
|
266
|
+
status,
|
|
267
|
+
attributes: redactedAttributes,
|
|
268
|
+
events,
|
|
269
|
+
links,
|
|
270
|
+
resource,
|
|
189
271
|
instrumentationScope: span.instrumentationScope,
|
|
190
272
|
droppedAttributesCount: span.droppedAttributesCount,
|
|
191
273
|
droppedEventsCount: span.droppedEventsCount,
|
|
@@ -193,10 +275,22 @@ export function redactSpan(span, options, cache) {
|
|
|
193
275
|
};
|
|
194
276
|
}
|
|
195
277
|
export function redactLog(log, options, cache) {
|
|
196
|
-
const budget = { bytes: 0, nodes: 0 };
|
|
197
|
-
|
|
198
|
-
if (body !== undefined &&
|
|
199
|
-
|
|
278
|
+
const budget = { bytes: 0, nodes: 0, truncated: [] };
|
|
279
|
+
let body = options.captureContent ? redactValue(log.body, "body", options, budget) : undefined;
|
|
280
|
+
if (body !== undefined && typeof body !== "string") {
|
|
281
|
+
// A structured body over the cap is replaced by the receiver's marker, as the receiver would
|
|
282
|
+
// replace it; the marker names the body's size as recorded, before any text part was cut.
|
|
283
|
+
if (Buffer.byteLength(JSON.stringify(body)) > MAX_CONTENT_BYTES) {
|
|
284
|
+
body = truncatedMarker(structuredBytes(log.body));
|
|
285
|
+
if (!budget.truncated.includes("body"))
|
|
286
|
+
budget.truncated.push("body");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const redactedAttributes = attributes(log.attributes, options, "attributes", budget);
|
|
290
|
+
const resource = redactResource(log.resource, options, cache, budget);
|
|
291
|
+
const scopeAttributes = log.instrumentationScope.attributes
|
|
292
|
+
? attributes(log.instrumentationScope.attributes, options, "scope.attributes", budget, "scope.")
|
|
293
|
+
: undefined;
|
|
200
294
|
return {
|
|
201
295
|
hrTime: log.hrTime,
|
|
202
296
|
hrTimeObserved: log.hrTimeObserved,
|
|
@@ -205,15 +299,11 @@ export function redactLog(log, options, cache) {
|
|
|
205
299
|
severityNumber: log.severityNumber,
|
|
206
300
|
eventName: log.eventName,
|
|
207
301
|
body: body,
|
|
208
|
-
attributes:
|
|
209
|
-
resource
|
|
302
|
+
attributes: withTruncated(redactedAttributes, budget),
|
|
303
|
+
resource,
|
|
210
304
|
instrumentationScope: {
|
|
211
305
|
...log.instrumentationScope,
|
|
212
|
-
...(
|
|
213
|
-
? {
|
|
214
|
-
attributes: attributes(log.instrumentationScope.attributes, options, "scope.attributes", budget),
|
|
215
|
-
}
|
|
216
|
-
: {}),
|
|
306
|
+
...(scopeAttributes ? { attributes: scopeAttributes } : {}),
|
|
217
307
|
},
|
|
218
308
|
droppedAttributesCount: log.droppedAttributesCount,
|
|
219
309
|
};
|
package/dist/safety.d.ts
CHANGED
|
@@ -9,5 +9,23 @@ export interface EncodeLimits {
|
|
|
9
9
|
}
|
|
10
10
|
/** Validate a bounded data tree without invoking toJSON or property getters. */
|
|
11
11
|
export declare function encodeContent(value: unknown, limits?: EncodeLimits): string;
|
|
12
|
+
/**
|
|
13
|
+
* The value as JSON text within `limits.bytes`, and whether it was cut to fit. A value over the
|
|
14
|
+
* cap is cut to a UTF-8 prefix of its JSON text at the cap, as Hue's receiver cuts a value over
|
|
15
|
+
* its own cap and as it recognizes a cut (a text at one of its limits), so a span still carries
|
|
16
|
+
* the call, the recorded part of its result and the rest of its evidence; the caller lists the
|
|
17
|
+
* key under `hue.truncated`. The text is written as the value is walked and the walk stops once
|
|
18
|
+
* it is past the budget, so no more than the budget and one value is ever held, and a value cut
|
|
19
|
+
* among its members is never a shorter document that reads whole: the prefix ends at the cap,
|
|
20
|
+
* inside whatever member the cap fell in.
|
|
21
|
+
*/
|
|
22
|
+
export declare function encodeBoundedContent(value: unknown, limits?: EncodeLimits, cut?: boolean): {
|
|
23
|
+
text: string;
|
|
24
|
+
truncated: boolean;
|
|
25
|
+
};
|
|
26
|
+
/** `value` cut to at most `max` UTF-8 bytes, ending on a character boundary. The copy is bounded
|
|
27
|
+
* before the cut: `max` code units hold at least `max` bytes, so a gigabyte of text is never
|
|
28
|
+
* encoded whole to be cut to a quarter megabyte. */
|
|
29
|
+
export declare function truncateUtf8(value: string, max: number): string;
|
|
12
30
|
/** Bounded conservative accounting for the record data retained by our queue, not total process RSS. */
|
|
13
31
|
export declare function estimateRecordBytes(value: unknown, limit: number): number;
|
package/dist/safety.js
CHANGED
|
@@ -12,6 +12,16 @@ class SafeSpan {
|
|
|
12
12
|
this.source = source;
|
|
13
13
|
this.failed = failed;
|
|
14
14
|
}
|
|
15
|
+
/** The SDK span's attributes as they stand (the API alone shows none); undefined for a span
|
|
16
|
+
* that carries none, such as a non-recording span. */
|
|
17
|
+
get attributes() {
|
|
18
|
+
try {
|
|
19
|
+
return this.source.attributes;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
15
25
|
write(work) {
|
|
16
26
|
try {
|
|
17
27
|
const result = work();
|
|
@@ -93,28 +103,56 @@ export function safeSpan(source, failed) {
|
|
|
93
103
|
const contentLimits = { bytes: MAX_CONTENT_BYTES, nodes: 16384, depth: 32 };
|
|
94
104
|
/** Validate a bounded data tree without invoking toJSON or property getters. */
|
|
95
105
|
export function encodeContent(value, limits = contentLimits) {
|
|
106
|
+
const { text, truncated } = encodeBoundedContent(value, limits, false);
|
|
107
|
+
if (truncated)
|
|
108
|
+
throw new RangeError("Content limit exceeded");
|
|
109
|
+
return text;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The value as JSON text within `limits.bytes`, and whether it was cut to fit. A value over the
|
|
113
|
+
* cap is cut to a UTF-8 prefix of its JSON text at the cap, as Hue's receiver cuts a value over
|
|
114
|
+
* its own cap and as it recognizes a cut (a text at one of its limits), so a span still carries
|
|
115
|
+
* the call, the recorded part of its result and the rest of its evidence; the caller lists the
|
|
116
|
+
* key under `hue.truncated`. The text is written as the value is walked and the walk stops once
|
|
117
|
+
* it is past the budget, so no more than the budget and one value is ever held, and a value cut
|
|
118
|
+
* among its members is never a shorter document that reads whole: the prefix ends at the cap,
|
|
119
|
+
* inside whatever member the cap fell in.
|
|
120
|
+
*/
|
|
121
|
+
export function encodeBoundedContent(value, limits = contentLimits, cut = true) {
|
|
96
122
|
let nodes = 0;
|
|
97
123
|
let bytes = 0;
|
|
124
|
+
let truncated = false;
|
|
125
|
+
const parts = [];
|
|
98
126
|
const ancestors = new Set();
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
127
|
+
const write = (text) => {
|
|
128
|
+
parts.push(text);
|
|
129
|
+
bytes += Buffer.byteLength(text);
|
|
130
|
+
if (bytes > limits.bytes) {
|
|
131
|
+
if (!cut)
|
|
132
|
+
throw new RangeError("Content limit exceeded");
|
|
133
|
+
truncated = true;
|
|
134
|
+
}
|
|
103
135
|
};
|
|
104
136
|
const visit = (item, depth) => {
|
|
105
137
|
if (++nodes > limits.nodes || depth > limits.depth)
|
|
106
138
|
throw new RangeError("Content complexity limit exceeded");
|
|
107
139
|
if (typeof item === "string") {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
140
|
+
let text = item;
|
|
141
|
+
if (text.length > limits.bytes || Buffer.byteLength(text) > limits.bytes) {
|
|
142
|
+
if (!cut)
|
|
143
|
+
throw new RangeError("Content limit exceeded");
|
|
144
|
+
// The copy itself is bounded in bytes: a longer string is cut before it is escaped.
|
|
145
|
+
truncated = true;
|
|
146
|
+
text = truncateUtf8(text.slice(0, limits.bytes), limits.bytes);
|
|
147
|
+
}
|
|
148
|
+
write(JSON.stringify(text));
|
|
149
|
+
return;
|
|
112
150
|
}
|
|
113
151
|
if (item === null || typeof item === "boolean" || typeof item === "number") {
|
|
114
152
|
if (typeof item === "number" && !Number.isFinite(item))
|
|
115
153
|
throw new TypeError("Invalid number");
|
|
116
|
-
|
|
117
|
-
return
|
|
154
|
+
write(JSON.stringify(item));
|
|
155
|
+
return;
|
|
118
156
|
}
|
|
119
157
|
if (!item || typeof item !== "object" || ancestors.has(item))
|
|
120
158
|
throw new TypeError("Invalid JSON");
|
|
@@ -126,32 +164,47 @@ export function encodeContent(value, limits = contentLimits) {
|
|
|
126
164
|
if (!array && ![Object.prototype, null].includes(Object.getPrototypeOf(item)))
|
|
127
165
|
throw new TypeError("Expected JSON data");
|
|
128
166
|
ancestors.add(item);
|
|
129
|
-
|
|
130
|
-
const result = array ? [] : Object.create(null);
|
|
167
|
+
write(array ? "[" : "{");
|
|
131
168
|
// Own descriptors avoid executing application accessors during capture.
|
|
132
169
|
const keys = array
|
|
133
170
|
? Array.from({ length: Math.min(item.length, limits.nodes + 1) }, (_, i) => String(i))
|
|
134
171
|
: Object.keys(item);
|
|
135
172
|
if (keys.length > limits.nodes)
|
|
136
173
|
throw new RangeError("Content complexity limit exceeded");
|
|
174
|
+
let first = true;
|
|
137
175
|
for (const key of keys) {
|
|
176
|
+
// Past the budget, the rest is cut anyway: nothing more is copied.
|
|
177
|
+
if (truncated)
|
|
178
|
+
break;
|
|
138
179
|
const descriptor = Object.getOwnPropertyDescriptor(item, key);
|
|
139
180
|
if (!descriptor || !("value" in descriptor))
|
|
140
181
|
throw new TypeError("Expected JSON data property");
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
result.push(child);
|
|
145
|
-
else
|
|
146
|
-
result[key] = child;
|
|
182
|
+
write(`${first ? "" : ","}${array ? "" : `${JSON.stringify(key)}:`}`);
|
|
183
|
+
first = false;
|
|
184
|
+
visit(descriptor.value, depth + 1);
|
|
147
185
|
}
|
|
186
|
+
write(array ? "]" : "}");
|
|
148
187
|
ancestors.delete(item);
|
|
149
|
-
return result;
|
|
150
188
|
};
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
189
|
+
visit(value, 0);
|
|
190
|
+
const encoded = parts.join("");
|
|
191
|
+
if (!truncated)
|
|
192
|
+
return { text: encoded, truncated: false };
|
|
193
|
+
// A cut value is past the cap as written (a cut text alone, with its quotes, is over it), so
|
|
194
|
+
// the prefix ends at the cap, where the receiver recognizes Hue's cut.
|
|
195
|
+
return { text: truncateUtf8(encoded, limits.bytes), truncated: true };
|
|
196
|
+
}
|
|
197
|
+
/** `value` cut to at most `max` UTF-8 bytes, ending on a character boundary. The copy is bounded
|
|
198
|
+
* before the cut: `max` code units hold at least `max` bytes, so a gigabyte of text is never
|
|
199
|
+
* encoded whole to be cut to a quarter megabyte. */
|
|
200
|
+
export function truncateUtf8(value, max) {
|
|
201
|
+
if (Buffer.byteLength(value, "utf8") <= max)
|
|
202
|
+
return value;
|
|
203
|
+
const bytes = Buffer.from(value.slice(0, max), "utf8");
|
|
204
|
+
let end = Math.min(max, bytes.byteLength);
|
|
205
|
+
while (end > 0 && end < bytes.byteLength && (bytes[end] & 0xc0) === 0x80)
|
|
206
|
+
end--;
|
|
207
|
+
return bytes.toString("utf8", 0, end);
|
|
155
208
|
}
|
|
156
209
|
/** Bounded conservative accounting for the record data retained by our queue, not total process RSS. */
|
|
157
210
|
export function estimateRecordBytes(value, limit) {
|
package/dist/transport.d.ts
CHANGED
|
@@ -42,6 +42,10 @@ export declare class HueTransport {
|
|
|
42
42
|
private logs;
|
|
43
43
|
private pendingBytes;
|
|
44
44
|
private dropped;
|
|
45
|
+
/** Records of each trace the SDK never sent (dropped from the queue, refused as invalid or
|
|
46
|
+
* over the request limit), reported on the trace's root span as `hue.sdk.dropped_records` when
|
|
47
|
+
* the root is exported, so Hue reads the trace as incomplete by that many records. */
|
|
48
|
+
private droppedByTrace;
|
|
45
49
|
private instrumentationFailures;
|
|
46
50
|
private diagnosticPending;
|
|
47
51
|
private lastDiagnosticAt;
|
|
@@ -53,6 +57,7 @@ export declare class HueTransport {
|
|
|
53
57
|
private live;
|
|
54
58
|
private liveTimer?;
|
|
55
59
|
private liveSpans;
|
|
60
|
+
private readonly announcesLiveSpans;
|
|
56
61
|
private placeholdersRejected;
|
|
57
62
|
private placeholders;
|
|
58
63
|
private placeholderSources;
|