@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.
@@ -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). An
109
- * authenticated request never follows a redirect, so the keys go only to the configured host;
110
- * `authenticated` is false for a presigned media download, which carries no Langfuse keys.
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. */
@@ -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). An
214
- * authenticated request never follows a redirect, so the keys go only to the configured host;
215
- * `authenticated` is false for a presigned media download, which carries no Langfuse keys.
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
- // The keys go only to the configured host; a presigned download carries none and may
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 download = await this.get(url, false, MAX_FILE_DATA_BYTES, DOWNLOAD_TIMEOUT_MS);
503
- if ("tooLarge" in download)
504
- return download.status === 200 ? "tooLarge" : "missing";
505
- if (download.status === 200)
506
- return new Uint8Array(download.body);
507
- if ([403, 404, 410].includes(download.status))
508
- return "missing";
509
- throw new LangfuseError(`The media download answered HTTP ${download.status}`);
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
- private flushPromise?;
55
- private safeFlushPromise?;
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` is hashed and measured locally and
170
- * uploaded only with `upload: true`. The event is metadata, so it is recorded in both capture modes. An invalid
171
- * record, or one without an active span, is omitted and counted, never thrown; an invalid name
172
- * alone is omitted and counted while the rest is recorded.
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, the hold ends: those records are reported lost to the rate limit, and the hold no
205
- * longer keeps the process running.
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. Each caller gets a
211
- * fresh serialized drain that includes records emitted before its call.
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 flushOnce;
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
- flushPromise;
223
- safeFlushPromise;
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 || !state.bytes || !state.sha256) {
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.bytes.byteLength)
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
- bytes: state.bytes,
628
- digest: { sha256: state.sha256, size: state.bytes.byteLength },
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` is hashed and measured locally and
792
- * uploaded only with `upload: true`. The event is metadata, so it is recorded in both capture modes. An invalid
793
- * record, or one without an active span, is omitted and counted, never thrown; an invalid name
794
- * alone is omitted and counted while the rest is recorded.
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
- if (data !== undefined) {
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
- if (!this.transport.stageMedia(span, bytes && eventTime
922
+ const upload = bytes ?? (onDisk ? fileSource(onDisk) : undefined);
923
+ if (!this.transport.stageMedia(span, upload && eventTime
892
924
  ? {
893
- bytes,
894
- digest: { sha256, size: bytes.byteLength },
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
- if (this.safeFlushPromise)
1038
- return this.safeFlushPromise;
1039
- const work = this.flushPromise ?? this.flush();
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.safeFlushPromise = result;
1042
- const clear = () => {
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, the hold ends: those records are reported lost to the rate limit, and the hold no
1054
- * longer keeps the process running.
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. Each caller gets a
1103
- * fresh serialized drain that includes records emitted before its call.
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 drain = async () => {
1113
- const report = await this.flushOnce();
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 next = (this.flushPromise ?? Promise.resolve()).then(drain, drain);
1121
- this.flushPromise = next;
1159
+ });
1160
+ const sending = { work: next, sent };
1161
+ this.sending = sending;
1122
1162
  const clear = () => {
1123
- if (this.flushPromise === next)
1124
- this.flushPromise = undefined;
1163
+ if (this.sending === sending)
1164
+ this.sending = undefined;
1125
1165
  };
1126
- void next.then(clear, clear);
1166
+ void sent.then(clear, clear);
1127
1167
  return next;
1128
1168
  }
1129
- async flushOnce() {
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.
@@ -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:` URL decodes. */
141
- export declare function placeable(file: InlineFilePart, decoded: InlineFileBytes): boolean;
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;
@@ -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:` URL decodes. */
711
- export function placeable(file, decoded) {
712
- return decoded.bytes.byteLength > 0 && (!file.whole || decoded.decoded);
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)