@hue-run/sdk 0.18.0 → 0.18.2

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.
@@ -0,0 +1,1075 @@
1
+ import { createHash } from "node:crypto";
2
+ import { SpanKind, SpanStatusCode, TraceFlags, } from "@opentelemetry/api";
3
+ import { MAX_CONTENT_BYTES, MAX_FILE_DATA_BYTES } from "../config.js";
4
+ import { BLOBS_KEY, MAX_BLOB_ENTRIES, blobEntry, newTally, offloadAttributes, } from "../blobs.js";
5
+ import { digestPart, isMediaContentType, mayInlineFiles, mediaIn, placedMediaText, readerParses, } from "../inline-files.js";
6
+ import { sdkVersion } from "../version.js";
7
+ /**
8
+ * Reading a Langfuse project through its public API and converting what it holds into the
9
+ * OpenTelemetry spans Hue reads, with the `langfuse.*` attributes the Langfuse SDKs write. Used by
10
+ * `hue import langfuse`; nothing here runs unless that command does.
11
+ */
12
+ // ---------------------------------------------------------------------------------------------
13
+ // Langfuse public API
14
+ // ---------------------------------------------------------------------------------------------
15
+ /** Every API request's own deadline. */
16
+ const REQUEST_TIMEOUT_MS = 60_000;
17
+ /** A media file download's deadline: up to 25 MiB over a slow link. */
18
+ const DOWNLOAD_TIMEOUT_MS = 300_000;
19
+ /** The largest answer read from the API. Langfuse Cloud answers at most 5 MB; a larger answer is
20
+ * asked for again in smaller pages. */
21
+ const MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
22
+ /** Waits between attempts of a request that failed for its connection or a 5xx. */
23
+ const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000];
24
+ /** Rate-limit answers (HTTP 429) waited out in a row before the request is given up. */
25
+ const MAX_RATE_LIMITED = 10;
26
+ /** The longest `Retry-After` honoured; Langfuse's buckets are per minute or per hour. */
27
+ const MAX_RETRY_AFTER_MS = 15 * 60_000;
28
+ /** Page sizes, each dividing the one before, so a page-numbered listing keeps its place when
29
+ * its page size shrinks after an answer too large to read. */
30
+ const PAGE_SIZES = [100, 50, 25, 5, 1];
31
+ /** How many pages of the next size one page of each size holds. */
32
+ const PAGE_SPLITS = [2, 2, 5, 5];
33
+ /** Root observations read per page when the answer is too large: the core fields only. */
34
+ const ROOT_PAGE_SIZES = [1000, 100, 10, 1];
35
+ /** Observation fields read for each trace through the v2 API. */
36
+ const V2_FIELDS = "core,basic,time,io,metadata,model,usage,prompt,trace_context";
37
+ /** Langfuse cuts metadata values at 200 characters unless their keys are expanded. */
38
+ const METADATA_PREVIEW_CHARS = 200;
39
+ /** Trace ids remembered so a trace with two root observations is imported once. */
40
+ const REMEMBERED_TRACE_IDS = 100_000;
41
+ /** A request Langfuse refused or did not answer; `fatal` stops the whole import. */
42
+ export class LangfuseError extends Error {
43
+ status;
44
+ fatal;
45
+ constructor(message, status, fatal = false) {
46
+ super(message);
47
+ this.status = status;
48
+ this.fatal = fatal;
49
+ this.name = "LangfuseError";
50
+ }
51
+ }
52
+ /** The import was stopped while a request was waiting. */
53
+ export class ImportStopped extends Error {
54
+ constructor() {
55
+ super("Stopped");
56
+ this.name = "ImportStopped";
57
+ }
58
+ }
59
+ function object(value) {
60
+ return value !== null && typeof value === "object" && !Array.isArray(value)
61
+ ? value
62
+ : undefined;
63
+ }
64
+ function text(value) {
65
+ return typeof value === "string" && value !== "" ? value : undefined;
66
+ }
67
+ function strings(value) {
68
+ return Array.isArray(value) && value.every((item) => typeof item === "string")
69
+ ? value
70
+ : undefined;
71
+ }
72
+ /** An observation from an API answer, or undefined when it lacks the fields every span needs. */
73
+ export function readObservation(value) {
74
+ const item = object(value);
75
+ const id = text(item?.id);
76
+ const traceId = text(item?.traceId);
77
+ const startTime = text(item?.startTime);
78
+ if (!item || !id || !traceId || !startTime)
79
+ return undefined;
80
+ const promptVersion = item.promptVersion;
81
+ return {
82
+ id,
83
+ traceId,
84
+ startTime,
85
+ type: text(item.type),
86
+ name: text(item.name),
87
+ endTime: text(item.endTime),
88
+ completionStartTime: text(item.completionStartTime),
89
+ parentObservationId: text(item.parentObservationId),
90
+ level: text(item.level),
91
+ statusMessage: text(item.statusMessage),
92
+ version: text(item.version),
93
+ environment: text(item.environment),
94
+ input: item.input,
95
+ output: item.output,
96
+ metadata: item.metadata,
97
+ model: text(item.model),
98
+ modelParameters: item.modelParameters,
99
+ usageDetails: object(item.usageDetails),
100
+ costDetails: object(item.costDetails),
101
+ promptName: text(item.promptName),
102
+ promptVersion: typeof promptVersion === "number" && Number.isSafeInteger(promptVersion)
103
+ ? promptVersion
104
+ : undefined,
105
+ userId: text(item.userId),
106
+ sessionId: text(item.sessionId),
107
+ traceName: text(item.traceName),
108
+ tags: strings(item.tags),
109
+ release: text(item.release),
110
+ };
111
+ }
112
+ /** A v1 trace from an API answer, or undefined when it lacks an id or timestamp. */
113
+ export function readTrace(value) {
114
+ const item = object(value);
115
+ const id = text(item?.id);
116
+ const timestamp = text(item?.timestamp);
117
+ if (!item || !id || !timestamp)
118
+ return undefined;
119
+ return {
120
+ id,
121
+ timestamp,
122
+ name: text(item.name),
123
+ input: item.input,
124
+ output: item.output,
125
+ metadata: item.metadata,
126
+ sessionId: text(item.sessionId),
127
+ userId: text(item.userId),
128
+ release: text(item.release),
129
+ version: text(item.version),
130
+ tags: strings(item.tags),
131
+ public: typeof item.public === "boolean" ? item.public : undefined,
132
+ environment: text(item.environment),
133
+ };
134
+ }
135
+ /** The wait a `Retry-After` header asks for (seconds or an HTTP date), or undefined. */
136
+ function retryAfter(value) {
137
+ if (!value)
138
+ return undefined;
139
+ const trimmed = value.trim();
140
+ if (/^\d{1,7}$/u.test(trimmed))
141
+ return Number(trimmed) * 1000;
142
+ const date = Date.parse(trimmed);
143
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
144
+ }
145
+ /** A response body read up to `max` bytes; undefined past it. */
146
+ async function readBounded(response, max) {
147
+ const declared = Number(response.headers.get("content-length") ?? NaN);
148
+ if (Number.isFinite(declared) && declared > max) {
149
+ await response.body?.cancel().catch(() => undefined);
150
+ return undefined;
151
+ }
152
+ const reader = response.body?.getReader();
153
+ if (!reader)
154
+ return Buffer.alloc(0);
155
+ const chunks = [];
156
+ let size = 0;
157
+ try {
158
+ for (;;) {
159
+ const { done, value } = await reader.read();
160
+ if (done)
161
+ break;
162
+ size += value.byteLength;
163
+ if (size > max) {
164
+ await reader.cancel().catch(() => undefined);
165
+ return undefined;
166
+ }
167
+ chunks.push(value);
168
+ }
169
+ }
170
+ finally {
171
+ reader.releaseLock();
172
+ }
173
+ return Buffer.concat(chunks, size);
174
+ }
175
+ /** Whether a numbered page has pages after it: as its `meta.totalPages` says, or, without one,
176
+ * when it came back full. */
177
+ function morePages(page, number, limit) {
178
+ const total = object(page.meta)?.totalPages;
179
+ if (typeof total === "number" && Number.isSafeInteger(total))
180
+ return page.data.length > 0 && number < total;
181
+ return page.data.length >= limit;
182
+ }
183
+ /** The cursor of the page after `page`; a cursor that does not move would read it forever. */
184
+ function nextCursor(page, current) {
185
+ const next = text(object(page.meta)?.cursor);
186
+ if (next !== undefined && next === current)
187
+ throw new LangfuseError("Langfuse answered the same page cursor twice");
188
+ return next;
189
+ }
190
+ /** Reads one Langfuse project through its public API with Basic authentication. */
191
+ export class LangfuseApi {
192
+ connection;
193
+ fetchImpl;
194
+ authorization;
195
+ seen = new Set();
196
+ constructor(connection) {
197
+ this.connection = connection;
198
+ this.fetchImpl = connection.fetch ?? globalThis.fetch;
199
+ this.authorization = `Basic ${Buffer.from(`${connection.publicKey}:${connection.secretKey}`).toString("base64")}`;
200
+ }
201
+ stopped() {
202
+ if (this.connection.signal.aborted)
203
+ throw new ImportStopped();
204
+ }
205
+ async wait(ms, reason) {
206
+ this.connection.onWait?.(Math.ceil(ms / 1000), reason);
207
+ await this.connection.sleep(ms);
208
+ this.stopped();
209
+ }
210
+ /**
211
+ * One GET with retries: a rate limit (HTTP 429) is waited out for as long as its `Retry-After`
212
+ * 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.
216
+ */
217
+ async get(url, authenticated = true, maxBytes = MAX_RESPONSE_BYTES, timeoutMs = REQUEST_TIMEOUT_MS) {
218
+ let failures = 0;
219
+ let limited = 0;
220
+ for (;;) {
221
+ this.stopped();
222
+ let response;
223
+ try {
224
+ response = await this.fetchImpl(url, {
225
+ headers: {
226
+ accept: "application/json",
227
+ "user-agent": `hue-sdk-typescript/${sdkVersion} hue-import`,
228
+ ...(authenticated ? { authorization: this.authorization } : {}),
229
+ },
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",
233
+ signal: AbortSignal.any([this.connection.signal, AbortSignal.timeout(timeoutMs)]),
234
+ });
235
+ }
236
+ catch {
237
+ this.stopped();
238
+ if (failures >= RETRY_DELAYS_MS.length)
239
+ throw new LangfuseError("Langfuse could not be reached");
240
+ await this.wait(RETRY_DELAYS_MS[failures++], "Langfuse could not be reached");
241
+ continue;
242
+ }
243
+ const status = response.status;
244
+ if (status === 429) {
245
+ await response.body?.cancel().catch(() => undefined);
246
+ if (limited++ >= MAX_RATE_LIMITED)
247
+ throw new LangfuseError("Langfuse kept refusing requests for their rate (HTTP 429)", 429);
248
+ const asked = retryAfter(response.headers.get("retry-after"));
249
+ await this.wait(Math.min(MAX_RETRY_AFTER_MS, asked ?? RETRY_DELAYS_MS[Math.min(limited, 4)]), "Langfuse rate limit");
250
+ continue;
251
+ }
252
+ if (status >= 500 || status === 408) {
253
+ await response.body?.cancel().catch(() => undefined);
254
+ if (failures >= RETRY_DELAYS_MS.length)
255
+ throw new LangfuseError(`Langfuse answered HTTP ${status}`, status);
256
+ const asked = retryAfter(response.headers.get("retry-after"));
257
+ await this.wait(asked === undefined ? RETRY_DELAYS_MS[failures] : Math.min(60_000, asked), `Langfuse answered HTTP ${status}`);
258
+ failures++;
259
+ continue;
260
+ }
261
+ let body;
262
+ try {
263
+ body = await readBounded(response, maxBytes);
264
+ }
265
+ catch {
266
+ this.stopped();
267
+ if (failures >= RETRY_DELAYS_MS.length)
268
+ throw new LangfuseError("Langfuse's answer could not be read");
269
+ await this.wait(RETRY_DELAYS_MS[failures++], "Langfuse's answer could not be read");
270
+ continue;
271
+ }
272
+ return body === undefined ? { status, tooLarge: true } : { status, body };
273
+ }
274
+ }
275
+ /** A GET of `path` under the API, answered with JSON; refusals of the keys are fatal. */
276
+ async json(path, query) {
277
+ const url = new URL(`${this.connection.baseUrl}${path}`);
278
+ for (const [name, value] of Object.entries(query))
279
+ if (value !== undefined)
280
+ url.searchParams.set(name, value);
281
+ const answer = await this.get(url);
282
+ if (answer.status === 401 || answer.status === 403)
283
+ throw new LangfuseError(`Langfuse refused the keys (HTTP ${answer.status}); check LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY and the host`, answer.status, true);
284
+ if (answer.status >= 300 && answer.status < 400)
285
+ throw new LangfuseError(`Langfuse redirected the request (HTTP ${answer.status}); set the host to the address it redirects to`, answer.status, true);
286
+ if ("tooLarge" in answer)
287
+ return { status: answer.status, tooLarge: true };
288
+ if (answer.status !== 200)
289
+ return { status: answer.status };
290
+ try {
291
+ return { status: 200, json: JSON.parse(answer.body.toString("utf8")) };
292
+ }
293
+ catch {
294
+ throw new LangfuseError("Langfuse answered with something other than JSON", 200);
295
+ }
296
+ }
297
+ /** Which API the deployment reads traces through: v2 unless it answers that it has none. */
298
+ async version() {
299
+ const answer = await this.json("/api/public/v2/observations", { limit: "1", fields: "core" });
300
+ if (answer.status === 200)
301
+ return "v2";
302
+ if (answer.status === 404)
303
+ return "v1";
304
+ throw new LangfuseError(`Langfuse answered HTTP ${answer.status}`, answer.status, true);
305
+ }
306
+ /** Whether a trace id has not been listed before in this import. */
307
+ first(traceId) {
308
+ if (this.seen.has(traceId))
309
+ return false;
310
+ this.seen.add(traceId);
311
+ if (this.seen.size > REMEMBERED_TRACE_IDS)
312
+ this.seen.delete(this.seen.values().next().value);
313
+ return true;
314
+ }
315
+ /** The ids of the traces whose root observations started in the window, newest first. */
316
+ async *rootTraceIds(window) {
317
+ let cursor;
318
+ let size = 0;
319
+ for (;;) {
320
+ const answer = await this.json("/api/public/v2/observations", {
321
+ isRootObservation: "true",
322
+ fields: "core",
323
+ limit: String(ROOT_PAGE_SIZES[size]),
324
+ fromStartTime: window.from,
325
+ toStartTime: window.to,
326
+ cursor,
327
+ });
328
+ if (answer.tooLarge || answer.status === 413) {
329
+ if (size === ROOT_PAGE_SIZES.length - 1)
330
+ throw new LangfuseError("Langfuse's list of traces is too large to read");
331
+ size++;
332
+ continue;
333
+ }
334
+ const page = this.page(answer, "observations");
335
+ for (const item of page.data) {
336
+ const traceId = text(object(item)?.traceId);
337
+ if (traceId && this.first(traceId))
338
+ yield traceId;
339
+ }
340
+ cursor = nextCursor(page, cursor);
341
+ if (!cursor)
342
+ return;
343
+ }
344
+ }
345
+ /** A trace's observations through the v2 API, a page at a time. */
346
+ async *v2Observations(traceId) {
347
+ let cursor;
348
+ let size = 0;
349
+ for (;;) {
350
+ const query = {
351
+ traceId,
352
+ fields: V2_FIELDS,
353
+ limit: String(PAGE_SIZES[size]),
354
+ cursor,
355
+ };
356
+ let answer = await this.json("/api/public/v2/observations", query);
357
+ if (answer.tooLarge || answer.status === 413) {
358
+ if (size === PAGE_SIZES.length - 1)
359
+ throw new LangfuseError("An observation is too large to read from Langfuse");
360
+ size++;
361
+ continue;
362
+ }
363
+ let page = this.page(answer, "observations");
364
+ // Metadata values past Langfuse's preview length were cut: their keys are asked for whole.
365
+ const cut = new Set();
366
+ for (const item of page.data)
367
+ for (const [key, value] of Object.entries(object(object(item)?.metadata) ?? {}))
368
+ if (typeof value === "string" && value.length >= METADATA_PREVIEW_CHARS)
369
+ cut.add(key);
370
+ if (cut.size) {
371
+ answer = await this.json("/api/public/v2/observations", {
372
+ ...query,
373
+ expandMetadata: [...cut].join(","),
374
+ });
375
+ if (answer.tooLarge || answer.status === 413) {
376
+ if (size === PAGE_SIZES.length - 1)
377
+ throw new LangfuseError("An observation is too large to read from Langfuse");
378
+ size++;
379
+ continue;
380
+ }
381
+ page = this.page(answer, "observations");
382
+ }
383
+ yield this.observations(page.data);
384
+ cursor = nextCursor(page, cursor);
385
+ if (!cursor)
386
+ return;
387
+ }
388
+ }
389
+ /** The v1 traces whose timestamp falls in the window, newest first. */
390
+ async *v1Traces(window) {
391
+ let number = 1;
392
+ let size = 1;
393
+ for (;;) {
394
+ const limit = PAGE_SIZES[size];
395
+ const answer = await this.json("/api/public/traces", {
396
+ page: String(number),
397
+ limit: String(limit),
398
+ fromTimestamp: window.from,
399
+ toTimestamp: window.to,
400
+ orderBy: "timestamp.desc",
401
+ fields: "core,io",
402
+ });
403
+ if (answer.tooLarge || answer.status === 413) {
404
+ if (size === PAGE_SIZES.length - 1)
405
+ throw new LangfuseError("A trace is too large to read from Langfuse");
406
+ number = (number - 1) * PAGE_SPLITS[size++] + 1;
407
+ continue;
408
+ }
409
+ const page = this.page(answer, "traces");
410
+ for (const item of page.data) {
411
+ const trace = readTrace(item);
412
+ if (!trace)
413
+ throw new LangfuseError("Langfuse listed a trace without an id or timestamp");
414
+ if (this.first(trace.id))
415
+ yield trace;
416
+ }
417
+ if (!morePages(page, number, limit))
418
+ return;
419
+ number++;
420
+ }
421
+ }
422
+ /** One v1 trace by id, or undefined when Langfuse holds none. */
423
+ async v1Trace(traceId) {
424
+ const answer = await this.json(`/api/public/traces/${encodeURIComponent(traceId)}`, {
425
+ fields: "core,io",
426
+ });
427
+ if (answer.status === 404)
428
+ return undefined;
429
+ if (answer.tooLarge)
430
+ throw new LangfuseError("The trace is too large to read from Langfuse");
431
+ if (answer.status !== 200)
432
+ throw new LangfuseError(`Langfuse answered HTTP ${answer.status}`);
433
+ const trace = readTrace(answer.json);
434
+ if (!trace)
435
+ throw new LangfuseError("Langfuse answered a trace without an id or timestamp");
436
+ return trace;
437
+ }
438
+ /** A trace's observations through the v1 API, a page at a time. */
439
+ async *v1Observations(traceId) {
440
+ let number = 1;
441
+ let size = 0;
442
+ for (;;) {
443
+ const limit = PAGE_SIZES[size];
444
+ const answer = await this.json("/api/public/observations", {
445
+ traceId,
446
+ page: String(number),
447
+ limit: String(limit),
448
+ });
449
+ if (answer.tooLarge || answer.status === 413) {
450
+ if (size === PAGE_SIZES.length - 1)
451
+ throw new LangfuseError("An observation is too large to read from Langfuse");
452
+ number = (number - 1) * PAGE_SPLITS[size++] + 1;
453
+ continue;
454
+ }
455
+ const page = this.page(answer, "observations");
456
+ yield this.observations(page.data);
457
+ if (!morePages(page, number, limit))
458
+ return;
459
+ number++;
460
+ }
461
+ }
462
+ page(answer, what) {
463
+ if (answer.status !== 200)
464
+ throw new LangfuseError(`Langfuse answered HTTP ${answer.status} when listing ${what}`, answer.status);
465
+ const body = object(answer.json);
466
+ if (!body || !Array.isArray(body.data))
467
+ throw new LangfuseError(`Langfuse answered a list of ${what} without data`);
468
+ return { data: body.data, meta: body.meta };
469
+ }
470
+ observations(data) {
471
+ return data.map((item) => {
472
+ const observation = readObservation(item);
473
+ if (!observation)
474
+ throw new LangfuseError("Langfuse listed an observation without an id, trace id or start time");
475
+ return observation;
476
+ });
477
+ }
478
+ /**
479
+ * A media file's bytes through `GET /api/public/media/{id}` and the download link it answers
480
+ * (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}.
482
+ */
483
+ async media(id) {
484
+ const answer = await this.json(`/api/public/media/${encodeURIComponent(id)}`, {});
485
+ if (answer.status === 404 || answer.status === 400)
486
+ return "missing";
487
+ if (answer.status !== 200 || answer.tooLarge)
488
+ throw new LangfuseError(`Langfuse answered HTTP ${answer.status} for a media file`);
489
+ const record = object(answer.json);
490
+ const length = record?.contentLength;
491
+ if (typeof length === "number" && length > MAX_FILE_DATA_BYTES)
492
+ return "tooLarge";
493
+ let url;
494
+ try {
495
+ url = new URL(text(record?.url) ?? "");
496
+ }
497
+ catch {
498
+ return "missing";
499
+ }
500
+ if (url.protocol !== "https:" && url.protocol !== "http:")
501
+ 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}`);
510
+ }
511
+ }
512
+ // ---------------------------------------------------------------------------------------------
513
+ // Conversion
514
+ // ---------------------------------------------------------------------------------------------
515
+ const HEX32 = /^[0-9a-f]{32}$/u;
516
+ const HEX16 = /^[0-9a-f]{16}$/u;
517
+ function digestHex(input, length) {
518
+ for (let salt = 0;; salt++) {
519
+ const hex = createHash("sha256")
520
+ .update(salt ? `${input}#${salt}` : input)
521
+ .digest("hex")
522
+ .slice(0, length);
523
+ if (!/^0+$/u.test(hex))
524
+ return hex;
525
+ }
526
+ }
527
+ /** The OpenTelemetry trace id of a Langfuse trace: its own id when it is one (32 lowercase hex
528
+ * digits, as the Langfuse OpenTelemetry SDKs write), otherwise one derived from it by SHA-256, so
529
+ * the same trace always takes the same id. */
530
+ export function hueTraceId(langfuseTraceId) {
531
+ return HEX32.test(langfuseTraceId) && !/^0+$/u.test(langfuseTraceId)
532
+ ? langfuseTraceId
533
+ : digestHex(`langfuse.trace:${langfuseTraceId}`, 32);
534
+ }
535
+ /** The OpenTelemetry span id of a Langfuse observation: its own id when it is one (16 lowercase
536
+ * hex digits), otherwise one derived from the trace's and its own id. */
537
+ export function hueSpanId(langfuseTraceId, observationId) {
538
+ return HEX16.test(observationId) && !/^0+$/u.test(observationId)
539
+ ? observationId
540
+ : digestHex(`langfuse.observation:${langfuseTraceId}:${observationId}`, 16);
541
+ }
542
+ /** The span id of the span that stands for a v1 trace itself. */
543
+ export function traceSpanId(langfuseTraceId) {
544
+ return digestHex(`langfuse.trace-span:${langfuseTraceId}`, 16);
545
+ }
546
+ /** An ISO 8601 instant as OpenTelemetry time, keeping up to nanosecond digits; undefined for
547
+ * anything else. */
548
+ export function hrTimeOf(value) {
549
+ if (!value)
550
+ return undefined;
551
+ const match = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})?$/iu.exec(value.trim());
552
+ if (!match)
553
+ return undefined;
554
+ const zone = match[4] === undefined || match[4].toUpperCase() === "Z" ? "Z" : match[4];
555
+ const millis = Date.parse(`${match[1]}T${match[2]}${zone.length === 5 ? `${zone.slice(0, 3)}:${zone.slice(3)}` : zone}`);
556
+ if (!Number.isFinite(millis) || millis < 0)
557
+ return undefined;
558
+ return [Math.floor(millis / 1000), Number((match[3] ?? "").padEnd(9, "0"))];
559
+ }
560
+ function later(a, b) {
561
+ return a[0] > b[0] || (a[0] === b[0] && a[1] >= b[1]) ? a : b;
562
+ }
563
+ function earlier(a, b) {
564
+ return later(a, b) === a ? b : a;
565
+ }
566
+ /** `bounds` widened to cover `span`. */
567
+ export function widen(bounds, span) {
568
+ return bounds
569
+ ? { start: earlier(bounds.start, span.start), end: later(bounds.end, span.end) }
570
+ : { start: span.start, end: span.end };
571
+ }
572
+ function minus(end, start) {
573
+ let seconds = end[0] - start[0];
574
+ let nanos = end[1] - start[1];
575
+ if (nanos < 0) {
576
+ seconds -= 1;
577
+ nanos += 1_000_000_000;
578
+ }
579
+ return [seconds, nanos];
580
+ }
581
+ /** A Langfuse value as attribute text: a string as it is, anything else as its JSON. */
582
+ function attributeText(value) {
583
+ if (value === undefined || value === null)
584
+ return undefined;
585
+ if (typeof value === "string")
586
+ return value === "" ? undefined : value;
587
+ try {
588
+ return JSON.stringify(value);
589
+ }
590
+ catch {
591
+ return undefined;
592
+ }
593
+ }
594
+ /** Metadata as the Langfuse SDKs record it on a span: one attribute per top-level key under
595
+ * `prefix`, a string as it is and any other value as its JSON; metadata that is no object is
596
+ * recorded whole under `prefix`. */
597
+ function metadataAttributes(prefix, metadata, into) {
598
+ const fields = object(metadata);
599
+ if (!fields) {
600
+ const whole = attributeText(metadata);
601
+ if (whole !== undefined)
602
+ into[prefix] = whole;
603
+ return;
604
+ }
605
+ for (const [key, value] of Object.entries(fields)) {
606
+ const recorded = attributeText(value);
607
+ if (recorded !== undefined && key !== "")
608
+ into[`${prefix}.${key}`] = recorded;
609
+ }
610
+ }
611
+ function setText(into, key, value) {
612
+ if (value !== undefined && value !== "")
613
+ into[key] = value;
614
+ }
615
+ /**
616
+ * A Langfuse observation as the span the Langfuse OpenTelemetry SDKs would have recorded:
617
+ * `langfuse.observation.*` (type in lower case, input, output, one `metadata.<key>` attribute per
618
+ * key, level, status message, model, model parameters, usage and cost details, completion start,
619
+ * prompt), `langfuse.trace.name`, `langfuse.trace.tags`, `langfuse.user.id`,
620
+ * `langfuse.session.id`, `langfuse.release`, `langfuse.version` and `langfuse.environment`, with
621
+ * `gen_ai.request.model` and the input and output token counts as GenAI attributes. The
622
+ * observation's and trace's own ids are kept as `langfuse.observation.id` and
623
+ * `langfuse.trace.id`. Throws for a start time that is no ISO 8601 instant.
624
+ */
625
+ export function observationSpan(observation, context) {
626
+ const start = hrTimeOf(observation.startTime);
627
+ if (!start)
628
+ throw new LangfuseError("An observation's start time is not an ISO 8601 instant");
629
+ // An observation without an end (an event, or one never ended) lasts no time.
630
+ const end = later(hrTimeOf(observation.endTime) ?? start, start);
631
+ const type = observation.type?.toLowerCase();
632
+ const attributes = {};
633
+ setText(attributes, "langfuse.observation.type", type);
634
+ setText(attributes, "langfuse.observation.input", attributeText(observation.input));
635
+ setText(attributes, "langfuse.observation.output", attributeText(observation.output));
636
+ metadataAttributes("langfuse.observation.metadata", observation.metadata, attributes);
637
+ setText(attributes, "langfuse.observation.level", observation.level);
638
+ setText(attributes, "langfuse.observation.status_message", observation.statusMessage);
639
+ if (observation.model) {
640
+ attributes["langfuse.observation.model.name"] = observation.model;
641
+ attributes["gen_ai.request.model"] = observation.model;
642
+ }
643
+ setText(attributes, "langfuse.observation.model.parameters", attributeText(observation.modelParameters));
644
+ const usage = observation.usageDetails;
645
+ if (usage && Object.keys(usage).length) {
646
+ attributes["langfuse.observation.usage_details"] = JSON.stringify(usage);
647
+ for (const [field, key] of [
648
+ ["input", "gen_ai.usage.input_tokens"],
649
+ ["output", "gen_ai.usage.output_tokens"],
650
+ ]) {
651
+ const count = usage[field];
652
+ if (typeof count === "number" && Number.isSafeInteger(count) && count >= 0)
653
+ attributes[key] = count;
654
+ }
655
+ }
656
+ if (observation.costDetails && Object.keys(observation.costDetails).length)
657
+ attributes["langfuse.observation.cost_details"] = JSON.stringify(observation.costDetails);
658
+ setText(attributes, "langfuse.observation.completion_start_time", observation.completionStartTime);
659
+ setText(attributes, "langfuse.observation.prompt.name", observation.promptName);
660
+ if (observation.promptVersion !== undefined)
661
+ attributes["langfuse.observation.prompt.version"] = observation.promptVersion;
662
+ setText(attributes, "langfuse.trace.name", observation.traceName ?? context.name);
663
+ const tags = observation.tags ?? context.tags;
664
+ if (tags?.length)
665
+ attributes["langfuse.trace.tags"] = [...tags];
666
+ setText(attributes, "langfuse.user.id", observation.userId ?? context.userId);
667
+ setText(attributes, "langfuse.session.id", observation.sessionId ?? context.sessionId);
668
+ setText(attributes, "langfuse.release", observation.release ?? context.release);
669
+ setText(attributes, "langfuse.version", observation.version);
670
+ setText(attributes, "langfuse.environment", observation.environment);
671
+ attributes["langfuse.trace.id"] = context.langfuseTraceId;
672
+ attributes["langfuse.observation.id"] = observation.id;
673
+ const parent = observation.parentObservationId
674
+ ? hueSpanId(context.langfuseTraceId, observation.parentObservationId)
675
+ : context.rootSpanId;
676
+ return {
677
+ traceId: hueTraceId(context.langfuseTraceId),
678
+ spanId: hueSpanId(context.langfuseTraceId, observation.id),
679
+ ...(parent ? { parentSpanId: parent } : {}),
680
+ name: observation.name ?? type ?? "observation",
681
+ start,
682
+ end,
683
+ attributes,
684
+ ...(observation.level === "ERROR" ? { error: observation.statusMessage ?? "" } : {}),
685
+ };
686
+ }
687
+ /** The context a v1 trace gives its observations. */
688
+ export function traceContext(trace) {
689
+ return {
690
+ langfuseTraceId: trace.id,
691
+ rootSpanId: traceSpanId(trace.id),
692
+ name: trace.name,
693
+ userId: trace.userId,
694
+ sessionId: trace.sessionId,
695
+ tags: trace.tags,
696
+ release: trace.release,
697
+ };
698
+ }
699
+ /**
700
+ * The span that stands for a v1 trace, the parent of its observations without one, as the trace
701
+ * Langfuse shows above them: `langfuse.trace.*` (name, input, output, one `metadata.<key>`
702
+ * attribute per key, tags, public), the user, session, release, version and environment. It
703
+ * covers the trace's timestamp and its observations, `observed`. Throws for a timestamp that is
704
+ * no ISO 8601 instant.
705
+ */
706
+ export function traceSpan(trace, observed) {
707
+ const timestamp = hrTimeOf(trace.timestamp);
708
+ if (!timestamp)
709
+ throw new LangfuseError("A trace's timestamp is not an ISO 8601 instant");
710
+ // An observation can start before the trace's own timestamp: the span covers both.
711
+ const start = observed ? earlier(timestamp, observed.start) : timestamp;
712
+ const attributes = {};
713
+ setText(attributes, "langfuse.trace.name", trace.name);
714
+ setText(attributes, "langfuse.trace.input", attributeText(trace.input));
715
+ setText(attributes, "langfuse.trace.output", attributeText(trace.output));
716
+ metadataAttributes("langfuse.trace.metadata", trace.metadata, attributes);
717
+ if (trace.tags?.length)
718
+ attributes["langfuse.trace.tags"] = [...trace.tags];
719
+ if (trace.public !== undefined)
720
+ attributes["langfuse.trace.public"] = trace.public;
721
+ setText(attributes, "langfuse.user.id", trace.userId);
722
+ setText(attributes, "langfuse.session.id", trace.sessionId);
723
+ setText(attributes, "langfuse.release", trace.release);
724
+ setText(attributes, "langfuse.version", trace.version);
725
+ setText(attributes, "langfuse.environment", trace.environment);
726
+ attributes["langfuse.trace.id"] = trace.id;
727
+ return {
728
+ traceId: hueTraceId(trace.id),
729
+ spanId: traceSpanId(trace.id),
730
+ name: trace.name ?? "trace",
731
+ start,
732
+ end: observed ? later(observed.end, start) : start,
733
+ attributes,
734
+ };
735
+ }
736
+ /** A converted span as the OpenTelemetry SDK hands a finished span to a processor. */
737
+ export function readableSpan(span, resource, scope) {
738
+ const context = { traceId: span.traceId, spanId: span.spanId, traceFlags: TraceFlags.SAMPLED };
739
+ return {
740
+ name: span.name,
741
+ kind: SpanKind.INTERNAL,
742
+ spanContext: () => context,
743
+ ...(span.parentSpanId
744
+ ? {
745
+ parentSpanContext: {
746
+ traceId: span.traceId,
747
+ spanId: span.parentSpanId,
748
+ traceFlags: TraceFlags.SAMPLED,
749
+ },
750
+ }
751
+ : {}),
752
+ startTime: span.start,
753
+ endTime: span.end,
754
+ duration: minus(span.end, span.start),
755
+ status: span.error === undefined
756
+ ? { code: SpanStatusCode.UNSET }
757
+ : { code: SpanStatusCode.ERROR, ...(span.error ? { message: span.error } : {}) },
758
+ attributes: span.attributes,
759
+ links: [],
760
+ events: [],
761
+ ended: true,
762
+ resource,
763
+ instrumentationScope: scope,
764
+ droppedAttributesCount: 0,
765
+ droppedEventsCount: 0,
766
+ droppedLinksCount: 0,
767
+ };
768
+ }
769
+ const TOKEN_MARK = "@@@langfuseMedia:";
770
+ const EMBEDDED_TOKENS = /@@@langfuseMedia:[^@]*@@@/gu;
771
+ /** The token a whole string is (`@@@langfuseMedia:type=<media type>|id=<id>|source=<source>@@@`),
772
+ * or undefined when it is not exactly one. */
773
+ export function parseMediaToken(value) {
774
+ if (!value.startsWith(TOKEN_MARK) || !value.endsWith("@@@"))
775
+ return undefined;
776
+ const inner = value.slice(TOKEN_MARK.length, -3);
777
+ if (inner.includes("@") || /\s/u.test(inner))
778
+ return undefined;
779
+ const fields = new Map();
780
+ for (const field of inner.split("|")) {
781
+ const equals = field.indexOf("=");
782
+ if (equals === -1)
783
+ return undefined;
784
+ const name = field.slice(0, equals);
785
+ if (fields.has(name))
786
+ return undefined;
787
+ fields.set(name, field.slice(equals + 1));
788
+ }
789
+ const type = fields.get("type");
790
+ const id = fields.get("id");
791
+ const source = fields.get("source");
792
+ if (!type || !id || !source || !/^[A-Za-z0-9_-]{1,128}$/u.test(id) || !isMediaContentType(type))
793
+ return undefined;
794
+ return { type, id, source };
795
+ }
796
+ function countTokens(value) {
797
+ let count = 0;
798
+ for (const match of value.matchAll(EMBEDDED_TOKENS))
799
+ if (parseMediaToken(match[0]))
800
+ count++;
801
+ return count;
802
+ }
803
+ /** The span attributes whose text Hue reads files from and the import rewrites tokens in. */
804
+ export function isMediaAttribute(key) {
805
+ return (key === "langfuse.observation.input" ||
806
+ key === "langfuse.observation.output" ||
807
+ key === "langfuse.trace.input" ||
808
+ key === "langfuse.trace.output" ||
809
+ key === "langfuse.observation.metadata" ||
810
+ key === "langfuse.trace.metadata" ||
811
+ key.startsWith("langfuse.observation.metadata.") ||
812
+ key.startsWith("langfuse.trace.metadata."));
813
+ }
814
+ /** What a token's file is replaced by when Hue reads it: a `data:` URL or bare base64 that the
815
+ * SDK's own reader finds at the token's place. Its bytes are never read. */
816
+ function probe(token, kind) {
817
+ return kind === "url" ? `data:${token.type};base64,AAAA` : "AAAA";
818
+ }
819
+ /** Each string of a parsed value that is exactly a token, replaced with `replace(token,
820
+ * pointer)`; the tokens inside longer text are counted. Whether Hue reads a file at a token's
821
+ * place (no deeper than ten levels, in a part it reads) is left to the SDK's
822
+ * reader. The value nests at most 32 levels (`readerParses`). */
823
+ function walkTokens(value, replace) {
824
+ const found = [];
825
+ let kept = 0;
826
+ let root;
827
+ const visit = (node, pointer, set) => {
828
+ if (typeof node === "string") {
829
+ if (!node.includes(TOKEN_MARK))
830
+ return;
831
+ const token = parseMediaToken(node);
832
+ if (token) {
833
+ found.push({ token, pointer });
834
+ set(replace(token, pointer));
835
+ }
836
+ else
837
+ kept += countTokens(node);
838
+ return;
839
+ }
840
+ if (Array.isArray(node)) {
841
+ node.forEach((item, index) => visit(item, `${pointer}/${index}`, (next) => (node[index] = next)));
842
+ return;
843
+ }
844
+ const fields = object(node);
845
+ if (!fields)
846
+ return;
847
+ for (const [key, item] of Object.entries(fields))
848
+ visit(item, `${pointer}/${key.replaceAll("~", "~0").replaceAll("/", "~1")}`, (next) => (fields[key] = next));
849
+ };
850
+ visit(value, "", (next) => (root = next));
851
+ return { found, kept, root };
852
+ }
853
+ /**
854
+ * The tokens in an attribute's text, found as Hue reads that text: a text that is exactly one
855
+ * token, or JSON Hue parses (not nested past 32 levels, with no integer past 2^53), its tokens no
856
+ * deeper than ten levels. Each is placed by the SDK's own reader of recorded media: a token where
857
+ * a provider part keeps its bytes (OpenAI `image_url.url` and `input_audio.data`, Anthropic
858
+ * `source.data`, Gemini `inlineData.data`, a GenAI `blob` part's `content`, …) is that part's
859
+ * file, and any other token is a whole-string file. Undefined when the text holds no token.
860
+ */
861
+ function attributeMedia(attribute, textValue) {
862
+ if (!textValue.includes(TOKEN_MARK))
863
+ return undefined;
864
+ const bare = parseMediaToken(textValue);
865
+ const parse = () => {
866
+ if (bare)
867
+ return textValue;
868
+ if (!/^\s*[[{"]/u.test(textValue))
869
+ return undefined;
870
+ try {
871
+ const parsed = JSON.parse(textValue);
872
+ return readerParses(parsed) ? parsed : undefined;
873
+ }
874
+ catch {
875
+ return undefined;
876
+ }
877
+ };
878
+ const read = (choose) => {
879
+ const value = parse();
880
+ if (value === undefined)
881
+ return undefined;
882
+ const walked = walkTokens(value, (token, pointer) => probe(token, choose(token, pointer)));
883
+ const root = walked.root ?? value;
884
+ // A bare token is the text itself, not JSON: the reading's edits replace it whole.
885
+ const media = bare ? mediaIn(root) : mediaIn(root, textValue);
886
+ const files = new Map();
887
+ for (const file of media.files)
888
+ files.set(file.pointer, file);
889
+ return { walked, media, files };
890
+ };
891
+ const first = read(() => "url");
892
+ if (!first)
893
+ return { kept: countTokens(textValue) };
894
+ // Tokens a `data:` URL does not fill are tried as bare base64 (an OpenAI `input_audio`, an
895
+ // Anthropic or Gemini source): the part reads its bytes as base64 there.
896
+ const missing = new Set(first.walked.found.filter(({ pointer }) => !first.files.has(pointer)).map((use) => use.pointer));
897
+ const chosen = missing.size
898
+ ? read((_token, pointer) => (missing.has(pointer) ? "base64" : "url"))
899
+ : first;
900
+ const uses = [];
901
+ const files = new Map();
902
+ let kept = chosen.walked.kept;
903
+ for (const { token, pointer } of chosen.walked.found) {
904
+ const file = chosen.files.get(pointer);
905
+ if (!file || file.encoded?.length) {
906
+ kept++;
907
+ continue;
908
+ }
909
+ files.set(pointer, file);
910
+ uses.push({ attribute, token, pointer, key: `${attribute}#${file.partPointer}` });
911
+ }
912
+ return uses.length
913
+ ? { located: { attribute, uses, media: chosen.media, files }, kept }
914
+ : { kept };
915
+ }
916
+ /** The tokens of a span's media attributes. */
917
+ export function spanMedia(span) {
918
+ const result = { attributes: [], kept: 0 };
919
+ for (const [key, value] of Object.entries(span.attributes)) {
920
+ if (typeof value !== "string" || !isMediaAttribute(key))
921
+ continue;
922
+ const found = attributeMedia(key, value);
923
+ if (!found)
924
+ continue;
925
+ result.kept += found.kept;
926
+ if (found.located)
927
+ result.attributes.push(found.located);
928
+ }
929
+ return result;
930
+ }
931
+ /**
932
+ * The span's attributes with each token whose file was uploaded in the digest form the SDK
933
+ * writes for a recorded file, `{"type":"blob","mime_type","modality","sha256","size"}` for a
934
+ * whole string or the provider part with its bytes removed and `sha256` and `size` set, every
935
+ * other byte of the attribute's text kept, and each file listed under `hue.blobs` as
936
+ * `<attribute>#<pointer>`. At most {@link MAX_BLOB_ENTRIES} files per span are placed; the rest
937
+ * stay tokens. Returns how many were placed.
938
+ */
939
+ export function placeMedia(span, media, outcomes) {
940
+ const entries = [];
941
+ for (const located of media.attributes) {
942
+ let changed = false;
943
+ for (const use of located.uses) {
944
+ const file = outcomes.get(use);
945
+ if (!file)
946
+ continue;
947
+ digestPart(located.files.get(use.pointer), { sha256: file.sha256, size: file.size });
948
+ entries.push(blobEntry(use.key, file.sha256, file.size, file.contentType));
949
+ changed = true;
950
+ }
951
+ if (changed)
952
+ span.attributes[located.attribute] = placedMediaText(located.media);
953
+ }
954
+ if (entries.length)
955
+ span.attributes[BLOBS_KEY] = entries;
956
+ return entries.length;
957
+ }
958
+ /** Whether Hue accepts a `hue.blobs` key: 1 to 256 code units, no control characters. A file
959
+ * whose key it would refuse stays a token. */
960
+ function isBlobKey(key) {
961
+ if (key.length < 1 || key.length > 256)
962
+ return false;
963
+ for (let index = 0; index < key.length; index++) {
964
+ const code = key.charCodeAt(index);
965
+ if (code < 0x20 || code === 0x7f)
966
+ return false;
967
+ }
968
+ return true;
969
+ }
970
+ /** The uses of a span that may be uploaded: the first {@link MAX_BLOB_ENTRIES} with a key Hue
971
+ * accepts. */
972
+ export function uploadableUses(media) {
973
+ const uses = [];
974
+ let kept = 0;
975
+ for (const located of media.attributes)
976
+ for (const use of located.uses) {
977
+ if (uses.length < MAX_BLOB_ENTRIES && isBlobKey(use.key))
978
+ uses.push(use);
979
+ else
980
+ kept++;
981
+ }
982
+ return { uses, kept };
983
+ }
984
+ /**
985
+ * Reads each file of a trace's tokens from Langfuse once and uploads it under the trace with the
986
+ * SDK's upload route (reservation, presigned PUT, completion); another reference to the same
987
+ * file in the trace reuses the outcome. Bytes are held only while their file uploads.
988
+ */
989
+ export class TraceMedia {
990
+ api;
991
+ uploader;
992
+ traceId;
993
+ upload;
994
+ outcomes = new Map();
995
+ constructor(api, uploader, traceId, upload) {
996
+ this.api = api;
997
+ this.uploader = uploader;
998
+ this.traceId = traceId;
999
+ this.upload = upload;
1000
+ }
1001
+ /** The outcome for a token's file, read and uploaded on its first use in the trace. */
1002
+ file(use) {
1003
+ let outcome = this.outcomes.get(use.token.id);
1004
+ if (!outcome) {
1005
+ outcome = this.fresh(use);
1006
+ this.outcomes.set(use.token.id, outcome);
1007
+ }
1008
+ return outcome;
1009
+ }
1010
+ async fresh(use) {
1011
+ let bytes;
1012
+ try {
1013
+ bytes = await this.api.media(use.token.id);
1014
+ }
1015
+ catch (error) {
1016
+ if (error instanceof ImportStopped || (error instanceof LangfuseError && error.fatal))
1017
+ throw error;
1018
+ return { kind: "failed", reason: error.message };
1019
+ }
1020
+ if (typeof bytes === "string" || bytes.byteLength === 0)
1021
+ return { kind: "kept" };
1022
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
1023
+ const stored = await this.uploader.store(this.traceId, use.key, bytes, use.token.type, { sha256, size: bytes.byteLength }, this.upload());
1024
+ if (stored.ok)
1025
+ return {
1026
+ kind: "uploaded",
1027
+ file: { sha256, size: bytes.byteLength, contentType: stored.contentType },
1028
+ };
1029
+ // A file over Hue's limit for one value stays a token; any other refusal is tried again later.
1030
+ if (stored.reason === "tooLarge")
1031
+ return { kind: "kept" };
1032
+ return { kind: "failed", reason: FALLBACK_REASONS[stored.reason] };
1033
+ }
1034
+ }
1035
+ /**
1036
+ * The rest of a span's content stored as the SDK's exporter stores it, but before the span is
1037
+ * sent: files inline in the attributes Hue reads files from (a `data:` URL or base64 a provider
1038
+ * part holds) uploaded and placed in their digest form, and text values still over Hue's inline
1039
+ * limit (1 MiB of UTF-8, the limit Hue advertises) uploaded whole and replaced by their first
1040
+ * 16 KiB, each listed in `hue.blobs` (and a whole value in `hue.truncated`). This is the
1041
+ * exporter's own placement (`offloadAttributes`), run here so that a file or value that cannot be
1042
+ * stored now holds its span back rather than being kept inline or cut, and every run sends the
1043
+ * same span. A file or value over a limit (Hue's size limit, the span's 64 entries) is placed
1044
+ * alike on every run and only counted. Resolves to the reason the span is held back, if it is.
1045
+ */
1046
+ export async function storeSpanContent(span, uploader, upload) {
1047
+ const candidates = Object.entries(span.attributes).flatMap(([key, value]) => typeof value === "string" &&
1048
+ (mayInlineFiles(key, value) || Buffer.byteLength(value, "utf8") > MAX_CONTENT_BYTES)
1049
+ ? [{ key, value }]
1050
+ : []);
1051
+ if (!candidates.length)
1052
+ return { stored: 0, overLimits: 0 };
1053
+ const tally = newTally();
1054
+ // One attribute at a time, in the span's order: the exporter places a value's files and then
1055
+ // the value itself, so values placed together would take the span's last `hue.blobs` places in
1056
+ // the order their uploads finish, which a later run could reverse.
1057
+ let placed = span.attributes;
1058
+ for (const candidate of candidates) {
1059
+ placed = await offloadAttributes(placed, { candidates: [candidate], cut: [] }, { uploader, traceId: span.traceId, valueBytes: MAX_CONTENT_BYTES, upload, tally });
1060
+ for (const reason of ["unsupported", "failed"])
1061
+ if (tally.fallbacks.has(reason))
1062
+ return { heldBack: FALLBACK_REASONS[reason], stored: 0, overLimits: 0 };
1063
+ }
1064
+ span.attributes = placed;
1065
+ let overLimits = 0;
1066
+ for (const { count } of tally.fallbacks.values())
1067
+ overLimits += count;
1068
+ return { stored: tally.uploaded, overLimits };
1069
+ }
1070
+ const FALLBACK_REASONS = {
1071
+ unsupported: "this Hue server does not accept uploaded files",
1072
+ failed: "Hue did not store the file",
1073
+ tooLarge: "the file is over Hue's limit",
1074
+ budget: "the upload did not finish in time",
1075
+ };