@hue-run/sdk 0.21.1 → 0.23.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 +246 -52
- package/README.md +69 -14
- package/WORLDS.md +6 -3
- package/dist/ai-sdk.d.ts +23 -1
- package/dist/ai-sdk.js +60 -2
- package/dist/cli/import-formats/adapter.d.ts +80 -0
- package/dist/cli/import-formats/adapter.js +1 -0
- package/dist/cli/import-formats/agent-events.d.ts +5 -0
- package/dist/cli/import-formats/agent-events.js +16 -0
- package/dist/cli/import-formats/atif.d.ts +3 -0
- package/dist/cli/import-formats/atif.js +15 -0
- package/dist/cli/import-formats/braintrust.d.ts +2 -0
- package/dist/cli/import-formats/braintrust.js +222 -0
- package/dist/cli/import-formats/containers.d.ts +32 -0
- package/dist/cli/import-formats/containers.js +399 -0
- package/dist/cli/import-formats/datadog.d.ts +2 -0
- package/dist/cli/import-formats/datadog.js +264 -0
- package/dist/cli/import-formats/helicone.d.ts +2 -0
- package/dist/cli/import-formats/helicone.js +161 -0
- package/dist/cli/import-formats/identity.d.ts +112 -0
- package/dist/cli/import-formats/identity.js +594 -0
- package/dist/cli/import-formats/jaeger.d.ts +3 -0
- package/dist/cli/import-formats/jaeger.js +266 -0
- package/dist/cli/import-formats/langfuse.d.ts +7 -0
- package/dist/cli/import-formats/langfuse.js +510 -0
- package/dist/cli/import-formats/langsmith.d.ts +2 -0
- package/dist/cli/import-formats/langsmith.js +244 -0
- package/dist/cli/import-formats/messages.d.ts +28 -0
- package/dist/cli/import-formats/messages.js +301 -0
- package/dist/cli/import-formats/mlflow.d.ts +2 -0
- package/dist/cli/import-formats/mlflow.js +260 -0
- package/dist/cli/import-formats/openai-agents.d.ts +2 -0
- package/dist/cli/import-formats/openai-agents.js +221 -0
- package/dist/cli/import-formats/opik.d.ts +2 -0
- package/dist/cli/import-formats/opik.js +193 -0
- package/dist/cli/import-formats/otel-family.d.ts +5 -0
- package/dist/cli/import-formats/otel-family.js +17 -0
- package/dist/cli/import-formats/otel-spans.d.ts +94 -0
- package/dist/cli/import-formats/otel-spans.js +262 -0
- package/dist/cli/import-formats/otlp-protobuf.d.ts +35 -0
- package/dist/cli/import-formats/otlp-protobuf.js +486 -0
- package/dist/cli/import-formats/otlp.d.ts +4 -0
- package/dist/cli/import-formats/otlp.js +15 -0
- package/dist/cli/import-formats/phoenix.d.ts +3 -0
- package/dist/cli/import-formats/phoenix.js +256 -0
- package/dist/cli/import-formats/raindrop.d.ts +21 -0
- package/dist/cli/import-formats/raindrop.js +255 -0
- package/dist/cli/import-formats/reader.d.ts +56 -0
- package/dist/cli/import-formats/reader.js +364 -0
- package/dist/cli/import-formats/registry.d.ts +36 -0
- package/dist/cli/import-formats/registry.js +71 -0
- package/dist/cli/import-formats/row-platform-fields.d.ts +32 -0
- package/dist/cli/import-formats/row-platform-fields.js +76 -0
- package/dist/cli/import-formats/rows.d.ts +165 -0
- package/dist/cli/import-formats/rows.js +638 -0
- package/dist/cli/import-formats/transcripts.d.ts +25 -0
- package/dist/cli/import-formats/transcripts.js +498 -0
- package/dist/cli/import-formats/weave.d.ts +2 -0
- package/dist/cli/import-formats/weave.js +159 -0
- package/dist/cli/import-formats/zipkin.d.ts +2 -0
- package/dist/cli/import-formats/zipkin.js +219 -0
- package/dist/cli/import.d.ts +1 -1
- package/dist/cli/import.js +4 -2
- package/dist/cli/traces-import-convert.d.ts +65 -33
- package/dist/cli/traces-import-convert.js +211 -214
- package/dist/cli/traces-import-prepare.d.ts +26 -22
- package/dist/cli/traces-import-prepare.js +51 -122
- package/dist/cli/traces-import.d.ts +4 -4
- package/dist/cli/traces-import.js +63 -11
- package/dist/transport.d.ts +12 -0
- package/dist/transport.js +139 -24
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
import { anyValue, AttributeList, BlobSink, canonicalJson, ConversionError, conversionFailure, digestHex, MAX_INLINE_VALUE_BYTES, sha256Hex, SOURCE_RECORD, validateSource, } from "../traces-import-convert.js";
|
|
2
|
+
import { decideKey, namesIdentity, resolveKey, subtreeIds, withoutIdentity, } from "./identity.js";
|
|
3
|
+
import { genAiMessages } from "./messages.js";
|
|
4
|
+
const OPERATIONS = {
|
|
5
|
+
agent: "invoke_agent",
|
|
6
|
+
llm: "chat",
|
|
7
|
+
tool: "execute_tool",
|
|
8
|
+
embedding: "embeddings",
|
|
9
|
+
retrieval: "retrieval",
|
|
10
|
+
};
|
|
11
|
+
/** The instrumentation scope version of every row span: raised when the emitted shape changes. */
|
|
12
|
+
const ROW_IMPORT_VERSION = "1";
|
|
13
|
+
const MAX_KEY = 4096;
|
|
14
|
+
/** Where an untimed trace is placed: 2000-01-01T00:00:00Z, a millisecond per record in order. */
|
|
15
|
+
const UNTIMED_EPOCH = 946684800000000000n;
|
|
16
|
+
const MILLISECOND = 1000000n;
|
|
17
|
+
const MAX_NANOS = 18446744073709551615n;
|
|
18
|
+
const UNITS = { s: 9, ms: 6, us: 3, ns: 0 };
|
|
19
|
+
/** A timestamp in `unit` (a number or its decimal text, exponent allowed) as Unix nanoseconds,
|
|
20
|
+
* read from its decimal digits so no float rounding is added. */
|
|
21
|
+
export function nanosFrom(value, unit) {
|
|
22
|
+
const text = typeof value === "number" && Number.isFinite(value)
|
|
23
|
+
? String(value)
|
|
24
|
+
: typeof value === "string"
|
|
25
|
+
? value
|
|
26
|
+
: "";
|
|
27
|
+
const match = /^(\d+)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/.exec(text);
|
|
28
|
+
if (!match)
|
|
29
|
+
throw new ConversionError("Invalid source timestamp");
|
|
30
|
+
const shift = UNITS[unit] + Number(match[3] ?? 0);
|
|
31
|
+
const digits = match[1] + (match[2] ?? "");
|
|
32
|
+
const point = match[1].length + shift;
|
|
33
|
+
if (point > 40)
|
|
34
|
+
throw new ConversionError("Invalid source timestamp");
|
|
35
|
+
const whole = point <= 0 ? "0" : digits.padEnd(point, "0").slice(0, point);
|
|
36
|
+
const nanos = BigInt(whole);
|
|
37
|
+
if (nanos <= 0n || nanos > MAX_NANOS)
|
|
38
|
+
throw new ConversionError("Invalid source timestamp");
|
|
39
|
+
return nanos;
|
|
40
|
+
}
|
|
41
|
+
/** Reads each record with `read`: a record that throws a ConversionError (or is too deep or
|
|
42
|
+
* large to walk) refuses the trace `traceKeyOf` names (or is reported alone), never the rest of
|
|
43
|
+
* the value. */
|
|
44
|
+
export function readRecords(context, records, traceKeyOf, read) {
|
|
45
|
+
for (const [index, record] of records.entries())
|
|
46
|
+
try {
|
|
47
|
+
read(record);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
const failure = conversionFailure(error);
|
|
51
|
+
if (!failure)
|
|
52
|
+
throw error;
|
|
53
|
+
const at = records.length > 1 ? `record ${index + 1}: ` : "";
|
|
54
|
+
let traceKey;
|
|
55
|
+
try {
|
|
56
|
+
traceKey = traceKeyOf(record);
|
|
57
|
+
}
|
|
58
|
+
catch (keyError) {
|
|
59
|
+
if (!conversionFailure(keyError))
|
|
60
|
+
throw keyError;
|
|
61
|
+
}
|
|
62
|
+
context.refuse(`${at}${failure.message}`, traceKey);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** A 32- or 16-hex OpenTelemetry id as lowercase hex, or undefined. */
|
|
66
|
+
function otelId(value, length) {
|
|
67
|
+
const lower = value.toLowerCase();
|
|
68
|
+
return lower.length === length && /^[0-9a-f]+$/.test(lower) && !/^0+$/.test(lower)
|
|
69
|
+
? lower
|
|
70
|
+
: undefined;
|
|
71
|
+
}
|
|
72
|
+
/** The OpenTelemetry trace id of a row format's trace: its own id when it is one, otherwise one
|
|
73
|
+
* derived from the format and the id by SHA-256. */
|
|
74
|
+
export const rowTraceId = (format, traceKey) => otelId(traceKey, 32) ?? digestHex(`${format}.trace:${traceKey}`, 32);
|
|
75
|
+
function key(value, what) {
|
|
76
|
+
if (typeof value !== "string" || !value || value.length > MAX_KEY)
|
|
77
|
+
throw new ConversionError(`The record has no valid ${what}`);
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
/** Orders revisions; undefined when either is missing. */
|
|
81
|
+
function compareRevisions(a, b) {
|
|
82
|
+
if (a === undefined || b === undefined)
|
|
83
|
+
return undefined;
|
|
84
|
+
if (/^\d+$/.test(a) && /^\d+$/.test(b)) {
|
|
85
|
+
const [x, y] = [BigInt(a), BigInt(b)];
|
|
86
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
87
|
+
}
|
|
88
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
89
|
+
}
|
|
90
|
+
/** Gathers one row format's rows by trace across files and lines, and emits each trace. */
|
|
91
|
+
export class RowTraceCollector {
|
|
92
|
+
format;
|
|
93
|
+
keys;
|
|
94
|
+
traces = new Map();
|
|
95
|
+
constructor(format, keys = {}) {
|
|
96
|
+
this.format = format;
|
|
97
|
+
this.keys = keys;
|
|
98
|
+
}
|
|
99
|
+
draft(traceKey) {
|
|
100
|
+
let draft = this.traces.get(traceKey);
|
|
101
|
+
if (!draft) {
|
|
102
|
+
draft = { rows: new Map(), events: new Map(), sourceDigests: new Set() };
|
|
103
|
+
this.traces.set(traceKey, draft);
|
|
104
|
+
}
|
|
105
|
+
return draft;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Adds a row read at `at`. Returns where an identical copy of the record was read before, when
|
|
109
|
+
* one was read somewhere else: the record is then read once, and the caller may say so.
|
|
110
|
+
*/
|
|
111
|
+
add(row, sourceDigest, at) {
|
|
112
|
+
key(row.traceKey, "trace id");
|
|
113
|
+
key(row.spanKey, "span id");
|
|
114
|
+
validateSource(row.record);
|
|
115
|
+
const draft = this.draft(row.traceKey);
|
|
116
|
+
draft.sourceDigests.add(sourceDigest);
|
|
117
|
+
const digest = sha256Hex(canonicalJson(row.record));
|
|
118
|
+
const held = draft.rows.get(row.spanKey);
|
|
119
|
+
if (!held) {
|
|
120
|
+
draft.rows.set(row.spanKey, { row, digest, ...(at ? { at } : {}) });
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
if (held.digest === digest) {
|
|
124
|
+
const elsewhere = at &&
|
|
125
|
+
held.at &&
|
|
126
|
+
(held.at.file !== at.file || held.at.line !== at.line || held.at.record !== at.record);
|
|
127
|
+
return elsewhere ? held.at : undefined;
|
|
128
|
+
}
|
|
129
|
+
const order = compareRevisions(held.row.revision, row.revision);
|
|
130
|
+
if (order === undefined || order === 0)
|
|
131
|
+
draft.refusal ??= `span ${row.spanKey} appears with different content`;
|
|
132
|
+
else if (order < 0)
|
|
133
|
+
draft.rows.set(row.spanKey, { row, digest, ...(at ? { at } : {}) });
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Adds an event. `repeats` counts, for one source value, the identical records it gave each span
|
|
138
|
+
* so far: an event the value repeats on a span is kept each time, the same value read again (a
|
|
139
|
+
* file read twice) adds nothing.
|
|
140
|
+
*/
|
|
141
|
+
addEvent(event, sourceDigest, repeats = new Map()) {
|
|
142
|
+
key(event.traceKey, "trace id");
|
|
143
|
+
key(event.spanKey, "span id");
|
|
144
|
+
validateSource(event.record);
|
|
145
|
+
const draft = this.draft(event.traceKey);
|
|
146
|
+
draft.sourceDigests.add(sourceDigest);
|
|
147
|
+
const copy = `${event.traceKey}\u0000${event.spanKey}\u0000${eventDigest(event)}`;
|
|
148
|
+
const occurrence = repeats.get(copy) ?? 0;
|
|
149
|
+
repeats.set(copy, occurrence + 1);
|
|
150
|
+
const id = `${copy}\u0000${occurrence}`;
|
|
151
|
+
if (!draft.events.has(id))
|
|
152
|
+
draft.events.set(id, event);
|
|
153
|
+
}
|
|
154
|
+
finish() {
|
|
155
|
+
const traces = [];
|
|
156
|
+
const refused = [];
|
|
157
|
+
for (const [traceKey, draft] of this.traces) {
|
|
158
|
+
// Each draft is let go once its trace is emitted, so a run holds one copy of each record.
|
|
159
|
+
this.traces.delete(traceKey);
|
|
160
|
+
const traceId = rowTraceId(this.format, traceKey);
|
|
161
|
+
if (draft.refusal || !draft.rows.size) {
|
|
162
|
+
refused.push({
|
|
163
|
+
traceId,
|
|
164
|
+
reason: draft.refusal ?? "the export holds events of this trace but none of its spans",
|
|
165
|
+
});
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
traces.push(this.emit(traceKey, traceId, draft, refused));
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
const failure = conversionFailure(error);
|
|
173
|
+
if (!failure)
|
|
174
|
+
throw error;
|
|
175
|
+
refused.push({ traceId, reason: failure.message });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { traces, refused };
|
|
179
|
+
}
|
|
180
|
+
emit(traceKey, traceId, draft, refused) {
|
|
181
|
+
const format = this.format;
|
|
182
|
+
const keys = [...draft.rows.keys()].sort();
|
|
183
|
+
const rows = new Map(keys.map((spanKey) => [spanKey, draft.rows.get(spanKey).row]));
|
|
184
|
+
// Span ids in key order, so a collision is salted the same way whatever order rows arrived.
|
|
185
|
+
const spanIds = new Map();
|
|
186
|
+
const taken = new Set();
|
|
187
|
+
let remappedSpans = 0;
|
|
188
|
+
for (const spanKey of keys) {
|
|
189
|
+
let spanId = otelId(spanKey, 16) ?? digestHex(`${format}.span:${traceId}:${spanKey}`, 16);
|
|
190
|
+
for (let salt = 0; taken.has(spanId); salt++)
|
|
191
|
+
spanId = digestHex(`${format}.span:${traceId}:${spanKey}#${salt}`, 16);
|
|
192
|
+
if (spanId !== spanKey)
|
|
193
|
+
remappedSpans++;
|
|
194
|
+
taken.add(spanId);
|
|
195
|
+
spanIds.set(spanKey, spanId);
|
|
196
|
+
}
|
|
197
|
+
const parentOf = (row) => row.parentKey !== undefined && row.parentKey !== row.spanKey && rows.has(row.parentKey)
|
|
198
|
+
? row.parentKey
|
|
199
|
+
: undefined;
|
|
200
|
+
if (cyclic(rows, parentOf))
|
|
201
|
+
throw new ConversionError("The source hierarchy contains a cycle");
|
|
202
|
+
const { starts, ends, notes } = timesOf(rows, parentOf);
|
|
203
|
+
const traceNotes = new Set([...notes.values()].flat());
|
|
204
|
+
// The trace's user and conversation: from the identity keys when they name one across the
|
|
205
|
+
// agent's rows, else from the rows' own; tool rows and rows beneath them never count.
|
|
206
|
+
const spans = keys.map((spanKey) => {
|
|
207
|
+
const row = rows.get(spanKey);
|
|
208
|
+
const parentKey = parentOf(row);
|
|
209
|
+
return {
|
|
210
|
+
spanId: spanKey,
|
|
211
|
+
...(parentKey !== undefined ? { parentSpanId: parentKey } : {}),
|
|
212
|
+
row,
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
// The vendor's own evaluation inside the trace, and every row beneath it.
|
|
216
|
+
const scored = subtreeIds(spans, (span) => span.row.scoring === true);
|
|
217
|
+
const tool = (span) => span.row.kind === "tool" || span.row.scoring === true;
|
|
218
|
+
const keyed = (path, span) => path &&
|
|
219
|
+
resolveKey(path, {
|
|
220
|
+
attributes: new Map(Object.entries(span.row.attributes ?? {})),
|
|
221
|
+
resource: new Map(Object.entries(span.row.resource ?? {})),
|
|
222
|
+
}, span.row.record, span.row.metadata);
|
|
223
|
+
const decide = (path, own) => {
|
|
224
|
+
const fromKey = path ? decideKey(spans, tool, (span) => keyed(path, span)) : { found: false };
|
|
225
|
+
return fromKey.found
|
|
226
|
+
? { keyed: true, value: fromKey.value }
|
|
227
|
+
: { keyed: false, value: decideKey(spans, tool, (span) => own(span.row)).value };
|
|
228
|
+
};
|
|
229
|
+
const user = decide(this.keys.user, (row) => row.user);
|
|
230
|
+
const session = decide(this.keys.session, (row) => row.session);
|
|
231
|
+
// What a key decided, a user or none, is the trace's: no span keeps a recorded attribute Hue
|
|
232
|
+
// would read another from.
|
|
233
|
+
const keyedKinds = [];
|
|
234
|
+
if (user.keyed)
|
|
235
|
+
keyedKinds.push("user");
|
|
236
|
+
if (session.keyed)
|
|
237
|
+
keyedKinds.push("session");
|
|
238
|
+
const blobs = new BlobSink();
|
|
239
|
+
let origin = "unknown";
|
|
240
|
+
const order = (a, b) => {
|
|
241
|
+
const [x, y] = [starts.get(a), starts.get(b)];
|
|
242
|
+
return x < y ? -1 : x > y ? 1 : spanIds.get(a) < spanIds.get(b) ? -1 : 1;
|
|
243
|
+
};
|
|
244
|
+
const events = new Map();
|
|
245
|
+
for (const event of draft.events.values()) {
|
|
246
|
+
if (!rows.has(event.spanKey)) {
|
|
247
|
+
refused.push({
|
|
248
|
+
reason: `an event for span ${event.spanKey}, which the export does not hold`,
|
|
249
|
+
});
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const held = events.get(event.spanKey);
|
|
253
|
+
if (held)
|
|
254
|
+
held.push(event);
|
|
255
|
+
else
|
|
256
|
+
events.set(event.spanKey, [event]);
|
|
257
|
+
}
|
|
258
|
+
const entries = [...keys].sort(order).map((spanKey) => {
|
|
259
|
+
const row = rows.get(spanKey);
|
|
260
|
+
const parentKey = parentOf(row);
|
|
261
|
+
const root = parentKey === undefined;
|
|
262
|
+
const scoring = scored.has(spanKey);
|
|
263
|
+
if (row.origin === "evaluation")
|
|
264
|
+
origin = "evaluation";
|
|
265
|
+
else if (row.origin === "simulation" && origin !== "evaluation")
|
|
266
|
+
origin = "simulation";
|
|
267
|
+
else if (row.environment === "production" && origin === "unknown")
|
|
268
|
+
origin = "production";
|
|
269
|
+
const attributes = new AttributeList(blobs);
|
|
270
|
+
const own = [];
|
|
271
|
+
for (const [name, value] of Object.entries(row.attributes ?? {})) {
|
|
272
|
+
let kept = value;
|
|
273
|
+
for (const kind of keyedKinds)
|
|
274
|
+
if (kept !== undefined)
|
|
275
|
+
kept = withoutIdentity(kind, name, kept);
|
|
276
|
+
if (kept !== undefined)
|
|
277
|
+
own.push([name, kept]);
|
|
278
|
+
}
|
|
279
|
+
// An attribute the source recorded under its own name is kept as recorded; the emitter
|
|
280
|
+
// adds none of the same name beside it.
|
|
281
|
+
const recorded = new Set(own.map(([name]) => name));
|
|
282
|
+
const add = (name, value) => {
|
|
283
|
+
if (!recorded.has(name))
|
|
284
|
+
attributes.add(name, value);
|
|
285
|
+
};
|
|
286
|
+
if (!scoring) {
|
|
287
|
+
const operation = row.operation ?? (row.kind ? OPERATIONS[row.kind] : undefined);
|
|
288
|
+
if (operation)
|
|
289
|
+
add("gen_ai.operation.name", operation);
|
|
290
|
+
if (row.model)
|
|
291
|
+
add("gen_ai.request.model", row.model);
|
|
292
|
+
if (row.responseModel)
|
|
293
|
+
add("gen_ai.response.model", row.responseModel);
|
|
294
|
+
if (row.provider)
|
|
295
|
+
add("gen_ai.provider.name", row.provider);
|
|
296
|
+
if (row.agentName)
|
|
297
|
+
add("gen_ai.agent.name", row.agentName);
|
|
298
|
+
if (root && row.kind !== "tool") {
|
|
299
|
+
if (user.value !== undefined)
|
|
300
|
+
add("user.id", user.value);
|
|
301
|
+
if (session.value !== undefined)
|
|
302
|
+
add("gen_ai.conversation.id", session.value);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
addContent(add, row, scoring);
|
|
306
|
+
for (const [field, name] of [
|
|
307
|
+
["input", "gen_ai.usage.input_tokens"],
|
|
308
|
+
["output", "gen_ai.usage.output_tokens"],
|
|
309
|
+
["cacheRead", "gen_ai.usage.cache_read.input_tokens"],
|
|
310
|
+
["cacheWrite", "gen_ai.usage.cache_write.input_tokens"],
|
|
311
|
+
["reasoning", "gen_ai.usage.reasoning.output_tokens"],
|
|
312
|
+
]) {
|
|
313
|
+
const count = row.usage?.[field];
|
|
314
|
+
if (!scoring && count !== undefined && Number.isSafeInteger(count) && count >= 0)
|
|
315
|
+
add(name, count);
|
|
316
|
+
}
|
|
317
|
+
if (row.error?.type)
|
|
318
|
+
add("error.type", row.error.type);
|
|
319
|
+
for (const [name, value] of own) {
|
|
320
|
+
if (name.startsWith("hue.import."))
|
|
321
|
+
throw new ConversionError("Source attributes use reserved import keys");
|
|
322
|
+
// A scorer's metadata may name a user; Hue never reads one from the vendor's evaluation.
|
|
323
|
+
if (scoring && namesIdentity(name))
|
|
324
|
+
continue;
|
|
325
|
+
attributes.add(name, value);
|
|
326
|
+
}
|
|
327
|
+
attributes.add("hue.import.format", format);
|
|
328
|
+
if (traceId !== traceKey)
|
|
329
|
+
attributes.add("hue.import.original_trace_id", traceKey);
|
|
330
|
+
if (spanIds.get(spanKey) !== spanKey)
|
|
331
|
+
attributes.add("hue.import.original_span_id", spanKey);
|
|
332
|
+
if (root && row.parentKey !== undefined && row.parentKey !== row.spanKey)
|
|
333
|
+
attributes.add("hue.import.original_parent_span_id", row.parentKey);
|
|
334
|
+
if (row.sequence !== undefined)
|
|
335
|
+
attributes.add("hue.import.source_index", row.sequence);
|
|
336
|
+
const spanNotes = [...(row.timing ? [row.timing] : []), ...(notes.get(spanKey) ?? [])];
|
|
337
|
+
for (const rule of row.timing?.split("; ") ?? [])
|
|
338
|
+
traceNotes.add(rule);
|
|
339
|
+
if (spanNotes.length)
|
|
340
|
+
attributes.add("hue.import.timing", spanNotes.join("; "));
|
|
341
|
+
attributes.add(SOURCE_RECORD, JSON.stringify(row.record));
|
|
342
|
+
const start = starts.get(spanKey);
|
|
343
|
+
const spanEvents = (events.get(spanKey) ?? [])
|
|
344
|
+
.map((event) => {
|
|
345
|
+
if (event.time === undefined)
|
|
346
|
+
traceNotes.add("event_at_span_start");
|
|
347
|
+
return { event, time: event.time ?? start };
|
|
348
|
+
})
|
|
349
|
+
.sort((a, b) => a.time < b.time
|
|
350
|
+
? -1
|
|
351
|
+
: a.time > b.time
|
|
352
|
+
? 1
|
|
353
|
+
: eventDigest(a.event) < eventDigest(b.event)
|
|
354
|
+
? -1
|
|
355
|
+
: 1)
|
|
356
|
+
.flatMap(({ event, time }) => {
|
|
357
|
+
const record = JSON.stringify(event.record);
|
|
358
|
+
if (Buffer.byteLength(record) > MAX_INLINE_VALUE_BYTES) {
|
|
359
|
+
refused.push({ reason: `an event of span ${spanKey} is over the inline value limit` });
|
|
360
|
+
return [];
|
|
361
|
+
}
|
|
362
|
+
return [
|
|
363
|
+
{
|
|
364
|
+
timeUnixNano: time.toString(),
|
|
365
|
+
name: event.name,
|
|
366
|
+
attributes: [
|
|
367
|
+
...Object.entries(event.attributes ?? {}).map(([name, value]) => ({
|
|
368
|
+
key: name,
|
|
369
|
+
value: anyValue(value),
|
|
370
|
+
})),
|
|
371
|
+
{ key: SOURCE_RECORD, value: { stringValue: record } },
|
|
372
|
+
],
|
|
373
|
+
},
|
|
374
|
+
];
|
|
375
|
+
});
|
|
376
|
+
const links = [
|
|
377
|
+
...(row.linkKeys ?? [])
|
|
378
|
+
.filter((link) => rows.has(link) && link !== parentKey)
|
|
379
|
+
.map((link) => ({ traceId, spanId: spanIds.get(link), attributes: [] })),
|
|
380
|
+
...(row.links ?? []).map((link) => {
|
|
381
|
+
const linkTrace = link.traceKey === traceKey ? traceId : rowTraceId(format, link.traceKey);
|
|
382
|
+
const held = link.traceKey === traceKey ? spanIds.get(link.spanKey) : undefined;
|
|
383
|
+
return {
|
|
384
|
+
traceId: linkTrace,
|
|
385
|
+
spanId: held ??
|
|
386
|
+
otelId(link.spanKey, 16) ??
|
|
387
|
+
digestHex(`${format}.span:${linkTrace}:${link.spanKey}`, 16),
|
|
388
|
+
attributes: Object.entries(link.attributes ?? {}).map(([name, value]) => ({
|
|
389
|
+
key: name,
|
|
390
|
+
value: anyValue(value),
|
|
391
|
+
})),
|
|
392
|
+
};
|
|
393
|
+
}),
|
|
394
|
+
];
|
|
395
|
+
const span = {
|
|
396
|
+
traceId,
|
|
397
|
+
spanId: spanIds.get(spanKey),
|
|
398
|
+
...(parentKey !== undefined ? { parentSpanId: spanIds.get(parentKey) } : {}),
|
|
399
|
+
name: row.name,
|
|
400
|
+
kind: 1,
|
|
401
|
+
startTimeUnixNano: start.toString(),
|
|
402
|
+
endTimeUnixNano: ends.get(spanKey).toString(),
|
|
403
|
+
attributes: attributes.finish(),
|
|
404
|
+
status: row.error
|
|
405
|
+
? { code: 2, ...(row.error.message ? { message: row.error.message } : {}) }
|
|
406
|
+
: { code: 0 },
|
|
407
|
+
...(spanEvents.length ? { events: spanEvents } : {}),
|
|
408
|
+
...(links.length ? { links } : {}),
|
|
409
|
+
};
|
|
410
|
+
const resource = Object.entries(row.resource ?? {}).map(([name, value]) => ({
|
|
411
|
+
key: name,
|
|
412
|
+
value: anyValue(value),
|
|
413
|
+
}));
|
|
414
|
+
if (row.environment && !row.resource?.["deployment.environment.name"])
|
|
415
|
+
resource.push({
|
|
416
|
+
key: "deployment.environment.name",
|
|
417
|
+
value: { stringValue: row.environment },
|
|
418
|
+
});
|
|
419
|
+
return {
|
|
420
|
+
resource: { attributes: resource },
|
|
421
|
+
scopeSpans: [
|
|
422
|
+
{ scope: { name: `hue.import.${format}`, version: ROW_IMPORT_VERSION }, spans: [span] },
|
|
423
|
+
],
|
|
424
|
+
};
|
|
425
|
+
});
|
|
426
|
+
const inferredTiming = [...traceNotes].sort();
|
|
427
|
+
return {
|
|
428
|
+
traceId,
|
|
429
|
+
format,
|
|
430
|
+
entries,
|
|
431
|
+
blobs: blobs.sorted(),
|
|
432
|
+
origin,
|
|
433
|
+
sourceDigests: draft.sourceDigests,
|
|
434
|
+
remappedTraceId: traceId !== traceKey,
|
|
435
|
+
remappedSpans,
|
|
436
|
+
...(inferredTiming.length ? { inferredTiming } : {}),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/** A row's content under the GenAI conventions: a tool's arguments and result, or chat messages,
|
|
441
|
+
* or the raw value as `input.value`/`output.value`. A scoring row's content is always raw. */
|
|
442
|
+
function addContent(add, row, scoring) {
|
|
443
|
+
if (row.kind === "tool" && !scoring) {
|
|
444
|
+
add("gen_ai.tool.name", row.tool?.name ?? row.name);
|
|
445
|
+
if (row.tool?.callId)
|
|
446
|
+
add("gen_ai.tool.call.id", row.tool.callId);
|
|
447
|
+
if (row.tool?.type)
|
|
448
|
+
add("gen_ai.tool.type", row.tool.type);
|
|
449
|
+
const args = row.tool?.arguments ?? row.input;
|
|
450
|
+
const result = row.tool?.result ?? row.output;
|
|
451
|
+
if (args !== undefined)
|
|
452
|
+
add("gen_ai.tool.call.arguments", JSON.stringify(args));
|
|
453
|
+
if (result !== undefined)
|
|
454
|
+
add("gen_ai.tool.call.result", JSON.stringify(result));
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const chat = row.kind === "llm" || row.kind === "agent";
|
|
458
|
+
if (!scoring && row.systemInstructions !== undefined && row.systemInstructions !== null)
|
|
459
|
+
add("gen_ai.system_instructions", JSON.stringify(typeof row.systemInstructions === "string"
|
|
460
|
+
? [{ type: "text", content: row.systemInstructions }]
|
|
461
|
+
: row.systemInstructions));
|
|
462
|
+
for (const [field, role] of [
|
|
463
|
+
["input", "user"],
|
|
464
|
+
["output", "assistant"],
|
|
465
|
+
]) {
|
|
466
|
+
const value = row[field];
|
|
467
|
+
if (value === undefined || value === null)
|
|
468
|
+
continue;
|
|
469
|
+
const messages = scoring ? undefined : genAiMessages(value, chat ? role : undefined);
|
|
470
|
+
if (messages)
|
|
471
|
+
add(`gen_ai.${field}.messages`, JSON.stringify(messages));
|
|
472
|
+
else if (typeof value === "string") {
|
|
473
|
+
add(`${field}.value`, value);
|
|
474
|
+
add(`${field}.mime_type`, "text/plain");
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
add(`${field}.value`, JSON.stringify(value));
|
|
478
|
+
add(`${field}.mime_type`, "application/json");
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
/** The SHA-256 of an event's record, by which copies of it are told apart. */
|
|
483
|
+
const eventDigests = new WeakMap();
|
|
484
|
+
function eventDigest(event) {
|
|
485
|
+
let digest = eventDigests.get(event);
|
|
486
|
+
if (digest === undefined) {
|
|
487
|
+
digest = sha256Hex(canonicalJson(event.record));
|
|
488
|
+
eventDigests.set(event, digest);
|
|
489
|
+
}
|
|
490
|
+
return digest;
|
|
491
|
+
}
|
|
492
|
+
/** Whether the parents of a trace's rows close on themselves anywhere; each row's ancestry is
|
|
493
|
+
* walked once, so the check takes time in proportion to the rows. */
|
|
494
|
+
export function cyclic(rows, parentOf) {
|
|
495
|
+
const settled = new Set();
|
|
496
|
+
for (const spanKey of rows.keys()) {
|
|
497
|
+
const path = new Set();
|
|
498
|
+
for (let at = spanKey; at !== undefined && !settled.has(at); at = parentOf(rows.get(at))) {
|
|
499
|
+
if (path.has(at))
|
|
500
|
+
return true;
|
|
501
|
+
path.add(at);
|
|
502
|
+
}
|
|
503
|
+
for (const done of path)
|
|
504
|
+
settled.add(done);
|
|
505
|
+
}
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Every span's start and end, the recorded ones kept and the rest inferred by these rules, each
|
|
510
|
+
* named in the span's `hue.import.timing` and the report:
|
|
511
|
+
* - `start_at_end`: no start but an end: the span starts at its end;
|
|
512
|
+
* - `start_from_parent`: no instant at all: the span starts with its parent;
|
|
513
|
+
* - `start_from_trace`: nor has its parent: it starts with the trace's earliest recorded instant;
|
|
514
|
+
* - `untimed_trace`: the trace records no instant: spans are placed at 2000-01-01T00:00:00Z, a
|
|
515
|
+
* millisecond apart in source order;
|
|
516
|
+
* - `end_from_children` / `end_at_start`: no end: the span ends with its last child, else at its
|
|
517
|
+
* start.
|
|
518
|
+
* A recorded end before its start refuses the trace.
|
|
519
|
+
*/
|
|
520
|
+
export function timesOf(rows, parentOf) {
|
|
521
|
+
const notes = new Map();
|
|
522
|
+
const note = (spanKey, rule) => {
|
|
523
|
+
const held = notes.get(spanKey);
|
|
524
|
+
if (held)
|
|
525
|
+
held.push(rule);
|
|
526
|
+
else
|
|
527
|
+
notes.set(spanKey, [rule]);
|
|
528
|
+
};
|
|
529
|
+
let earliest;
|
|
530
|
+
for (const row of rows.values())
|
|
531
|
+
for (const time of [row.start, row.end])
|
|
532
|
+
if (time !== undefined && (earliest === undefined || time < earliest))
|
|
533
|
+
earliest = time;
|
|
534
|
+
const starts = new Map();
|
|
535
|
+
if (earliest === undefined) {
|
|
536
|
+
const ordered = [...rows.values()].sort((a, b) => (a.sequence ?? Infinity) - (b.sequence ?? Infinity) || (a.spanKey < b.spanKey ? -1 : 1));
|
|
537
|
+
for (const [index, row] of ordered.entries()) {
|
|
538
|
+
starts.set(row.spanKey, UNTIMED_EPOCH + BigInt(index) * MILLISECOND);
|
|
539
|
+
note(row.spanKey, "untimed_trace");
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// Starts: a row without an instant takes its nearest ancestor's start, found by walking up to
|
|
543
|
+
// the first row whose start is settled and settling the path on the way back down, without
|
|
544
|
+
// recursion (the hierarchy is acyclic: the caller checked).
|
|
545
|
+
const ownStart = (row) => {
|
|
546
|
+
if (row.start !== undefined)
|
|
547
|
+
return row.start;
|
|
548
|
+
if (row.end !== undefined) {
|
|
549
|
+
note(row.spanKey, "start_at_end");
|
|
550
|
+
return row.end;
|
|
551
|
+
}
|
|
552
|
+
return undefined;
|
|
553
|
+
};
|
|
554
|
+
for (const row of rows.values()) {
|
|
555
|
+
if (starts.has(row.spanKey))
|
|
556
|
+
continue;
|
|
557
|
+
const path = [];
|
|
558
|
+
let inherited;
|
|
559
|
+
for (let at = row; at;) {
|
|
560
|
+
const known = starts.get(at.spanKey);
|
|
561
|
+
if (known !== undefined) {
|
|
562
|
+
inherited = known;
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
const own = ownStart(at);
|
|
566
|
+
if (own !== undefined) {
|
|
567
|
+
starts.set(at.spanKey, own);
|
|
568
|
+
inherited = own;
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
path.push(at);
|
|
572
|
+
const parent = parentOf(at);
|
|
573
|
+
at = parent === undefined ? undefined : rows.get(parent);
|
|
574
|
+
}
|
|
575
|
+
// The path's first rows are the deepest; the last is the one nearest the settled ancestor,
|
|
576
|
+
// or the root without a parent, which starts with the trace.
|
|
577
|
+
for (let index = path.length - 1; index >= 0; index--) {
|
|
578
|
+
const at = path[index];
|
|
579
|
+
note(at.spanKey, inherited !== undefined ? "start_from_parent" : "start_from_trace");
|
|
580
|
+
inherited ??= earliest;
|
|
581
|
+
starts.set(at.spanKey, inherited);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const children = new Map();
|
|
585
|
+
for (const row of rows.values()) {
|
|
586
|
+
const parent = parentOf(row);
|
|
587
|
+
if (parent === undefined)
|
|
588
|
+
continue;
|
|
589
|
+
const held = children.get(parent);
|
|
590
|
+
if (held)
|
|
591
|
+
held.push(row);
|
|
592
|
+
else
|
|
593
|
+
children.set(parent, [row]);
|
|
594
|
+
}
|
|
595
|
+
// Ends: a row without one ends with its last child, so children are settled first, in a
|
|
596
|
+
// depth-first order kept on an explicit stack.
|
|
597
|
+
const ends = new Map();
|
|
598
|
+
const settle = (row) => {
|
|
599
|
+
const start = starts.get(row.spanKey);
|
|
600
|
+
if (row.end !== undefined) {
|
|
601
|
+
if (row.start !== undefined && row.end < start)
|
|
602
|
+
throw new ConversionError(`span ${row.spanKey} ends before it starts`);
|
|
603
|
+
ends.set(row.spanKey, row.end);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
let last = start;
|
|
607
|
+
for (const child of children.get(row.spanKey) ?? []) {
|
|
608
|
+
const end = ends.get(child.spanKey);
|
|
609
|
+
if (end > last)
|
|
610
|
+
last = end;
|
|
611
|
+
}
|
|
612
|
+
ends.set(row.spanKey, last);
|
|
613
|
+
note(row.spanKey, last > start ? "end_from_children" : "end_at_start");
|
|
614
|
+
};
|
|
615
|
+
for (const row of rows.values()) {
|
|
616
|
+
if (ends.has(row.spanKey))
|
|
617
|
+
continue;
|
|
618
|
+
const stack = [{ row, expanded: false }];
|
|
619
|
+
while (stack.length) {
|
|
620
|
+
const top = stack[stack.length - 1];
|
|
621
|
+
if (ends.has(top.row.spanKey)) {
|
|
622
|
+
stack.pop();
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
// A row with its own end needs no child's.
|
|
626
|
+
if (top.expanded || top.row.end !== undefined) {
|
|
627
|
+
stack.pop();
|
|
628
|
+
settle(top.row);
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
top.expanded = true;
|
|
632
|
+
for (const child of children.get(top.row.spanKey) ?? [])
|
|
633
|
+
if (!ends.has(child.spanKey))
|
|
634
|
+
stack.push({ row: child, expanded: false });
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return { starts, ends, notes };
|
|
638
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type JsonValue } from "../traces-import-convert.js";
|
|
2
|
+
import type { ImportAdapter } from "./adapter.js";
|
|
3
|
+
import { type SpanRow } from "./rows.js";
|
|
4
|
+
type Usage = NonNullable<SpanRow["usage"]>;
|
|
5
|
+
/**
|
|
6
|
+
* A message list's items as the message shapes `genAiMessages` reads: a Responses item it reads
|
|
7
|
+
* no message from (a hosted tool call, encrypted reasoning) becomes a message holding that item as
|
|
8
|
+
* a part of its own type, from the tool for an `…_output` item and from the assistant otherwise.
|
|
9
|
+
*/
|
|
10
|
+
export declare function chatItems(items: readonly JsonValue[]): JsonValue[];
|
|
11
|
+
/** The conversation a model request sends, as message shapes: chat `messages` (OpenAI, Anthropic)
|
|
12
|
+
* or Responses `input`; anything else as recorded. */
|
|
13
|
+
export declare function requestContent(body: JsonValue | undefined): JsonValue | undefined;
|
|
14
|
+
/** A request's system instructions: Anthropic `system` or Responses `instructions`. */
|
|
15
|
+
export declare const requestInstructions: (body: JsonValue | undefined) => JsonValue | undefined;
|
|
16
|
+
/** A model response's content as message shapes: Responses `output` items; a chat completion or
|
|
17
|
+
* Anthropic message as recorded, which `genAiMessages` reads. */
|
|
18
|
+
export declare function responseContent(body: JsonValue | undefined): JsonValue | undefined;
|
|
19
|
+
/** Token usage of an OpenAI (chat or Responses) or Anthropic response. Anthropic's input count
|
|
20
|
+
* leaves out cached tokens, so they are added back as the GenAI conventions count input. */
|
|
21
|
+
export declare function responseUsage(usage: JsonValue | undefined): Usage | undefined;
|
|
22
|
+
export declare const openaiChat: ImportAdapter;
|
|
23
|
+
export declare const anthropicMessages: ImportAdapter;
|
|
24
|
+
export declare const openaiResponses: ImportAdapter;
|
|
25
|
+
export {};
|