@hue-run/sdk 0.18.1 → 0.19.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 +16 -12
- package/EVALUATIONS.md +1 -1
- package/README.md +135 -74
- package/dist/attachment.d.ts +51 -7
- package/dist/attachment.js +141 -33
- package/dist/blobs.d.ts +136 -40
- package/dist/blobs.js +535 -225
- package/dist/cli/langfuse.d.ts +21 -5
- package/dist/cli/langfuse.js +123 -16
- package/dist/client.d.ts +20 -11
- package/dist/client.js +81 -41
- package/dist/inline-files.d.ts +5 -4
- package/dist/inline-files.js +6 -5
- package/dist/json-edits.js +26 -7
- package/dist/large-media.d.ts +118 -0
- package/dist/large-media.js +422 -0
- package/dist/snapshot.d.ts +2 -0
- package/dist/snapshot.js +19 -1
- package/dist/transport.d.ts +39 -7
- package/dist/transport.js +262 -75
- package/dist/types.d.ts +15 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/cli/langfuse.d.ts
CHANGED
|
@@ -86,12 +86,15 @@ export declare function readObservation(value: unknown): LangfuseObservation | u
|
|
|
86
86
|
/** A v1 trace from an API answer, or undefined when it lacks an id or timestamp. */
|
|
87
87
|
export declare function readTrace(value: unknown): LangfuseTrace | undefined;
|
|
88
88
|
/** An answer read whole: its status and body, or `tooLarge` for a body past the bound. */
|
|
89
|
-
type Answer = {
|
|
89
|
+
type Answer = ({
|
|
90
90
|
status: number;
|
|
91
91
|
body: Buffer;
|
|
92
92
|
} | {
|
|
93
93
|
status: number;
|
|
94
94
|
tooLarge: true;
|
|
95
|
+
}) & {
|
|
96
|
+
/** A redirect's `Location`, which no request follows itself. */
|
|
97
|
+
location?: string;
|
|
95
98
|
};
|
|
96
99
|
/** Reads one Langfuse project through its public API with Basic authentication. */
|
|
97
100
|
export declare class LangfuseApi {
|
|
@@ -105,9 +108,10 @@ export declare class LangfuseApi {
|
|
|
105
108
|
/**
|
|
106
109
|
* One GET with retries: a rate limit (HTTP 429) is waited out for as long as its `Retry-After`
|
|
107
110
|
* asks (up to 15 minutes, ten times in a row), and a connection failure, a timeout or a 5xx is
|
|
108
|
-
* tried again after 1, 2, 4, 8 and 16 seconds (or its `Retry-After`, up to a minute).
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
+
* tried again after 1, 2, 4, 8 and 16 seconds (or its `Retry-After`, up to a minute). No
|
|
112
|
+
* redirect is followed (its `Location` is answered), so the keys go only to the configured host
|
|
113
|
+
* and a media download reaches only links {@link LangfuseApi.media} checked; `authenticated` is
|
|
114
|
+
* false for a presigned media download, which carries no Langfuse keys.
|
|
111
115
|
*/
|
|
112
116
|
get(url: URL, authenticated?: boolean, maxBytes?: number, timeoutMs?: number): Promise<Answer>;
|
|
113
117
|
/** A GET of `path` under the API, answered with JSON; refusals of the keys are fatal. */
|
|
@@ -131,10 +135,22 @@ export declare class LangfuseApi {
|
|
|
131
135
|
/**
|
|
132
136
|
* A media file's bytes through `GET /api/public/media/{id}` and the download link it answers
|
|
133
137
|
* (fetched without the Langfuse keys): `missing` when Langfuse holds no bytes for it (no record,
|
|
134
|
-
* no link, or a link whose object is gone), `tooLarge` past {@link MAX_FILE_DATA_BYTES}.
|
|
138
|
+
* no link, or a link whose object is gone), `tooLarge` past {@link MAX_FILE_DATA_BYTES}. The link
|
|
139
|
+
* and each redirect from it must pass {@link refusedDownload}; one that does not fails the file.
|
|
135
140
|
*/
|
|
136
141
|
media(id: string): Promise<Uint8Array | "missing" | "tooLarge">;
|
|
137
142
|
}
|
|
143
|
+
/** Whether an IP address is one {@link INTERNAL_ADDRESSES} holds; false for anything else. */
|
|
144
|
+
export declare function isInternalAddress(address: string): boolean;
|
|
145
|
+
/**
|
|
146
|
+
* Why a media download link is refused, or undefined when it may be fetched: it must be HTTPS
|
|
147
|
+
* (plain HTTP only to this machine), at most 8192 characters, without credentials or a fragment,
|
|
148
|
+
* and name a host whose every address is public, unless it names the configured Langfuse host or
|
|
149
|
+
* that host is on this machine. Langfuse chooses the link, so this keeps a hostile or compromised
|
|
150
|
+
* deployment from sending requests to this machine's network. The name's lookup gives up after a
|
|
151
|
+
* minute or once `signal` stops the import.
|
|
152
|
+
*/
|
|
153
|
+
export declare function refusedDownload(url: URL, langfuse: URL, signal: AbortSignal): Promise<string | undefined>;
|
|
138
154
|
/** The OpenTelemetry trace id of a Langfuse trace: its own id when it is one (32 lowercase hex
|
|
139
155
|
* digits, as the Langfuse OpenTelemetry SDKs write), otherwise one derived from it by SHA-256, so
|
|
140
156
|
* the same trace always takes the same id. */
|
package/dist/cli/langfuse.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { lookup } from "node:dns/promises";
|
|
3
|
+
import { BlockList, isIP } from "node:net";
|
|
2
4
|
import { SpanKind, SpanStatusCode, TraceFlags, } from "@opentelemetry/api";
|
|
3
|
-
import { MAX_CONTENT_BYTES, MAX_FILE_DATA_BYTES } from "../config.js";
|
|
5
|
+
import { MAX_CONTENT_BYTES, MAX_FILE_DATA_BYTES, isLoopbackHost } from "../config.js";
|
|
4
6
|
import { BLOBS_KEY, MAX_BLOB_ENTRIES, blobEntry, newTally, offloadAttributes, } from "../blobs.js";
|
|
5
7
|
import { digestPart, isMediaContentType, mayInlineFiles, mediaIn, placedMediaText, readerParses, } from "../inline-files.js";
|
|
6
8
|
import { sdkVersion } from "../version.js";
|
|
@@ -16,6 +18,10 @@ import { sdkVersion } from "../version.js";
|
|
|
16
18
|
const REQUEST_TIMEOUT_MS = 60_000;
|
|
17
19
|
/** A media file download's deadline: up to 25 MiB over a slow link. */
|
|
18
20
|
const DOWNLOAD_TIMEOUT_MS = 300_000;
|
|
21
|
+
/** Redirects a media download follows, each to a link checked as the first one was. */
|
|
22
|
+
const MAX_DOWNLOAD_REDIRECTS = 5;
|
|
23
|
+
/** The longest media download link accepted, as the Python SDK's media client accepts. */
|
|
24
|
+
const MAX_LINK_LENGTH = 8192;
|
|
19
25
|
/** The largest answer read from the API. Langfuse Cloud answers at most 5 MB; a larger answer is
|
|
20
26
|
* asked for again in smaller pages. */
|
|
21
27
|
const MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
|
|
@@ -210,9 +216,10 @@ export class LangfuseApi {
|
|
|
210
216
|
/**
|
|
211
217
|
* One GET with retries: a rate limit (HTTP 429) is waited out for as long as its `Retry-After`
|
|
212
218
|
* asks (up to 15 minutes, ten times in a row), and a connection failure, a timeout or a 5xx is
|
|
213
|
-
* tried again after 1, 2, 4, 8 and 16 seconds (or its `Retry-After`, up to a minute).
|
|
214
|
-
*
|
|
215
|
-
*
|
|
219
|
+
* tried again after 1, 2, 4, 8 and 16 seconds (or its `Retry-After`, up to a minute). No
|
|
220
|
+
* redirect is followed (its `Location` is answered), so the keys go only to the configured host
|
|
221
|
+
* and a media download reaches only links {@link LangfuseApi.media} checked; `authenticated` is
|
|
222
|
+
* false for a presigned media download, which carries no Langfuse keys.
|
|
216
223
|
*/
|
|
217
224
|
async get(url, authenticated = true, maxBytes = MAX_RESPONSE_BYTES, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
218
225
|
let failures = 0;
|
|
@@ -227,9 +234,7 @@ export class LangfuseApi {
|
|
|
227
234
|
"user-agent": `hue-sdk-typescript/${sdkVersion} hue-import`,
|
|
228
235
|
...(authenticated ? { authorization: this.authorization } : {}),
|
|
229
236
|
},
|
|
230
|
-
|
|
231
|
-
// be redirected by its store.
|
|
232
|
-
redirect: authenticated ? "manual" : "follow",
|
|
237
|
+
redirect: "manual",
|
|
233
238
|
signal: AbortSignal.any([this.connection.signal, AbortSignal.timeout(timeoutMs)]),
|
|
234
239
|
});
|
|
235
240
|
}
|
|
@@ -258,6 +263,11 @@ export class LangfuseApi {
|
|
|
258
263
|
failures++;
|
|
259
264
|
continue;
|
|
260
265
|
}
|
|
266
|
+
const location = status >= 300 && status < 400 ? response.headers.get("location") : null;
|
|
267
|
+
if (location !== null) {
|
|
268
|
+
await response.body?.cancel().catch(() => undefined);
|
|
269
|
+
return { status, body: Buffer.alloc(0), location };
|
|
270
|
+
}
|
|
261
271
|
let body;
|
|
262
272
|
try {
|
|
263
273
|
body = await readBounded(response, maxBytes);
|
|
@@ -478,7 +488,8 @@ export class LangfuseApi {
|
|
|
478
488
|
/**
|
|
479
489
|
* A media file's bytes through `GET /api/public/media/{id}` and the download link it answers
|
|
480
490
|
* (fetched without the Langfuse keys): `missing` when Langfuse holds no bytes for it (no record,
|
|
481
|
-
* no link, or a link whose object is gone), `tooLarge` past {@link MAX_FILE_DATA_BYTES}.
|
|
491
|
+
* no link, or a link whose object is gone), `tooLarge` past {@link MAX_FILE_DATA_BYTES}. The link
|
|
492
|
+
* and each redirect from it must pass {@link refusedDownload}; one that does not fails the file.
|
|
482
493
|
*/
|
|
483
494
|
async media(id) {
|
|
484
495
|
const answer = await this.json(`/api/public/media/${encodeURIComponent(id)}`, {});
|
|
@@ -499,16 +510,112 @@ export class LangfuseApi {
|
|
|
499
510
|
}
|
|
500
511
|
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
501
512
|
return "missing";
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
513
|
+
const langfuse = new URL(this.connection.baseUrl);
|
|
514
|
+
for (let redirects = 0;; redirects++) {
|
|
515
|
+
const refused = await refusedDownload(url, langfuse, this.connection.signal);
|
|
516
|
+
this.stopped();
|
|
517
|
+
if (refused)
|
|
518
|
+
throw new LangfuseError(`Langfuse named a media download link that ${refused}`);
|
|
519
|
+
const download = await this.get(url, false, MAX_FILE_DATA_BYTES, DOWNLOAD_TIMEOUT_MS);
|
|
520
|
+
if (download.location !== undefined) {
|
|
521
|
+
if (redirects === MAX_DOWNLOAD_REDIRECTS)
|
|
522
|
+
throw new LangfuseError("The media download redirected too many times");
|
|
523
|
+
try {
|
|
524
|
+
url = new URL(download.location, url);
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
throw new LangfuseError("The media download redirected to something other than a URL");
|
|
528
|
+
}
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
if ("tooLarge" in download)
|
|
532
|
+
return download.status === 200 ? "tooLarge" : "missing";
|
|
533
|
+
if (download.status === 200)
|
|
534
|
+
return new Uint8Array(download.body);
|
|
535
|
+
if ([403, 404, 410].includes(download.status))
|
|
536
|
+
return "missing";
|
|
537
|
+
throw new LangfuseError(`The media download answered HTTP ${download.status}`);
|
|
538
|
+
}
|
|
510
539
|
}
|
|
511
540
|
}
|
|
541
|
+
/** Addresses a media download may not reach: this machine, private networks, link-local
|
|
542
|
+
* (cloud metadata), shared, benchmarking, multicast and reserved ranges. IPv4-mapped IPv6
|
|
543
|
+
* addresses are checked as their IPv4 address. */
|
|
544
|
+
const INTERNAL_ADDRESSES = new BlockList();
|
|
545
|
+
for (const [network, prefix] of [
|
|
546
|
+
["0.0.0.0", 8],
|
|
547
|
+
["10.0.0.0", 8],
|
|
548
|
+
["100.64.0.0", 10],
|
|
549
|
+
["127.0.0.0", 8],
|
|
550
|
+
["169.254.0.0", 16],
|
|
551
|
+
["172.16.0.0", 12],
|
|
552
|
+
["192.0.0.0", 24],
|
|
553
|
+
["192.168.0.0", 16],
|
|
554
|
+
["198.18.0.0", 15],
|
|
555
|
+
["224.0.0.0", 3],
|
|
556
|
+
])
|
|
557
|
+
INTERNAL_ADDRESSES.addSubnet(network, prefix, "ipv4");
|
|
558
|
+
for (const [network, prefix] of [
|
|
559
|
+
["::", 128],
|
|
560
|
+
["::1", 128],
|
|
561
|
+
["64:ff9b::", 96],
|
|
562
|
+
["fc00::", 7],
|
|
563
|
+
["fe80::", 10],
|
|
564
|
+
["fec0::", 10],
|
|
565
|
+
["ff00::", 8],
|
|
566
|
+
])
|
|
567
|
+
INTERNAL_ADDRESSES.addSubnet(network, prefix, "ipv6");
|
|
568
|
+
/** Whether an IP address is one {@link INTERNAL_ADDRESSES} holds; false for anything else. */
|
|
569
|
+
export function isInternalAddress(address) {
|
|
570
|
+
const version = isIP(address);
|
|
571
|
+
return version !== 0 && INTERNAL_ADDRESSES.check(address, version === 4 ? "ipv4" : "ipv6");
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Why a media download link is refused, or undefined when it may be fetched: it must be HTTPS
|
|
575
|
+
* (plain HTTP only to this machine), at most 8192 characters, without credentials or a fragment,
|
|
576
|
+
* and name a host whose every address is public, unless it names the configured Langfuse host or
|
|
577
|
+
* that host is on this machine. Langfuse chooses the link, so this keeps a hostile or compromised
|
|
578
|
+
* deployment from sending requests to this machine's network. The name's lookup gives up after a
|
|
579
|
+
* minute or once `signal` stops the import.
|
|
580
|
+
*/
|
|
581
|
+
export async function refusedDownload(url, langfuse, signal) {
|
|
582
|
+
if (url.href.length > MAX_LINK_LENGTH)
|
|
583
|
+
return "is too long";
|
|
584
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHost(url.hostname)))
|
|
585
|
+
return "does not use https";
|
|
586
|
+
if (url.username || url.password || url.hash)
|
|
587
|
+
return "carries credentials or a fragment";
|
|
588
|
+
if (isLoopbackHost(langfuse.hostname) || url.hostname === langfuse.hostname)
|
|
589
|
+
return undefined;
|
|
590
|
+
const host = url.hostname.replace(/^\[|\]$/gu, "");
|
|
591
|
+
const named = host === "localhost" || host.endsWith(".localhost");
|
|
592
|
+
// shortcut: the name is resolved apart from the request's own lookup, so a host that answers a
|
|
593
|
+
// second lookup differently (DNS rebinding) is not caught; pin the lookup if fetch gains a hook.
|
|
594
|
+
let addresses;
|
|
595
|
+
try {
|
|
596
|
+
addresses =
|
|
597
|
+
named || isIP(host)
|
|
598
|
+
? [named ? "127.0.0.1" : host]
|
|
599
|
+
: await resolved(host, AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]));
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
return "names a host that could not be resolved";
|
|
603
|
+
}
|
|
604
|
+
if (addresses.length === 0 || addresses.some(isInternalAddress))
|
|
605
|
+
return "names an address on this machine or a private network";
|
|
606
|
+
return undefined;
|
|
607
|
+
}
|
|
608
|
+
/** Every address `host` resolves to; rejects once `signal` aborts, since a lookup cannot be
|
|
609
|
+
* cancelled. */
|
|
610
|
+
function resolved(host, signal) {
|
|
611
|
+
return new Promise((resolve, reject) => {
|
|
612
|
+
const stop = () => reject(new Error("The lookup was abandoned"));
|
|
613
|
+
if (signal.aborted)
|
|
614
|
+
return stop();
|
|
615
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
616
|
+
lookup(host, { all: true }).then((entries) => resolve(entries.map((entry) => entry.address)), reject);
|
|
617
|
+
});
|
|
618
|
+
}
|
|
512
619
|
// ---------------------------------------------------------------------------------------------
|
|
513
620
|
// Conversion
|
|
514
621
|
// ---------------------------------------------------------------------------------------------
|
package/dist/client.d.ts
CHANGED
|
@@ -51,8 +51,9 @@ export declare class HueClient {
|
|
|
51
51
|
private ownedProviders?;
|
|
52
52
|
private closed;
|
|
53
53
|
private shutdownPromise?;
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
/** The latest flush while it still sends its records (`sent`, which the next flush's send
|
|
55
|
+
* follows), and the safe result that joined it. */
|
|
56
|
+
private sending?;
|
|
56
57
|
private safeShutdownPromise?;
|
|
57
58
|
constructor(options: HueOptions | ExistingHueProviders);
|
|
58
59
|
/**
|
|
@@ -166,10 +167,15 @@ export declare class HueClient {
|
|
|
166
167
|
/**
|
|
167
168
|
* Adds a `hue.file` event to the active (or given) span for a file the work read, received or
|
|
168
169
|
* produced: `hue.file.sha256`, `hue.file.role`, `hue.file.media_type`, `hue.file.size` when known
|
|
169
|
-
* and, when `captureContent` is true, `hue.file.name`. `data`
|
|
170
|
-
* uploaded only with `upload: true
|
|
171
|
-
*
|
|
172
|
-
*
|
|
170
|
+
* and, when `captureContent` is true, `hue.file.name`. `data` or the file at `path` is hashed and
|
|
171
|
+
* measured locally and uploaded only with `upload: true`: `data` and a file up to 25 MiB are
|
|
172
|
+
* held in memory until then, while a larger regular file, up to Hue's 1 GB upload limit, is
|
|
173
|
+
* hashed a slice at a time and read again from disk when it is uploaded, and is uploaded only if
|
|
174
|
+
* it still has the size and SHA-256 it was recorded with; a `path` that is no regular file, such
|
|
175
|
+
* as a named pipe, is refused without waiting on it. The event is metadata, so it is
|
|
176
|
+
* recorded in both capture modes. An invalid record, or one without an active span, is omitted
|
|
177
|
+
* and counted, never thrown; an invalid name alone is omitted and counted while the rest is
|
|
178
|
+
* recorded.
|
|
173
179
|
*/
|
|
174
180
|
recordFile(file: FileRecord, explicitContext?: Context): void;
|
|
175
181
|
/** The keys of each span's content values that were cut to Hue's value cap, for `hue.truncated`. */
|
|
@@ -201,22 +207,25 @@ export declare class HueClient {
|
|
|
201
207
|
/**
|
|
202
208
|
* Safe for finally blocks; preserves the application's result or original exception. When its
|
|
203
209
|
* budget runs out while the client's own transport holds records for a receiver that limited
|
|
204
|
-
* the rate,
|
|
205
|
-
*
|
|
210
|
+
* the rate, or still uploads values its records listed, the hold and the uploads end: those
|
|
211
|
+
* records are reported lost to the rate limit, the uploads counted as failed (their records keep
|
|
212
|
+
* their entries), and neither keeps the process running.
|
|
206
213
|
*/
|
|
207
214
|
shutdownSafe(options?: SafeLifecycleOptions): Promise<SafeLifecycleResult>;
|
|
208
215
|
private safeLifecycle;
|
|
209
216
|
/**
|
|
210
|
-
* Drains the trace and log providers and waits for Hue's acknowledgements
|
|
211
|
-
* fresh
|
|
217
|
+
* Drains the trace and log providers and waits for Hue's acknowledgements, then for the uploads
|
|
218
|
+
* queued so far. Each caller gets a fresh drain that includes records emitted before its call;
|
|
219
|
+
* its records are sent after an earlier drain's records, never after that drain's uploads.
|
|
212
220
|
*
|
|
213
221
|
* @throws HueExportError when this drain observed a new rejection, delivery failure, drop or
|
|
214
222
|
* invalid record; its `issues` and `report` are sanitized counts, never server text.
|
|
215
223
|
*/
|
|
216
224
|
flush(): Promise<ExportReport>;
|
|
217
|
-
private
|
|
225
|
+
private flushProviders;
|
|
218
226
|
/**
|
|
219
227
|
* Flushes, then shuts down the providers this client owns; borrowed providers are left running.
|
|
228
|
+
* Like {@link flush}, it waits for queued uploads after the records are sent.
|
|
220
229
|
* Idempotent: later calls return the same promise, and later helper calls only run their callbacks.
|
|
221
230
|
*
|
|
222
231
|
* @throws HueExportError when the final flush observed new failures; owned providers are still released.
|
package/dist/client.js
CHANGED
|
@@ -13,8 +13,8 @@ import { hostedServerAddresses, hostedToolActivity, hostedToolProvider, provider
|
|
|
13
13
|
import { toolCatalogSummary } from "./tool-definitions.js";
|
|
14
14
|
import { contentLimitsOf, encodeBoundedContent, encodeContent, noopSpan, safeSpan, spanSource, } from "./safety.js";
|
|
15
15
|
import { TRUNCATED_KEY, withTruncatedKeys } from "./privacy.js";
|
|
16
|
-
import { countFallback, MAX_BLOB_BYTES, newTally } from "./blobs.js";
|
|
17
|
-
import { attachmentPart, attachmentState, holdsAttachment, MAX_ATTACHMENT_DEPTH, } from "./attachment.js";
|
|
16
|
+
import { countFallback, MAX_BLOB_BYTES, newTally, sizeOf } from "./blobs.js";
|
|
17
|
+
import { attachmentPart, attachmentState, fileSource, holdsAttachment, MAX_ATTACHMENT_DEPTH, readPath, } from "./attachment.js";
|
|
18
18
|
import { createHueTransport, exportBudgetMillis, HueExportError, } from "./transport.js";
|
|
19
19
|
import { verifyTrace } from "./receipt.js";
|
|
20
20
|
import { sdkVersion } from "./version.js";
|
|
@@ -219,8 +219,9 @@ export class HueClient {
|
|
|
219
219
|
ownedProviders;
|
|
220
220
|
closed = false;
|
|
221
221
|
shutdownPromise;
|
|
222
|
-
|
|
223
|
-
|
|
222
|
+
/** The latest flush while it still sends its records (`sent`, which the next flush's send
|
|
223
|
+
* follows), and the safe result that joined it. */
|
|
224
|
+
sending;
|
|
224
225
|
safeShutdownPromise;
|
|
225
226
|
constructor(options) {
|
|
226
227
|
if ("transport" in options) {
|
|
@@ -611,21 +612,22 @@ export class HueClient {
|
|
|
611
612
|
const files = [];
|
|
612
613
|
const tally = newTally();
|
|
613
614
|
for (const { state, part, depth } of placed) {
|
|
614
|
-
if (!part ||
|
|
615
|
+
if (!part || state.size === undefined || !state.sha256) {
|
|
615
616
|
this.instrumentationFailed(signal);
|
|
616
617
|
continue;
|
|
617
618
|
}
|
|
618
619
|
if (state.invalidName && captureContent)
|
|
619
620
|
this.instrumentationFailed(signal);
|
|
620
|
-
if (!this.transport.offloadsValues() || !state.
|
|
621
|
+
if (!this.transport.offloadsValues() || !state.size)
|
|
621
622
|
continue;
|
|
622
623
|
if (cut || depth > MAX_ATTACHMENT_DEPTH) {
|
|
623
624
|
countFallback(tally, "budget", traceId);
|
|
624
625
|
continue;
|
|
625
626
|
}
|
|
626
627
|
files.push({
|
|
627
|
-
|
|
628
|
-
|
|
628
|
+
// A file given by path over 25 MiB is read again from disk when it is uploaded.
|
|
629
|
+
bytes: state.file ? fileSource(state.file) : state.bytes,
|
|
630
|
+
digest: { sha256: state.sha256, size: state.size },
|
|
629
631
|
contentType: state.contentType,
|
|
630
632
|
...(state.name === undefined ? {} : { name: state.name }),
|
|
631
633
|
});
|
|
@@ -788,10 +790,15 @@ export class HueClient {
|
|
|
788
790
|
/**
|
|
789
791
|
* Adds a `hue.file` event to the active (or given) span for a file the work read, received or
|
|
790
792
|
* produced: `hue.file.sha256`, `hue.file.role`, `hue.file.media_type`, `hue.file.size` when known
|
|
791
|
-
* and, when `captureContent` is true, `hue.file.name`. `data`
|
|
792
|
-
* uploaded only with `upload: true
|
|
793
|
-
*
|
|
794
|
-
*
|
|
793
|
+
* and, when `captureContent` is true, `hue.file.name`. `data` or the file at `path` is hashed and
|
|
794
|
+
* measured locally and uploaded only with `upload: true`: `data` and a file up to 25 MiB are
|
|
795
|
+
* held in memory until then, while a larger regular file, up to Hue's 1 GB upload limit, is
|
|
796
|
+
* hashed a slice at a time and read again from disk when it is uploaded, and is uploaded only if
|
|
797
|
+
* it still has the size and SHA-256 it was recorded with; a `path` that is no regular file, such
|
|
798
|
+
* as a named pipe, is refused without waiting on it. The event is metadata, so it is
|
|
799
|
+
* recorded in both capture modes. An invalid record, or one without an active span, is omitted
|
|
800
|
+
* and counted, never thrown; an invalid name alone is omitted and counted while the rest is
|
|
801
|
+
* recorded.
|
|
795
802
|
*/
|
|
796
803
|
recordFile(file, explicitContext) {
|
|
797
804
|
if (!this.enabled || this.closed)
|
|
@@ -800,7 +807,7 @@ export class HueClient {
|
|
|
800
807
|
const span = trace.getSpan(explicitContext ?? this.storage.getStore()?.context ?? context.active());
|
|
801
808
|
if (!span?.isRecording())
|
|
802
809
|
throw new Error("File records require an active span");
|
|
803
|
-
const { role, mediaType, data, name } = file;
|
|
810
|
+
const { role, mediaType, data, name, path } = file;
|
|
804
811
|
if (role !== "input" && role !== "attachment" && role !== "output")
|
|
805
812
|
throw new TypeError("Invalid file role");
|
|
806
813
|
if (!isLabel(mediaType))
|
|
@@ -808,7 +815,31 @@ export class HueClient {
|
|
|
808
815
|
let sha256 = typeof file.sha256 === "string" ? file.sha256.toLowerCase() : file.sha256;
|
|
809
816
|
let byteSize = file.byteSize;
|
|
810
817
|
let bytes;
|
|
811
|
-
|
|
818
|
+
// A file given by path over 25 MiB: hashed now and read again from disk when it is uploaded.
|
|
819
|
+
let onDisk;
|
|
820
|
+
let given = data;
|
|
821
|
+
if (path !== undefined) {
|
|
822
|
+
if (data !== undefined)
|
|
823
|
+
throw new TypeError("A file takes data or a path, not both");
|
|
824
|
+
if (typeof path !== "string" || !path)
|
|
825
|
+
throw new TypeError("Invalid file path");
|
|
826
|
+
const read = readPath(path);
|
|
827
|
+
if (!read)
|
|
828
|
+
throw new RangeError("File must be a regular file within Hue's 1 GB upload limit");
|
|
829
|
+
if ("bytes" in read)
|
|
830
|
+
given = read.bytes;
|
|
831
|
+
else {
|
|
832
|
+
onDisk = read.file;
|
|
833
|
+
// A caller-supplied digest or size must describe the same bytes.
|
|
834
|
+
if ((sha256 !== undefined && sha256 !== onDisk.sha256) ||
|
|
835
|
+
(byteSize !== undefined && byteSize !== onDisk.size))
|
|
836
|
+
throw new TypeError("File digest or size does not match its data");
|
|
837
|
+
sha256 = onDisk.sha256;
|
|
838
|
+
byteSize = onDisk.size;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
if (given !== undefined) {
|
|
842
|
+
const data = given;
|
|
812
843
|
bytes =
|
|
813
844
|
typeof data === "string"
|
|
814
845
|
? (() => {
|
|
@@ -888,10 +919,11 @@ export class HueClient {
|
|
|
888
919
|
if (eventTime)
|
|
889
920
|
this.fileEventTimes.set(source, eventTime);
|
|
890
921
|
if (file.upload === true && this.transport.offloadsValues()) {
|
|
891
|
-
|
|
922
|
+
const upload = bytes ?? (onDisk ? fileSource(onDisk) : undefined);
|
|
923
|
+
if (!this.transport.stageMedia(span, upload && eventTime
|
|
892
924
|
? {
|
|
893
|
-
bytes,
|
|
894
|
-
digest: { sha256, size:
|
|
925
|
+
bytes: upload,
|
|
926
|
+
digest: { sha256, size: sizeOf(upload) },
|
|
895
927
|
contentType: mediaType,
|
|
896
928
|
eventTime,
|
|
897
929
|
...(isMediaName(name) ? { name } : {}),
|
|
@@ -1034,24 +1066,25 @@ export class HueClient {
|
|
|
1034
1066
|
}
|
|
1035
1067
|
/** Production lifecycle path: never rejects; timeouts do not cancel borrowed provider work. */
|
|
1036
1068
|
flushSafe(options = {}) {
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1069
|
+
// A flush still sending its records is joined, so repeated timeouts do not accumulate
|
|
1070
|
+
// promises against a hung borrowed provider. Once it sent them, a new flush sends the
|
|
1071
|
+
// caller's own records instead of waiting on the earlier flush's uploads.
|
|
1072
|
+
if (this.sending?.safe)
|
|
1073
|
+
return this.sending.safe;
|
|
1074
|
+
const work = this.sending?.work ?? this.flush();
|
|
1075
|
+
// An invalid budget returns before the result awaits the flush: its failure is still handled.
|
|
1076
|
+
void work.catch(() => { });
|
|
1040
1077
|
const result = this.safeLifecycle(() => work, options);
|
|
1041
|
-
this.
|
|
1042
|
-
|
|
1043
|
-
this.safeFlushPromise = undefined;
|
|
1044
|
-
};
|
|
1045
|
-
// Retain the timed-out result until its underlying drain settles. Repeated
|
|
1046
|
-
// timeouts must not accumulate promises against a hung borrowed provider.
|
|
1047
|
-
void work.then(clear, clear);
|
|
1078
|
+
if (this.sending?.work === work)
|
|
1079
|
+
this.sending.safe = result;
|
|
1048
1080
|
return result;
|
|
1049
1081
|
}
|
|
1050
1082
|
/**
|
|
1051
1083
|
* Safe for finally blocks; preserves the application's result or original exception. When its
|
|
1052
1084
|
* budget runs out while the client's own transport holds records for a receiver that limited
|
|
1053
|
-
* the rate,
|
|
1054
|
-
*
|
|
1085
|
+
* the rate, or still uploads values its records listed, the hold and the uploads end: those
|
|
1086
|
+
* records are reported lost to the rate limit, the uploads counted as failed (their records keep
|
|
1087
|
+
* their entries), and neither keeps the process running.
|
|
1055
1088
|
*/
|
|
1056
1089
|
shutdownSafe(options = {}) {
|
|
1057
1090
|
if (this.safeShutdownPromise)
|
|
@@ -1099,8 +1132,9 @@ export class HueClient {
|
|
|
1099
1132
|
}
|
|
1100
1133
|
}
|
|
1101
1134
|
/**
|
|
1102
|
-
* Drains the trace and log providers and waits for Hue's acknowledgements
|
|
1103
|
-
* fresh
|
|
1135
|
+
* Drains the trace and log providers and waits for Hue's acknowledgements, then for the uploads
|
|
1136
|
+
* queued so far. Each caller gets a fresh drain that includes records emitted before its call;
|
|
1137
|
+
* its records are sent after an earlier drain's records, never after that drain's uploads.
|
|
1104
1138
|
*
|
|
1105
1139
|
* @throws HueExportError when this drain observed a new rejection, delivery failure, drop or
|
|
1106
1140
|
* invalid record; its `issues` and `report` are sanitized counts, never server text.
|
|
@@ -1108,25 +1142,31 @@ export class HueClient {
|
|
|
1108
1142
|
flush() {
|
|
1109
1143
|
// Each caller needs a drain after its own preceding span/log emissions.
|
|
1110
1144
|
// Joining an earlier drain can acknowledge records that were not in its batch.
|
|
1145
|
+
// Only the sends are serialized: each flush then waits on its own for the uploads queued so
|
|
1146
|
+
// far, so a later flush's records never wait behind an earlier flush's uploads.
|
|
1111
1147
|
const from = this.transport.getFailureSequence();
|
|
1112
|
-
const
|
|
1113
|
-
|
|
1148
|
+
const start = () => this.transport.flushRecords(() => this.flushProviders());
|
|
1149
|
+
const flushing = (this.sending?.sent ?? Promise.resolve()).then(start, start);
|
|
1150
|
+
const sent = flushing.then(({ sent }) => sent);
|
|
1151
|
+
const next = flushing
|
|
1152
|
+
.then(({ report }) => report)
|
|
1153
|
+
.then((report) => {
|
|
1114
1154
|
if (this.transport.getFailureSequence() !== from)
|
|
1115
1155
|
throw new HueExportError(this.transport
|
|
1116
1156
|
.getIssues()
|
|
1117
1157
|
.filter((issue) => issue.sequence > from && issue.kind !== "warning"), report);
|
|
1118
1158
|
return report;
|
|
1119
|
-
};
|
|
1120
|
-
const
|
|
1121
|
-
this.
|
|
1159
|
+
});
|
|
1160
|
+
const sending = { work: next, sent };
|
|
1161
|
+
this.sending = sending;
|
|
1122
1162
|
const clear = () => {
|
|
1123
|
-
if (this.
|
|
1124
|
-
this.
|
|
1163
|
+
if (this.sending === sending)
|
|
1164
|
+
this.sending = undefined;
|
|
1125
1165
|
};
|
|
1126
|
-
void
|
|
1166
|
+
void sent.then(clear, clear);
|
|
1127
1167
|
return next;
|
|
1128
1168
|
}
|
|
1129
|
-
async
|
|
1169
|
+
async flushProviders() {
|
|
1130
1170
|
// Providers this client owns wait as long as an export may take, a rate-limit hold included,
|
|
1131
1171
|
// instead of OpenTelemetry's 30 s; borrowed providers keep their own budget.
|
|
1132
1172
|
const owned = this.ownedProviders;
|
|
@@ -1138,10 +1178,10 @@ export class HueClient {
|
|
|
1138
1178
|
for (const [index, result] of results.entries())
|
|
1139
1179
|
if (result.status === "rejected")
|
|
1140
1180
|
this.transport.issue(index === 0 ? "traces" : "logs", "failed", 0, "OpenTelemetry provider flush failed");
|
|
1141
|
-
return this.transport.flush();
|
|
1142
1181
|
}
|
|
1143
1182
|
/**
|
|
1144
1183
|
* Flushes, then shuts down the providers this client owns; borrowed providers are left running.
|
|
1184
|
+
* Like {@link flush}, it waits for queued uploads after the records are sent.
|
|
1145
1185
|
* Idempotent: later calls return the same promise, and later helper calls only run their callbacks.
|
|
1146
1186
|
*
|
|
1147
1187
|
* @throws HueExportError when the final flush observed new failures; owned providers are still released.
|
package/dist/inline-files.d.ts
CHANGED
|
@@ -137,20 +137,21 @@ export declare function placedMediaText(media: MediaValue): string;
|
|
|
137
137
|
/** Each JSON-encoded string a placed file sits in, written again, innermost first: the string as
|
|
138
138
|
* recorded with only its placed files changed. */
|
|
139
139
|
export declare function reencodeMedia(media: MediaValue): void;
|
|
140
|
-
/** Whether a found file has bytes to place: nonempty, and a whole string's `data:`
|
|
141
|
-
|
|
140
|
+
/** Whether a found file has bytes to place: nonempty (`size` bytes), and a whole string's `data:`
|
|
141
|
+
* URL decodes. */
|
|
142
|
+
export declare function placeable(file: InlineFilePart, decoded: Pick<InlineFileBytes, "decoded">, size: number): boolean;
|
|
142
143
|
/** Whether an uploaded file keeps a 16 KiB prefix of its content in place (text media in a
|
|
143
144
|
* provider part, whose entry names the content) rather than its digest. A whole string, a file
|
|
144
145
|
* inside a JSON-encoded string, and a text file a part holds as bare base64 (a GenAI `blob`,
|
|
145
146
|
* Vertex `media` or AI SDK part, whose content is not the text itself) take the digest form. */
|
|
146
|
-
export declare function keepsPrefix(file: InlineFilePart, textual: boolean, decoded: InlineFileBytes): boolean;
|
|
147
|
+
export declare function keepsPrefix(file: InlineFilePart, textual: boolean, decoded: Pick<InlineFileBytes, "bareBase64">): boolean;
|
|
147
148
|
/** A file's bytes: a field its convention declares base64 decoded, any other content read as
|
|
148
149
|
* Hue's reader reads it, whatever the part's media type (`inlineFileBytes`): a `data:` URL by its
|
|
149
150
|
* own encoding, text that reads as base64 decoded (what the GenAI conventions, and
|
|
150
151
|
* `client.media.resolve`, write in a `blob` or `media` part for every media type), anything else
|
|
151
152
|
* as its UTF-8. */
|
|
152
153
|
export declare function mediaBytes(file: InlineFilePart): InlineFileBytes;
|
|
153
|
-
export declare function mediaContentType(file: InlineFilePart, decoded: InlineFileBytes): string;
|
|
154
|
+
export declare function mediaContentType(file: InlineFilePart, decoded: Pick<InlineFileBytes, "decoded" | "urlMediaType">): string;
|
|
154
155
|
export interface FileDigest {
|
|
155
156
|
sha256: string;
|
|
156
157
|
size: number;
|
package/dist/inline-files.js
CHANGED
|
@@ -707,9 +707,10 @@ export function reencodeMedia(media) {
|
|
|
707
707
|
});
|
|
708
708
|
}
|
|
709
709
|
}
|
|
710
|
-
/** Whether a found file has bytes to place: nonempty, and a whole string's `data:`
|
|
711
|
-
|
|
712
|
-
|
|
710
|
+
/** Whether a found file has bytes to place: nonempty (`size` bytes), and a whole string's `data:`
|
|
711
|
+
* URL decodes. */
|
|
712
|
+
export function placeable(file, decoded, size) {
|
|
713
|
+
return size > 0 && (!file.whole || decoded.decoded);
|
|
713
714
|
}
|
|
714
715
|
/** Whether an uploaded file keeps a 16 KiB prefix of its content in place (text media in a
|
|
715
716
|
* provider part, whose entry names the content) rather than its digest. A whole string, a file
|
|
@@ -841,7 +842,7 @@ export function digestMedia(media, nested, count, memo, traceId = "") {
|
|
|
841
842
|
let changed = false;
|
|
842
843
|
for (const file of media.files) {
|
|
843
844
|
const decoded = mediaBytes(file);
|
|
844
|
-
if (!placeable(file, decoded))
|
|
845
|
+
if (!placeable(file, decoded, decoded.bytes.byteLength))
|
|
845
846
|
continue;
|
|
846
847
|
if ((nested || isTextMediaType(mediaContentType(file, decoded))) &&
|
|
847
848
|
decoded.bytes.byteLength <= INLINE_FILE_LIMIT)
|
|
@@ -875,7 +876,7 @@ export function digestFormBytes(value) {
|
|
|
875
876
|
continue;
|
|
876
877
|
inspected += units;
|
|
877
878
|
const decoded = mediaBytes(file);
|
|
878
|
-
if (!placeable(file, decoded))
|
|
879
|
+
if (!placeable(file, decoded, decoded.bytes.byteLength))
|
|
879
880
|
continue;
|
|
880
881
|
if (isTextMediaType(mediaContentType(file, decoded)) &&
|
|
881
882
|
decoded.bytes.byteLength <= INLINE_FILE_LIMIT)
|