@hue-run/sdk 0.21.0 → 0.22.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 +255 -61
- package/README.md +61 -20
- 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/mcp.d.ts +5 -1
- package/dist/cli/mcp.js +35 -23
- 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/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
import { digestHex, isObject, } from "../traces-import-convert.js";
|
|
2
|
+
/**
|
|
3
|
+
* Who a trace ran for and which conversation it belongs to, read the way Hue reads a trace it
|
|
4
|
+
* receives, so the manifest's identity and the report's `without_identity` say what Hue will
|
|
5
|
+
* capture. Two parts, both copied from Hue's reader and kept in its order:
|
|
6
|
+
*
|
|
7
|
+
* - The identity registry: the attributes and metadata fields instrumentations name a user or a
|
|
8
|
+
* conversation by, first present wins on a record.
|
|
9
|
+
* - The owner rule: a trace's local roots (no parent, or a parent outside the trace) speak first;
|
|
10
|
+
* when they name nobody, the agent's other spans do. A tool span and everything beneath it never
|
|
11
|
+
* name the owner. Every carrier at the deciding step must agree, and an explicit
|
|
12
|
+
* `hue.world.entity.*` declaration wins over users.
|
|
13
|
+
*
|
|
14
|
+
* `--user-key` and `--session-key` name the identity of exports whose user sits under a key of
|
|
15
|
+
* their own; see {@link parseKeyPath} and {@link applyIdentityKeys}.
|
|
16
|
+
*/
|
|
17
|
+
/** Dedicated attributes, in precedence order. */
|
|
18
|
+
const DEDICATED = {
|
|
19
|
+
user: [
|
|
20
|
+
"user.id",
|
|
21
|
+
"enduser.id",
|
|
22
|
+
"hue.user.id",
|
|
23
|
+
"ai.telemetry.metadata.userId",
|
|
24
|
+
"langfuse.user.id",
|
|
25
|
+
"enduser.pseudo.id",
|
|
26
|
+
"mlflow.trace.user",
|
|
27
|
+
"mastra.metadata.resourceId",
|
|
28
|
+
],
|
|
29
|
+
session: [
|
|
30
|
+
"gen_ai.conversation.id",
|
|
31
|
+
"session.id",
|
|
32
|
+
"hue.session.id",
|
|
33
|
+
"ai.telemetry.metadata.sessionId",
|
|
34
|
+
"langfuse.session.id",
|
|
35
|
+
"mlflow.trace.session",
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
/** Metadata maps an instrumentation lets the application fill, flattened under the map's prefix
|
|
39
|
+
* or recorded whole as one JSON object, in precedence order. */
|
|
40
|
+
const METADATA_MAPS = [
|
|
41
|
+
"ai.telemetry.metadata",
|
|
42
|
+
"ai.settings.context",
|
|
43
|
+
"ai.telemetry.metadata.raindrop",
|
|
44
|
+
"ai.telemetry.metadata.raindrop.ai",
|
|
45
|
+
"traceloop.association.properties",
|
|
46
|
+
"ai.telemetry.metadata.traceloop.association.properties",
|
|
47
|
+
"langfuse.trace.metadata",
|
|
48
|
+
"langfuse.observation.metadata",
|
|
49
|
+
"langsmith.metadata",
|
|
50
|
+
"braintrust.metadata",
|
|
51
|
+
"mastra.metadata",
|
|
52
|
+
"metadata",
|
|
53
|
+
];
|
|
54
|
+
/** Bare keys, read last: Logfire's baggage stamps them on every span it opens. */
|
|
55
|
+
const BARE = { user: ["user_id"], session: ["session_id"] };
|
|
56
|
+
/** The field names applications give a user or a conversation inside those maps. */
|
|
57
|
+
const FIELDS = {
|
|
58
|
+
user: ["userId", "user_id", "langfuse_user_id"],
|
|
59
|
+
session: [
|
|
60
|
+
"sessionId",
|
|
61
|
+
"session_id",
|
|
62
|
+
"threadId",
|
|
63
|
+
"thread_id",
|
|
64
|
+
"conversationId",
|
|
65
|
+
"conversation_id",
|
|
66
|
+
"convoId",
|
|
67
|
+
"convo_id",
|
|
68
|
+
"langfuse_session_id",
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
function candidates(kind) {
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
const out = [];
|
|
74
|
+
const add = (candidate) => {
|
|
75
|
+
const id = `${candidate.key}\u0000${candidate.field ?? ""}`;
|
|
76
|
+
if (seen.has(id))
|
|
77
|
+
return;
|
|
78
|
+
seen.add(id);
|
|
79
|
+
out.push(candidate);
|
|
80
|
+
};
|
|
81
|
+
for (const key of DEDICATED[kind])
|
|
82
|
+
add({ key });
|
|
83
|
+
for (const map of METADATA_MAPS)
|
|
84
|
+
for (const field of FIELDS[kind]) {
|
|
85
|
+
add({ key: `${map}.${field}`, metadata: true });
|
|
86
|
+
add({ key: map, field });
|
|
87
|
+
}
|
|
88
|
+
for (const key of BARE[kind])
|
|
89
|
+
add({ key });
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
const CANDIDATES = {
|
|
93
|
+
user: candidates("user"),
|
|
94
|
+
session: candidates("session"),
|
|
95
|
+
};
|
|
96
|
+
/** Every attribute Hue reads a user, a conversation or an explicit owner from. */
|
|
97
|
+
const IDENTITY_KEYS = new Set([
|
|
98
|
+
...[...CANDIDATES.user, ...CANDIDATES.session].map((candidate) => candidate.key),
|
|
99
|
+
"hue.world.entity.id",
|
|
100
|
+
"hue.world.entity.namespace",
|
|
101
|
+
]);
|
|
102
|
+
/** Whether Hue reads an owner or a conversation from an attribute of this name. */
|
|
103
|
+
export const namesIdentity = (key) => IDENTITY_KEYS.has(key);
|
|
104
|
+
const KIND_KEYS = {
|
|
105
|
+
user: new Set(CANDIDATES.user.map((candidate) => candidate.key)),
|
|
106
|
+
session: new Set(CANDIDATES.session.map((candidate) => candidate.key)),
|
|
107
|
+
};
|
|
108
|
+
const MAPS = new Set(METADATA_MAPS);
|
|
109
|
+
/** A JSON object recorded as text is parsed only while it is this short. */
|
|
110
|
+
const MAX_METADATA_TEXT = 64 * 1024;
|
|
111
|
+
/** A recorded attribute without what Hue would read a `kind` identifier from: undefined for an
|
|
112
|
+
* attribute that names one itself, and a metadata map without the kind's fields. */
|
|
113
|
+
export function withoutIdentity(kind, key, value) {
|
|
114
|
+
if (!KIND_KEYS[kind].has(key))
|
|
115
|
+
return value;
|
|
116
|
+
if (!MAPS.has(key))
|
|
117
|
+
return undefined;
|
|
118
|
+
const map = metadataObject(value);
|
|
119
|
+
const fields = FIELDS[kind];
|
|
120
|
+
if (!map || !fields.some((field) => Object.hasOwn(map, field)))
|
|
121
|
+
return value;
|
|
122
|
+
const kept = Object.fromEntries(Object.entries(map).filter(([field]) => !fields.includes(field)));
|
|
123
|
+
return typeof value === "string" ? JSON.stringify(kept) : kept;
|
|
124
|
+
}
|
|
125
|
+
/** An identifier as recorded: a non-blank string, or an integer as its decimal text. */
|
|
126
|
+
export function identityText(value) {
|
|
127
|
+
if (typeof value === "string")
|
|
128
|
+
return value.trim() ? value : undefined;
|
|
129
|
+
if (typeof value === "number" && Number.isSafeInteger(value))
|
|
130
|
+
return String(value);
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
/** A flattened metadata value: Langfuse's SDKs record each one as JSON text, a string included. */
|
|
134
|
+
function metadataText(value) {
|
|
135
|
+
if (typeof value === "string" &&
|
|
136
|
+
value.length >= 2 &&
|
|
137
|
+
value.startsWith('"') &&
|
|
138
|
+
value.endsWith('"'))
|
|
139
|
+
try {
|
|
140
|
+
const parsed = JSON.parse(value);
|
|
141
|
+
if (typeof parsed === "string")
|
|
142
|
+
return identityText(parsed);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// Not JSON text: the quotes are part of the identifier.
|
|
146
|
+
}
|
|
147
|
+
return identityText(value);
|
|
148
|
+
}
|
|
149
|
+
/** A JSON object, or one recorded as JSON text while it is short enough to parse. */
|
|
150
|
+
function metadataObject(value) {
|
|
151
|
+
if (typeof value === "string") {
|
|
152
|
+
if (value.length > MAX_METADATA_TEXT || !/^\s*\{/.test(value))
|
|
153
|
+
return undefined;
|
|
154
|
+
try {
|
|
155
|
+
value = JSON.parse(value);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return isObject(value) ? value : undefined;
|
|
162
|
+
}
|
|
163
|
+
/** Claude Code records a random per-installation `user.id` beside the account it runs under
|
|
164
|
+
* (`user.account_uuid`); there `user.id` names a machine, so the next key is read instead. */
|
|
165
|
+
const installationUser = (key, read) => key === "user.id" && read("user.account_uuid") !== undefined;
|
|
166
|
+
/** The record's `kind` identifier: the first registry key it carries. `read` answers an
|
|
167
|
+
* attribute in source form (an int64 as its decimal text, a kvlist as an object). */
|
|
168
|
+
export function readIdentity(kind, read) {
|
|
169
|
+
const objects = new Map();
|
|
170
|
+
for (const candidate of CANDIDATES[kind]) {
|
|
171
|
+
if (candidate.field === undefined) {
|
|
172
|
+
if (installationUser(candidate.key, read))
|
|
173
|
+
continue;
|
|
174
|
+
const recorded = read(candidate.key);
|
|
175
|
+
const value = candidate.metadata ? metadataText(recorded) : identityText(recorded);
|
|
176
|
+
if (value !== undefined)
|
|
177
|
+
return value;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (!objects.has(candidate.key))
|
|
181
|
+
objects.set(candidate.key, metadataObject(read(candidate.key)));
|
|
182
|
+
const value = identityText(objects.get(candidate.key)?.[candidate.field]);
|
|
183
|
+
if (value !== undefined)
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
// --- Reading emitted spans ---------------------------------------------------------------------
|
|
189
|
+
/** An OTLP value in source form: an int64 stays its decimal text, a kvlist becomes an object.
|
|
190
|
+
* A value of no OTLP shape (a recorded span need not have one) is null. */
|
|
191
|
+
function sourceValue(value) {
|
|
192
|
+
if (!isObject(value))
|
|
193
|
+
return null;
|
|
194
|
+
const { stringValue, boolValue, doubleValue, intValue, bytesValue } = value;
|
|
195
|
+
if (typeof stringValue === "string")
|
|
196
|
+
return stringValue;
|
|
197
|
+
if (typeof boolValue === "boolean")
|
|
198
|
+
return boolValue;
|
|
199
|
+
if (typeof doubleValue === "number")
|
|
200
|
+
return doubleValue;
|
|
201
|
+
if (typeof intValue === "string" || typeof intValue === "number")
|
|
202
|
+
return String(intValue);
|
|
203
|
+
if (typeof bytesValue === "string")
|
|
204
|
+
return bytesValue;
|
|
205
|
+
const list = (field) => {
|
|
206
|
+
const holder = value[field];
|
|
207
|
+
const values = isObject(holder) ? holder.values : undefined;
|
|
208
|
+
return Array.isArray(values) ? values : isObject(holder) ? [] : undefined;
|
|
209
|
+
};
|
|
210
|
+
const array = list("arrayValue");
|
|
211
|
+
if (array)
|
|
212
|
+
return array.map(sourceValue);
|
|
213
|
+
const kvlist = list("kvlistValue");
|
|
214
|
+
if (kvlist)
|
|
215
|
+
return Object.fromEntries(kvlist.flatMap((item) => isObject(item) && typeof item.key === "string" ? [[item.key, sourceValue(item.value)]] : []));
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
function sourceMap(attributes) {
|
|
219
|
+
const map = new Map();
|
|
220
|
+
if (!Array.isArray(attributes))
|
|
221
|
+
return map;
|
|
222
|
+
for (const item of attributes)
|
|
223
|
+
if (isObject(item) && typeof item.key === "string" && isObject(item.value))
|
|
224
|
+
map.set(item.key, sourceValue(item.value));
|
|
225
|
+
return map;
|
|
226
|
+
}
|
|
227
|
+
const text = (map, key) => {
|
|
228
|
+
const value = map.get(key);
|
|
229
|
+
return typeof value === "string" && value ? value : undefined;
|
|
230
|
+
};
|
|
231
|
+
const OPERATION_KEYS = [
|
|
232
|
+
"gen_ai.operation.name",
|
|
233
|
+
"openinference.span.kind",
|
|
234
|
+
"traceloop.span.kind",
|
|
235
|
+
"ai.operationId",
|
|
236
|
+
"operation.name",
|
|
237
|
+
"langfuse.observation.type",
|
|
238
|
+
];
|
|
239
|
+
const TOOL_OPERATIONS = new Set(["tool", "execute_tool", "ai.toolcall"]);
|
|
240
|
+
/** Whether Hue reads the span as a tool call: its operation names one, or an instrumentation
|
|
241
|
+
* convention reads a tool from its attributes. */
|
|
242
|
+
export function isToolSpan(attributes) {
|
|
243
|
+
let operation;
|
|
244
|
+
for (const key of OPERATION_KEYS)
|
|
245
|
+
if ((operation = text(attributes, key)))
|
|
246
|
+
break;
|
|
247
|
+
if (operation && TOOL_OPERATIONS.has(operation.toLowerCase()))
|
|
248
|
+
return true;
|
|
249
|
+
if (text(attributes, "gen_ai.tool.name") ||
|
|
250
|
+
text(attributes, "gen_ai.tool.call.id") ||
|
|
251
|
+
attributes.has("gen_ai.tool.call.arguments") ||
|
|
252
|
+
attributes.has("gen_ai.tool.call.result"))
|
|
253
|
+
return true;
|
|
254
|
+
if (text(attributes, "ai.toolCall.name") ||
|
|
255
|
+
text(attributes, "ai.toolCall.id") ||
|
|
256
|
+
attributes.has("ai.toolCall.args") ||
|
|
257
|
+
attributes.has("ai.toolCall.result"))
|
|
258
|
+
return true;
|
|
259
|
+
const kind = text(attributes, "openinference.span.kind")?.toUpperCase();
|
|
260
|
+
if (kind === "TOOL" || (kind === undefined && text(attributes, "tool.name")))
|
|
261
|
+
return true;
|
|
262
|
+
if (text(attributes, "traceloop.span.kind") === "tool" &&
|
|
263
|
+
(text(attributes, "traceloop.entity.name") ||
|
|
264
|
+
attributes.has("traceloop.entity.input") ||
|
|
265
|
+
attributes.has("traceloop.entity.output")))
|
|
266
|
+
return true;
|
|
267
|
+
return (text(attributes, "langfuse.observation.type") === "tool" &&
|
|
268
|
+
(attributes.has("langfuse.observation.input") || attributes.has("langfuse.observation.output")));
|
|
269
|
+
}
|
|
270
|
+
export function spanViews(entries) {
|
|
271
|
+
return entries.flatMap((entry) => {
|
|
272
|
+
const span = entry.scopeSpans[0]?.spans[0];
|
|
273
|
+
if (!span || typeof span.spanId !== "string")
|
|
274
|
+
return [];
|
|
275
|
+
const parent = span.parentSpanId;
|
|
276
|
+
let start = 0n;
|
|
277
|
+
if (typeof span.startTimeUnixNano === "string" && /^\d+$/.test(span.startTimeUnixNano))
|
|
278
|
+
start = BigInt(span.startTimeUnixNano);
|
|
279
|
+
return [
|
|
280
|
+
{
|
|
281
|
+
spanId: span.spanId,
|
|
282
|
+
...(typeof parent === "string" && parent ? { parentSpanId: parent } : {}),
|
|
283
|
+
start,
|
|
284
|
+
attributes: sourceMap(span.attributes),
|
|
285
|
+
resource: sourceMap(isObject(entry.resource) ? entry.resource.attributes : undefined),
|
|
286
|
+
span,
|
|
287
|
+
},
|
|
288
|
+
];
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
const ENTITY_ID = "hue.world.entity.id";
|
|
292
|
+
const ENTITY_NAMESPACE = "hue.world.entity.namespace";
|
|
293
|
+
const MAX_IDENTIFIER_BYTES = 16_384;
|
|
294
|
+
const MAX_NAMESPACE_BYTES = 128;
|
|
295
|
+
const MAX_SESSION = 2_048;
|
|
296
|
+
const validIdentifier = (value, bytes) => Buffer.byteLength(value) <= bytes && !value.includes("\0") && value.isWellFormed();
|
|
297
|
+
/** One step's verdict: explicit entities win over users, and every carrier must agree. A step
|
|
298
|
+
* that names nobody answers nothing, so the next step is read. */
|
|
299
|
+
function decideStep(carriers) {
|
|
300
|
+
if (carriers.some((carrier) => carrier.invalid))
|
|
301
|
+
return "none";
|
|
302
|
+
const explicit = carriers.flatMap((carrier) => carrier.explicit);
|
|
303
|
+
const found = explicit.length ? explicit : carriers.flatMap((carrier) => carrier.users);
|
|
304
|
+
if (!found.length)
|
|
305
|
+
return undefined;
|
|
306
|
+
const unique = new Set(found.map((owner) => JSON.stringify(owner)));
|
|
307
|
+
return unique.size === 1 ? found[0] : "none";
|
|
308
|
+
}
|
|
309
|
+
/** The ids of the spans `marked` picks and of every span beneath one, walked from them down
|
|
310
|
+
* without recursion; a parent chain that closes on itself reaches nothing marked on its own. */
|
|
311
|
+
export function subtreeIds(spans, marked) {
|
|
312
|
+
const children = new Map();
|
|
313
|
+
for (const span of spans) {
|
|
314
|
+
if (span.parentSpanId === undefined)
|
|
315
|
+
continue;
|
|
316
|
+
const held = children.get(span.parentSpanId);
|
|
317
|
+
if (held)
|
|
318
|
+
held.push(span.spanId);
|
|
319
|
+
else
|
|
320
|
+
children.set(span.parentSpanId, [span.spanId]);
|
|
321
|
+
}
|
|
322
|
+
const found = new Set();
|
|
323
|
+
const pending = spans.filter(marked).map((span) => span.spanId);
|
|
324
|
+
while (pending.length) {
|
|
325
|
+
const id = pending.pop();
|
|
326
|
+
if (found.has(id))
|
|
327
|
+
continue;
|
|
328
|
+
found.add(id);
|
|
329
|
+
for (const child of children.get(id) ?? [])
|
|
330
|
+
pending.push(child);
|
|
331
|
+
}
|
|
332
|
+
return found;
|
|
333
|
+
}
|
|
334
|
+
/** The spans the agent itself recorded, split into local roots and the rest: a tool span and
|
|
335
|
+
* every span beneath one are left out. */
|
|
336
|
+
export function agentSpans(spans, tool) {
|
|
337
|
+
const ids = new Set(spans.map((span) => span.spanId));
|
|
338
|
+
const underTool = subtreeIds(spans, tool);
|
|
339
|
+
const roots = [];
|
|
340
|
+
const others = [];
|
|
341
|
+
for (const span of spans) {
|
|
342
|
+
if (underTool.has(span.spanId))
|
|
343
|
+
continue;
|
|
344
|
+
if (span.parentSpanId === undefined || !ids.has(span.parentSpanId))
|
|
345
|
+
roots.push(span);
|
|
346
|
+
else
|
|
347
|
+
others.push(span);
|
|
348
|
+
}
|
|
349
|
+
return { roots, others };
|
|
350
|
+
}
|
|
351
|
+
/** The owner of a set of carriers split as {@link agentSpans} splits them: the roots' verdict,
|
|
352
|
+
* else the other spans'. */
|
|
353
|
+
function decideOwner(roots, others) {
|
|
354
|
+
const fromRoots = decideStep(roots);
|
|
355
|
+
if (fromRoots !== undefined)
|
|
356
|
+
return fromRoots === "none" ? undefined : fromRoots;
|
|
357
|
+
const fromOthers = decideStep(others);
|
|
358
|
+
return fromOthers === "none" ? undefined : fromOthers;
|
|
359
|
+
}
|
|
360
|
+
/** What one span says about the owner: its explicit entity declarations and its registry user,
|
|
361
|
+
* from its own attributes, else its resource's. */
|
|
362
|
+
function carrierOf(view) {
|
|
363
|
+
const carrier = { explicit: [], users: [], invalid: false };
|
|
364
|
+
for (const values of [view.attributes, view.resource]) {
|
|
365
|
+
const id = text(values, ENTITY_ID);
|
|
366
|
+
const namespace = text(values, ENTITY_NAMESPACE);
|
|
367
|
+
if (!id && !namespace)
|
|
368
|
+
continue;
|
|
369
|
+
if (!id ||
|
|
370
|
+
!namespace ||
|
|
371
|
+
!validIdentifier(id, MAX_IDENTIFIER_BYTES) ||
|
|
372
|
+
!validIdentifier(namespace, MAX_NAMESPACE_BYTES))
|
|
373
|
+
carrier.invalid = true;
|
|
374
|
+
else
|
|
375
|
+
carrier.explicit.push({ kind: "entity", namespace, id });
|
|
376
|
+
}
|
|
377
|
+
for (const values of [view.attributes, view.resource]) {
|
|
378
|
+
const user = readIdentity("user", (key) => values.get(key));
|
|
379
|
+
if (user === undefined)
|
|
380
|
+
continue;
|
|
381
|
+
if (validIdentifier(user, MAX_IDENTIFIER_BYTES))
|
|
382
|
+
carrier.users.push({ kind: "user", id: user });
|
|
383
|
+
else
|
|
384
|
+
carrier.invalid = true;
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
return carrier;
|
|
388
|
+
}
|
|
389
|
+
const byStart = (a, b) => a.start < b.start ? -1 : a.start > b.start ? 1 : a.spanId < b.spanId ? -1 : 1;
|
|
390
|
+
/** The trace's session as Hue's trace list takes it: the earliest local root's, else the earliest
|
|
391
|
+
* span carrying one. */
|
|
392
|
+
function sessionOf(views) {
|
|
393
|
+
const ids = new Set(views.map((view) => view.spanId));
|
|
394
|
+
const ordered = [...views].sort(byStart);
|
|
395
|
+
const root = ordered.find((view) => !view.parentSpanId || !ids.has(view.parentSpanId));
|
|
396
|
+
for (const view of root ? [root, ...ordered] : ordered) {
|
|
397
|
+
const session = readIdentity("session", (key) => view.attributes.get(key));
|
|
398
|
+
if (session !== undefined &&
|
|
399
|
+
session.length <= MAX_SESSION &&
|
|
400
|
+
validIdentifier(session, Infinity))
|
|
401
|
+
return session;
|
|
402
|
+
}
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
/** The trace's identity as Hue will capture it from these spans: the owner (an explicit entity or
|
|
406
|
+
* a user) and the session. */
|
|
407
|
+
export function traceIdentity(entries) {
|
|
408
|
+
const views = spanViews(entries);
|
|
409
|
+
const { roots, others } = agentSpans(views, (view) => isToolSpan(view.attributes));
|
|
410
|
+
const owner = decideOwner(roots.map(carrierOf), others.map(carrierOf));
|
|
411
|
+
const session = sessionOf(views);
|
|
412
|
+
const identity = {
|
|
413
|
+
...(owner?.kind === "user" ? { user: owner.id } : {}),
|
|
414
|
+
...(owner?.kind === "entity" ? { entity: { namespace: owner.namespace, id: owner.id } } : {}),
|
|
415
|
+
...(session !== undefined ? { session } : {}),
|
|
416
|
+
};
|
|
417
|
+
return identity.user || identity.entity || identity.session ? identity : undefined;
|
|
418
|
+
}
|
|
419
|
+
const MAX_KEY_PATH = 512;
|
|
420
|
+
/**
|
|
421
|
+
* A key path from its command-line text, or undefined when it is malformed:
|
|
422
|
+
* - `/a/b` is a JSON Pointer into the source record (`~1` for `/`, `~0` for `~`);
|
|
423
|
+
* - `$.a.b`, `$.a["b.c"]` or `$.a[0]` is a JSONPath of members and indexes into the record;
|
|
424
|
+
* - anything else is a dotted key: an attribute of the span or its resource, then a key of the
|
|
425
|
+
* record's metadata (exact, then as a dotted path), then of the record itself.
|
|
426
|
+
*/
|
|
427
|
+
export function parseKeyPath(text) {
|
|
428
|
+
if (!text || text.length > MAX_KEY_PATH || [...text].some((char) => char < " "))
|
|
429
|
+
return undefined;
|
|
430
|
+
if (text.startsWith("/"))
|
|
431
|
+
return {
|
|
432
|
+
kind: "pointer",
|
|
433
|
+
segments: text
|
|
434
|
+
.slice(1)
|
|
435
|
+
.split("/")
|
|
436
|
+
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")),
|
|
437
|
+
text,
|
|
438
|
+
};
|
|
439
|
+
if (text.startsWith("$")) {
|
|
440
|
+
const segments = [];
|
|
441
|
+
let rest = text.slice(1);
|
|
442
|
+
while (rest) {
|
|
443
|
+
const member = /^\.([^.[\]]+)/.exec(rest);
|
|
444
|
+
const quoted = /^\[(?:"((?:[^"\\]|\\.)*)"|'([^'\\]*)')\]/.exec(rest);
|
|
445
|
+
const index = /^\[(\d+)\]/.exec(rest);
|
|
446
|
+
const match = member ?? quoted ?? index;
|
|
447
|
+
if (!match)
|
|
448
|
+
return undefined;
|
|
449
|
+
if (quoted && quoted[1] !== undefined) {
|
|
450
|
+
try {
|
|
451
|
+
segments.push(JSON.parse(`"${quoted[1]}"`));
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
return undefined;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
else
|
|
458
|
+
segments.push((match[1] ?? match[2]));
|
|
459
|
+
rest = rest.slice(match[0].length);
|
|
460
|
+
}
|
|
461
|
+
return segments.length ? { kind: "pointer", segments, text } : undefined;
|
|
462
|
+
}
|
|
463
|
+
return text.trim() === text ? { kind: "dotted", key: text, text } : undefined;
|
|
464
|
+
}
|
|
465
|
+
/** A member of a JSON value, reading into a JSON object or list recorded as text. */
|
|
466
|
+
function member(value, segment) {
|
|
467
|
+
if (typeof value === "string" && value.length <= MAX_METADATA_TEXT && /^\s*[{[]/.test(value))
|
|
468
|
+
try {
|
|
469
|
+
value = JSON.parse(value);
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
return undefined;
|
|
473
|
+
}
|
|
474
|
+
if (Array.isArray(value))
|
|
475
|
+
return /^\d+$/.test(segment) ? value[Number(segment)] : undefined;
|
|
476
|
+
return isObject(value) && Object.hasOwn(value, segment) ? value[segment] : undefined;
|
|
477
|
+
}
|
|
478
|
+
const walk = (value, segments) => segments.reduce((current, segment) => member(current, segment), value);
|
|
479
|
+
/** What a key path names on one span, as an identifier. */
|
|
480
|
+
export function resolveKey(path, view, record, metadata) {
|
|
481
|
+
if (path.kind === "pointer")
|
|
482
|
+
return metadataText(walk(record, path.segments));
|
|
483
|
+
const dotted = path.key.split(".");
|
|
484
|
+
for (const found of [
|
|
485
|
+
() => view.attributes.get(path.key),
|
|
486
|
+
() => view.resource?.get(path.key),
|
|
487
|
+
() => member(metadata, path.key),
|
|
488
|
+
() => walk(metadata, dotted),
|
|
489
|
+
() => member(record, path.key),
|
|
490
|
+
() => walk(record, dotted),
|
|
491
|
+
]) {
|
|
492
|
+
const value = metadataText(found());
|
|
493
|
+
if (value !== undefined)
|
|
494
|
+
return value;
|
|
495
|
+
}
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
498
|
+
/** The single value a key names across the agent's spans: the roots' when they name one, else
|
|
499
|
+
* the other spans'; spans that disagree name nothing. */
|
|
500
|
+
export function decideKey(spans, tool, value) {
|
|
501
|
+
const { roots, others } = agentSpans(spans, tool);
|
|
502
|
+
for (const step of [roots, others]) {
|
|
503
|
+
const values = new Set(step.flatMap((span) => value(span) ?? []));
|
|
504
|
+
if (values.size === 1)
|
|
505
|
+
return { found: true, value: [...values][0] };
|
|
506
|
+
if (values.size > 1)
|
|
507
|
+
return { found: true };
|
|
508
|
+
}
|
|
509
|
+
return { found: false };
|
|
510
|
+
}
|
|
511
|
+
/** A span's source record: the vendor record an importer kept whole, else the span itself. */
|
|
512
|
+
function recordOf(view) {
|
|
513
|
+
const kept = view.attributes.get("hue.import.source_record");
|
|
514
|
+
if (typeof kept === "string" && kept.length <= 16 * MAX_METADATA_TEXT)
|
|
515
|
+
try {
|
|
516
|
+
return JSON.parse(kept);
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
// Kept text that is not JSON: the span is the record.
|
|
520
|
+
}
|
|
521
|
+
return view.span;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Applies `--user-key` and `--session-key` to a trace whose spans are kept as converted (OTLP,
|
|
525
|
+
* Langfuse, ATIF): a span's bytes never change, so a trace also sent live does not conflict.
|
|
526
|
+
* When a key names one user across the agent's spans and the trace names no owner, a child span
|
|
527
|
+
* `hue.import.user` with the single attribute `user.id` is added under the agent's earliest local
|
|
528
|
+
* root (never a tool span), which Hue reads as one of the agent's spans; `hue.import.session` with
|
|
529
|
+
* `gen_ai.conversation.id` likewise names a session the trace lacks. An owner the spans already
|
|
530
|
+
* name is kept: Hue reads it before any added span.
|
|
531
|
+
*/
|
|
532
|
+
export function applyIdentityKeys(trace, keys) {
|
|
533
|
+
if (!keys.user && !keys.session)
|
|
534
|
+
return;
|
|
535
|
+
const views = spanViews(trace.entries);
|
|
536
|
+
if (!views.length)
|
|
537
|
+
return;
|
|
538
|
+
const records = new Map(views.map((view) => [view, recordOf(view)]));
|
|
539
|
+
const tool = (view) => isToolSpan(view.attributes);
|
|
540
|
+
const current = traceIdentity(trace.entries);
|
|
541
|
+
const ids = new Set(views.map((view) => view.spanId));
|
|
542
|
+
// The added span goes under the agent's earliest local root, where Hue reads it: never under a
|
|
543
|
+
// tool span, whose subtree never names the owner. A trace whose every span is a tool's, or
|
|
544
|
+
// whose parents close on themselves, has no such root and gains nothing.
|
|
545
|
+
const [root] = agentSpans(views, tool).roots.sort(byStart);
|
|
546
|
+
if (!root)
|
|
547
|
+
return;
|
|
548
|
+
const rootEntry = trace.entries.find((entry) => entry.scopeSpans[0]?.spans[0] === root.span);
|
|
549
|
+
const add = (name, attribute) => {
|
|
550
|
+
let spanId = digestHex(`${name}:${trace.traceId}`, 16);
|
|
551
|
+
for (let salt = 0; ids.has(spanId) || /^0+$/.test(spanId); salt++)
|
|
552
|
+
spanId = digestHex(`${name}:${trace.traceId}:${salt}`, 16);
|
|
553
|
+
ids.add(spanId);
|
|
554
|
+
const start = String(root.span.startTimeUnixNano);
|
|
555
|
+
trace.entries.push({
|
|
556
|
+
resource: isObject(rootEntry?.resource) ? rootEntry.resource : { attributes: [] },
|
|
557
|
+
scopeSpans: [
|
|
558
|
+
{
|
|
559
|
+
scope: { name: "hue.import" },
|
|
560
|
+
spans: [
|
|
561
|
+
{
|
|
562
|
+
traceId: trace.traceId,
|
|
563
|
+
spanId,
|
|
564
|
+
parentSpanId: root.spanId,
|
|
565
|
+
name,
|
|
566
|
+
kind: 1,
|
|
567
|
+
startTimeUnixNano: start,
|
|
568
|
+
endTimeUnixNano: start,
|
|
569
|
+
attributes: [attribute],
|
|
570
|
+
status: { code: 0 },
|
|
571
|
+
},
|
|
572
|
+
],
|
|
573
|
+
},
|
|
574
|
+
],
|
|
575
|
+
});
|
|
576
|
+
};
|
|
577
|
+
for (const [kind, path] of [
|
|
578
|
+
["user", keys.user],
|
|
579
|
+
["session", keys.session],
|
|
580
|
+
]) {
|
|
581
|
+
if (!path)
|
|
582
|
+
continue;
|
|
583
|
+
const decided = decideKey(views, tool, (view) => resolveKey(path, view, records.get(view), member(records.get(view), "metadata")));
|
|
584
|
+
if (decided.value === undefined)
|
|
585
|
+
continue;
|
|
586
|
+
if (kind === "user" && !current?.user && !current?.entity)
|
|
587
|
+
add("hue.import.user", { key: "user.id", value: { stringValue: decided.value } });
|
|
588
|
+
if (kind === "session" && !current?.session)
|
|
589
|
+
add("hue.import.session", {
|
|
590
|
+
key: "gen_ai.conversation.id",
|
|
591
|
+
value: { stringValue: decided.value },
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|