@insightfactory.ai/insightfactory-databricks-langgraph-tracer 1.0.0-dev.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/CHANGELOG.md +176 -0
- package/LICENSE +5 -0
- package/README.md +160 -0
- package/THIRD_PARTY_NOTICES +26 -0
- package/dist/cost.d.ts +76 -0
- package/dist/cost.d.ts.map +1 -0
- package/dist/cost.js +124 -0
- package/dist/cost.js.map +1 -0
- package/dist/databricks-tracer.d.ts +235 -0
- package/dist/databricks-tracer.d.ts.map +1 -0
- package/dist/databricks-tracer.js +841 -0
- package/dist/databricks-tracer.js.map +1 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +8 -0
- package/dist/errors.js.map +1 -0
- package/dist/generated-keys.d.ts +39 -0
- package/dist/generated-keys.d.ts.map +1 -0
- package/dist/generated-keys.js +39 -0
- package/dist/generated-keys.js.map +1 -0
- package/dist/index.d.ts +65 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +130 -0
- package/dist/index.js.map +1 -0
- package/dist/pricing/model-pricing-overrides.json +28 -0
- package/dist/pricing/model-pricing.json +1 -0
- package/dist/token-aggregate.d.ts +29 -0
- package/dist/token-aggregate.d.ts.map +1 -0
- package/dist/token-aggregate.js +63 -0
- package/dist/token-aggregate.js.map +1 -0
- package/dist/trace-metadata.d.ts +14 -0
- package/dist/trace-metadata.d.ts.map +1 -0
- package/dist/trace-metadata.js +33 -0
- package/dist/trace-metadata.js.map +1 -0
- package/dist/uc-export.d.ts +48 -0
- package/dist/uc-export.d.ts.map +1 -0
- package/dist/uc-export.js +243 -0
- package/dist/uc-export.js.map +1 -0
- package/package.json +68 -0
- package/src/pricing/model-pricing-overrides.json +28 -0
- package/src/pricing/model-pricing.json +1 -0
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom LangChain `BaseTracer` that emits the shared schema to Databricks
|
|
3
|
+
* MLflow via `@mlflow/core`.
|
|
4
|
+
*
|
|
5
|
+
* This is the TypeScript counterpart of the Python `DatabricksLangGraphTracer`
|
|
6
|
+
* and emits the **identical** shared trace/span schema
|
|
7
|
+
* (`schema/trace-schema.json`):
|
|
8
|
+
*
|
|
9
|
+
* - **Root run create** → trace **tags** (`source`, `langgraph.run_id` /
|
|
10
|
+
* `graph_id` / `env` / `api_revision`, conditional `langgraph.thread_id`) and
|
|
11
|
+
* trace **metadata** (`mlflow.trace.session` / `mlflow.trace.user` when a
|
|
12
|
+
* thread / user is present).
|
|
13
|
+
* - **Child run create** → `langgraph.node` / `langgraph.step` on the live node
|
|
14
|
+
* span (from LangGraph's run metadata).
|
|
15
|
+
* - **LLM run update (end)** → resolved `mlflow.llm.model` / `mlflow.llm.provider`,
|
|
16
|
+
* the reserved `mlflow.chat.tokenUsage` (incl. the cache-read / cache-creation
|
|
17
|
+
* slots), the non-reserved token-count attrs, the reserved 3-key
|
|
18
|
+
* `mlflow.llm.cost` plus the non-reserved cache cost line-items, and the
|
|
19
|
+
* OpenInference-style `llm.*` dashboard keys (`llm.model_name` /
|
|
20
|
+
* `llm.model_provider` / `llm.usage.prompt_tokens_cost` /
|
|
21
|
+
* `llm.usage.completion_tokens_cost` — the keys the Databricks experiment
|
|
22
|
+
* Overview cost charts aggregate) — accumulating a per-root cost aggregate
|
|
23
|
+
* (totals **and** a per-model cost/token rollup).
|
|
24
|
+
* - **Root run update (end), before `span.end()`** → the reserved 3-key
|
|
25
|
+
* `mlflow.trace.cost` metadata rollup, the per-model `cost.by_model` tag
|
|
26
|
+
* (JSON), and the `cost.unknown_model` tag.
|
|
27
|
+
*
|
|
28
|
+
* Uses `startSpan` with an explicit `parent` ({@link LiveSpan}) so nested runs
|
|
29
|
+
* keep hierarchy without relying on the OTEL active context (callbacks are not
|
|
30
|
+
* executed inside `withSpan`). {@link BaseTracer} invokes `persistRun` only for
|
|
31
|
+
* root runs; every run (root included) is closed from `onRunUpdate`.
|
|
32
|
+
*/
|
|
33
|
+
import { DEFAULT_SPAN_NAME, SpanStatusCode, SpanType, startSpan, } from "@mlflow/core";
|
|
34
|
+
import { BaseTracer } from "@langchain/core/tracers/base";
|
|
35
|
+
import { calculateLLMCost, getPricingProvider, qualifyDeploymentName, spanCostPayload, } from "./cost.js";
|
|
36
|
+
import { SpanAttrKey, TraceMetadataKey, TraceTagKey } from "./generated-keys.js";
|
|
37
|
+
import { writeTraceFields } from "./trace-metadata.js";
|
|
38
|
+
/** Maps LangChain `run_type` strings to MLflow {@link SpanType} (default CHAIN). */
|
|
39
|
+
export function mapLangChainRunTypeToMlflowSpanType(runType) {
|
|
40
|
+
switch (runType) {
|
|
41
|
+
case "llm":
|
|
42
|
+
return SpanType.LLM;
|
|
43
|
+
case "tool":
|
|
44
|
+
return SpanType.TOOL;
|
|
45
|
+
case "chain":
|
|
46
|
+
return SpanType.CHAIN;
|
|
47
|
+
case "retriever":
|
|
48
|
+
return SpanType.RETRIEVER;
|
|
49
|
+
default:
|
|
50
|
+
return SpanType.CHAIN;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Best-effort JSON-serializable copy for span inputs/outputs (handles cycles). */
|
|
54
|
+
export function serializeTracePayload(value) {
|
|
55
|
+
if (value === undefined)
|
|
56
|
+
return undefined;
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(JSON.stringify(value));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { _unserializable: String(value) };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* True when a URL-typed content field holds an inline `data:` URI. Only `image_url`
|
|
66
|
+
* is ambiguous: it may carry an inline `data:` URI (megabytes that bloat the trace
|
|
67
|
+
* — externalize it) *or* a remote `http(s)://` URL (a small reference that does not
|
|
68
|
+
* bloat the trace — keep it verbatim). The base64-typed fields (`file_data` /
|
|
69
|
+
* `source.data` / `base64` / `data`) are inline by contract; remote images in those
|
|
70
|
+
* representations use a *separate* key (Anthropic `source.url`, standard-block
|
|
71
|
+
* top-level `url`) the size probe never matches, so they are already left untouched.
|
|
72
|
+
*/
|
|
73
|
+
function isInlinePayload(value) {
|
|
74
|
+
return value.slice(0, 5).toLowerCase() === "data:";
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The multimodal content part's *inline* binary payload *string* (`undefined` if
|
|
78
|
+
* none) — the data-URI / base64 text that would otherwise be stored in the span
|
|
79
|
+
* input. Returning the value (not just its length) lets callers detect a resolver
|
|
80
|
+
* that echoes the exact payload back under a neutral key. Covers the encodings seen
|
|
81
|
+
* across providers and LangChain representations:
|
|
82
|
+
*
|
|
83
|
+
* - OpenAI / LangChain `image_url` — nested `image_url.url` (chat completions) or a
|
|
84
|
+
* bare `image_url` string (Responses API `input_image`), counted only when it is
|
|
85
|
+
* an inline `data:` URI; a remote `http(s)://` URL returns `undefined` and is
|
|
86
|
+
* preserved verbatim (see {@link isInlinePayload});
|
|
87
|
+
* - OpenAI `file` — nested `file.file_data` data URI (chat completions) or a
|
|
88
|
+
* top-level `file_data` (Responses API `input_file`);
|
|
89
|
+
* - Anthropic `image` / `document` — `source.data` base64;
|
|
90
|
+
* - LangChain v1 standard content blocks — top-level `base64` (or `data`).
|
|
91
|
+
*
|
|
92
|
+
* `text` parts, remote image URLs, and any part without such a payload return
|
|
93
|
+
* `undefined` and are never externalized.
|
|
94
|
+
*/
|
|
95
|
+
function contentPartPayloadValue(part) {
|
|
96
|
+
const imageUrl = part.image_url;
|
|
97
|
+
if (imageUrl && typeof imageUrl === "object") {
|
|
98
|
+
const url = imageUrl.url;
|
|
99
|
+
if (typeof url === "string" && isInlinePayload(url))
|
|
100
|
+
return url;
|
|
101
|
+
}
|
|
102
|
+
if (typeof imageUrl === "string" && isInlinePayload(imageUrl))
|
|
103
|
+
return imageUrl;
|
|
104
|
+
const file = part.file;
|
|
105
|
+
if (file && typeof file === "object") {
|
|
106
|
+
const fileData = file.file_data;
|
|
107
|
+
if (typeof fileData === "string")
|
|
108
|
+
return fileData;
|
|
109
|
+
}
|
|
110
|
+
const source = part.source;
|
|
111
|
+
if (source && typeof source === "object") {
|
|
112
|
+
const data = source.data;
|
|
113
|
+
if (typeof data === "string")
|
|
114
|
+
return data;
|
|
115
|
+
}
|
|
116
|
+
// Top-level payload keys: OpenAI Responses API `input_file.file_data`,
|
|
117
|
+
// LangChain v1 standard content blocks `base64`, and the generic `data`.
|
|
118
|
+
for (const key of ["file_data", "base64", "data"]) {
|
|
119
|
+
const value = part[key];
|
|
120
|
+
if (typeof value === "string")
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Encoded length of a part's inline payload (`0` if none) — the value reported as
|
|
127
|
+
* `bytes` in the placeholder. See {@link contentPartPayloadValue}.
|
|
128
|
+
*/
|
|
129
|
+
function contentPartPayloadSize(part) {
|
|
130
|
+
return contentPartPayloadValue(part)?.length ?? 0;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* True if `obj` (a resolver result) carries an inline binary payload anywhere. A
|
|
134
|
+
* resolver is meant to return a *reference*, not bytes. A shallow
|
|
135
|
+
* {@link contentPartPayloadSize} check on the result catches `return part` and
|
|
136
|
+
* `{ ...part }`, but a result that tucks the payload elsewhere hides it from a
|
|
137
|
+
* top-level probe. Walking the whole result catches it regardless of where the
|
|
138
|
+
* resolver put it, via two string-leaf checks:
|
|
139
|
+
*
|
|
140
|
+
* - a bare `data:` URI leaf (`{ ref: part.image_url.url }`); and
|
|
141
|
+
* - a leaf equal to `originalPayload` — the exact inline payload of the part being
|
|
142
|
+
* externalized — which catches a resolver echoing the *raw base64* of an Anthropic
|
|
143
|
+
* `source.data` / standard-block `base64` part under a neutral key
|
|
144
|
+
* (`{ ref: part.source.data }`), where the string has no `data:` prefix to flag.
|
|
145
|
+
* (A large *fabricated* string unrelated to the part is out of scope — ref size is
|
|
146
|
+
* the consumer's responsibility, see the README.)
|
|
147
|
+
*
|
|
148
|
+
* Cycle-safe: `path` tracks the containers on the current branch, so a
|
|
149
|
+
* self-referential result (`result.self = result`) is detected as a back-edge and
|
|
150
|
+
* treated as a leak (it cannot be verified payload-free, and a cyclic object is not
|
|
151
|
+
* serializable into the trace anyway) instead of recursing to a stack overflow. The
|
|
152
|
+
* path is per-branch, so a legitimate shared (diamond) reference off the current
|
|
153
|
+
* branch is not mistaken for a cycle.
|
|
154
|
+
*/
|
|
155
|
+
function containsInlinePayload(obj, originalPayload, path = new Set()) {
|
|
156
|
+
if (typeof obj === "string") {
|
|
157
|
+
return isInlinePayload(obj) || (originalPayload !== undefined && obj === originalPayload);
|
|
158
|
+
}
|
|
159
|
+
if (!obj || typeof obj !== "object")
|
|
160
|
+
return false;
|
|
161
|
+
if (path.has(obj))
|
|
162
|
+
return true; // cycle -> cannot verify -> reject (placeholder)
|
|
163
|
+
if (!Array.isArray(obj) && contentPartPayloadSize(obj) > 0) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
const nextPath = new Set(path).add(obj);
|
|
167
|
+
const children = Array.isArray(obj) ? obj : Object.values(obj);
|
|
168
|
+
return children.some((child) => containsInlinePayload(child, originalPayload, nextPath));
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Externalize one content part if it carries an inline binary payload (else
|
|
172
|
+
* recurse). Returns `part` unchanged (same reference) when nothing changed, so
|
|
173
|
+
* callers can detect "no change" by identity and skip copying.
|
|
174
|
+
*
|
|
175
|
+
* The resolver result is itself checked (recursively): if it carries an inline
|
|
176
|
+
* payload anywhere — a resolver that returns the part, spreads it (`{ ...part, ref }`),
|
|
177
|
+
* or nests it (`{ ref, original: part }`) would reintroduce the very bytes it was
|
|
178
|
+
* meant to strip — it is rejected and the placeholder is used, so the "no inline
|
|
179
|
+
* bytes are stored" invariant holds regardless of resolver behaviour.
|
|
180
|
+
*
|
|
181
|
+
* The text cap (`maxStringChars`) is intentionally *not* applied to the resolver
|
|
182
|
+
* result or the `_omitted` placeholder: a resolver's reference is the consumer's to
|
|
183
|
+
* keep small (see the README), and the placeholder is already tiny. The cap reaches
|
|
184
|
+
* non-payload parts (e.g. a huge `text` part) via the recursive {@link externalizeWalk}.
|
|
185
|
+
*/
|
|
186
|
+
function externalizeContentPart(part, index, resolver, metadata, maxStringChars) {
|
|
187
|
+
if (!part || typeof part !== "object" || Array.isArray(part)) {
|
|
188
|
+
return externalizeWalk(part, resolver, metadata, maxStringChars);
|
|
189
|
+
}
|
|
190
|
+
const p = part;
|
|
191
|
+
const payload = contentPartPayloadValue(p);
|
|
192
|
+
if (payload === undefined)
|
|
193
|
+
return externalizeWalk(part, resolver, metadata, maxStringChars);
|
|
194
|
+
const size = payload.length;
|
|
195
|
+
if (resolver) {
|
|
196
|
+
let replacement;
|
|
197
|
+
try {
|
|
198
|
+
replacement = resolver(p, { index, bytes: size, metadata });
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
// Resilient by design: a faulty resolver must not break tracing.
|
|
202
|
+
console.warn("[DatabricksLangGraphTracer] contentRefResolver failed:", err);
|
|
203
|
+
replacement = undefined;
|
|
204
|
+
}
|
|
205
|
+
if (replacement && typeof replacement === "object" && !Array.isArray(replacement)) {
|
|
206
|
+
let leaks;
|
|
207
|
+
try {
|
|
208
|
+
// Pass the part's own payload so a resolver echoing the raw base64 back
|
|
209
|
+
// (under any key) is caught, not just data: URIs.
|
|
210
|
+
leaks = containsInlinePayload(replacement, payload);
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
console.warn("[DatabricksLangGraphTracer] contentRefResolver result validation failed:", err);
|
|
214
|
+
leaks = true; // cannot verify -> reject
|
|
215
|
+
}
|
|
216
|
+
if (!leaks)
|
|
217
|
+
return replacement;
|
|
218
|
+
console.warn("[DatabricksLangGraphTracer] contentRefResolver result still carries an " +
|
|
219
|
+
"inline payload (or could not be verified); using placeholder instead");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return typeof p.type === "string"
|
|
223
|
+
? { type: p.type, _omitted: true, bytes: size }
|
|
224
|
+
: { _omitted: true, bytes: size };
|
|
225
|
+
}
|
|
226
|
+
/** Externalize a message `content` array (copy-on-write; same array when unchanged). */
|
|
227
|
+
function externalizeContentList(parts, resolver, metadata, maxStringChars) {
|
|
228
|
+
let result = parts;
|
|
229
|
+
for (let i = 0; i < parts.length; i++) {
|
|
230
|
+
const newPart = externalizeContentPart(parts[i], i, resolver, metadata, maxStringChars);
|
|
231
|
+
if (newPart !== parts[i]) {
|
|
232
|
+
if (result === parts)
|
|
233
|
+
result = [...parts];
|
|
234
|
+
result[i] = newPart;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Plain-text span-content cap. Large *text* leaves — not multimodal
|
|
241
|
+
* payloads — also bloat traces past the SQL inline read limit (e.g. a classification
|
|
242
|
+
* vocabulary / aggregated result set threaded through every fan-out span's inputs and
|
|
243
|
+
* outputs). When `maxStringChars` is set, any string leaf longer than it is replaced
|
|
244
|
+
* with a compact `{ _truncated: true, chars: N, bytes: M, preview: "…" }` placeholder
|
|
245
|
+
* so the recorded trace stays readable.
|
|
246
|
+
*
|
|
247
|
+
* Opt-in / default off: generic text truncation costs debuggability, so the consumer
|
|
248
|
+
* chooses the threshold (constructor option or `DATABRICKS_TRACING_MAX_STRING_CHARS`).
|
|
249
|
+
* It caps individual string *values*, never keys or our short structural fields, and
|
|
250
|
+
* is a per-leaf mitigation — not a hard per-trace byte budget (enough sub-cap leaves
|
|
251
|
+
* can still sum past the limit), so pair it with consumer-side payload reduction for
|
|
252
|
+
* the heaviest spans. Distinct from the removed multimodal `maxContentPartBytes`: that
|
|
253
|
+
* gated inline binary payloads; this caps arbitrary text.
|
|
254
|
+
*/
|
|
255
|
+
const TRUNCATION_PREVIEW_CHARS = 256;
|
|
256
|
+
const MAX_STRING_CHARS_ENV = "DATABRICKS_TRACING_MAX_STRING_CHARS";
|
|
257
|
+
/**
|
|
258
|
+
* Resolve the text cap: explicit option wins, else `DATABRICKS_TRACING_MAX_STRING_CHARS`.
|
|
259
|
+
* Returns `undefined` (cap disabled) when neither is set, or when the value is not a
|
|
260
|
+
* positive integer. A non-positive / unparseable env value is ignored (warned), not
|
|
261
|
+
* thrown, so a stray override never breaks tracing startup.
|
|
262
|
+
*/
|
|
263
|
+
export function resolveMaxStringChars(explicit) {
|
|
264
|
+
if (explicit !== undefined)
|
|
265
|
+
return explicit > 0 ? explicit : undefined;
|
|
266
|
+
const raw = process.env[MAX_STRING_CHARS_ENV]?.trim();
|
|
267
|
+
if (!raw)
|
|
268
|
+
return undefined;
|
|
269
|
+
// Mirror Python's int(): an optional sign and digits with single underscores
|
|
270
|
+
// allowed as separators (no leading / trailing / doubled underscore). Validate
|
|
271
|
+
// before parsing — Number.parseInt accepts a prefix ("50_000" -> 50, "100abc" ->
|
|
272
|
+
// 100), which would silently truncate traces far more aggressively than intended.
|
|
273
|
+
if (!/^[+-]?\d(?:_?\d)*$/.test(raw)) {
|
|
274
|
+
console.warn(`[DatabricksLangGraphTracer] ${MAX_STRING_CHARS_ENV}=${raw} is not an integer; ignoring`);
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
const value = Number.parseInt(raw.replace(/_/g, ""), 10);
|
|
278
|
+
return value > 0 ? value : undefined;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Replace an over-cap string leaf with a compact placeholder; else return it as-is
|
|
282
|
+
* (same reference, so the copy-on-write walk treats it as "no change"). The
|
|
283
|
+
* placeholder records the original length (UTF-16 code units) and UTF-8 byte length
|
|
284
|
+
* plus a short head preview (itself bounded by the cap, so it never exceeds the
|
|
285
|
+
* allowance), keeping the value identifiable in the trace without its full size.
|
|
286
|
+
*/
|
|
287
|
+
function truncateLongString(value, maxStringChars) {
|
|
288
|
+
if (maxStringChars === undefined || value.length <= maxStringChars)
|
|
289
|
+
return value;
|
|
290
|
+
return {
|
|
291
|
+
_truncated: true,
|
|
292
|
+
chars: value.length,
|
|
293
|
+
bytes: Buffer.byteLength(value, "utf8"),
|
|
294
|
+
preview: value.slice(0, Math.min(TRUNCATION_PREVIEW_CHARS, maxStringChars)),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Externalize multimodal content parts' inline payloads and cap text.
|
|
299
|
+
* Public entry point. Takes a **shallow copy** of `metadata` once (when payloads are
|
|
300
|
+
* externalized with a resolver) so a resolver mutating `ctx.metadata` cannot corrupt
|
|
301
|
+
* the live run metadata, which the caller passes by reference and which is shared with
|
|
302
|
+
* sibling / child spans (resilient by design — a faulty resolver must not affect
|
|
303
|
+
* tracing). Then walks via {@link externalizeWalk}.
|
|
304
|
+
*
|
|
305
|
+
* `externalizePayloads` controls the multimodal-part stripping:
|
|
306
|
+
* - **inputs** (`true`) — the #21 behaviour: inline image / PDF / file payloads in
|
|
307
|
+
* message `content` arrays are replaced with references (inputs are LLM messages,
|
|
308
|
+
* where the `content` / `data` / `base64` shapes are genuinely multimodal).
|
|
309
|
+
* - **outputs** (`false`) — only the text cap is applied. Span outputs are arbitrary
|
|
310
|
+
* chain / tool return values; the multimodal probe matches a bare `data` / `base64`
|
|
311
|
+
* string in any `content` list, so running it would strip legitimate non-binary
|
|
312
|
+
* output to an `_omitted` placeholder. A genuinely huge inline payload in an output
|
|
313
|
+
* is still bounded by the text cap when it is set.
|
|
314
|
+
*/
|
|
315
|
+
export function externalizeContentRefs(obj, resolver, metadata, maxStringChars, externalizePayloads = true) {
|
|
316
|
+
const meta = externalizePayloads && resolver ? { ...metadata } : metadata;
|
|
317
|
+
return externalizeWalk(obj, resolver, meta, maxStringChars, externalizePayloads);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Recursively externalize multimodal inline payloads and cap over-long text. When
|
|
321
|
+
* `externalizePayloads` is set, walks message-content arrays — the `content` key
|
|
322
|
+
* whose value is an array, as the serialized message nests it under `kwargs` — and
|
|
323
|
+
* replaces every part carrying an inline binary payload with a reference (see
|
|
324
|
+
* {@link externalizeContentPart}). At every string leaf, when `maxStringChars` is
|
|
325
|
+
* set, an over-long value is replaced with a compact `{ _truncated: … }` placeholder
|
|
326
|
+
* (see {@link truncateLongString}); keys are never touched (only values).
|
|
327
|
+
*
|
|
328
|
+
* Copy-on-write: returns the *same* object when nothing beneath it changed, and
|
|
329
|
+
* allocates new containers only along the path to a replaced part / truncated leaf.
|
|
330
|
+
* So inputs / outputs with no multimodal parts and no over-long text (every chain /
|
|
331
|
+
* tool span, text-only chat turns) do no copying, and the input is never mutated —
|
|
332
|
+
* the live model message is untouched.
|
|
333
|
+
*/
|
|
334
|
+
function externalizeWalk(obj, resolver, metadata, maxStringChars, externalizePayloads = true) {
|
|
335
|
+
if (typeof obj === "string")
|
|
336
|
+
return truncateLongString(obj, maxStringChars);
|
|
337
|
+
if (Array.isArray(obj)) {
|
|
338
|
+
let result = obj;
|
|
339
|
+
for (let i = 0; i < obj.length; i++) {
|
|
340
|
+
const newItem = externalizeWalk(obj[i], resolver, metadata, maxStringChars, externalizePayloads);
|
|
341
|
+
if (newItem !== obj[i]) {
|
|
342
|
+
if (result === obj)
|
|
343
|
+
result = [...obj];
|
|
344
|
+
result[i] = newItem;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return result;
|
|
348
|
+
}
|
|
349
|
+
if (obj && typeof obj === "object") {
|
|
350
|
+
const src = obj;
|
|
351
|
+
let result = src;
|
|
352
|
+
for (const [key, value] of Object.entries(src)) {
|
|
353
|
+
const newValue = externalizePayloads && key === "content" && Array.isArray(value)
|
|
354
|
+
? externalizeContentList(value, resolver, metadata, maxStringChars)
|
|
355
|
+
: externalizeWalk(value, resolver, metadata, maxStringChars, externalizePayloads);
|
|
356
|
+
if (newValue !== value) {
|
|
357
|
+
if (result === src)
|
|
358
|
+
result = { ...src };
|
|
359
|
+
result[key] = newValue;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return result;
|
|
363
|
+
}
|
|
364
|
+
return obj;
|
|
365
|
+
}
|
|
366
|
+
function toFiniteNumber(v) {
|
|
367
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
368
|
+
}
|
|
369
|
+
function runMetadata(run) {
|
|
370
|
+
const meta = run.extra?.metadata;
|
|
371
|
+
return meta && typeof meta === "object" ? meta : {};
|
|
372
|
+
}
|
|
373
|
+
function getMessageKwargs(run) {
|
|
374
|
+
const outputs = run.outputs;
|
|
375
|
+
const generations = outputs?.generations;
|
|
376
|
+
if (!Array.isArray(generations))
|
|
377
|
+
return undefined;
|
|
378
|
+
const firstGroup = generations[0];
|
|
379
|
+
if (!Array.isArray(firstGroup))
|
|
380
|
+
return undefined;
|
|
381
|
+
const firstGen = firstGroup[0];
|
|
382
|
+
if (!firstGen || typeof firstGen !== "object")
|
|
383
|
+
return undefined;
|
|
384
|
+
const message = firstGen.message;
|
|
385
|
+
if (!message || typeof message !== "object")
|
|
386
|
+
return undefined;
|
|
387
|
+
const kwargs = message.kwargs;
|
|
388
|
+
if (kwargs && typeof kwargs === "object")
|
|
389
|
+
return kwargs;
|
|
390
|
+
return message;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Extract normalized token usage from an LLM run end payload, extended for the
|
|
394
|
+
* Anthropic `cache_creation` category to match the Python `_extract_usage`.
|
|
395
|
+
*
|
|
396
|
+
* Prefers LangChain normalized `usage_metadata`
|
|
397
|
+
* (`input_token_details.cache_read` / `.cache_creation`,
|
|
398
|
+
* `output_token_details.reasoning`); falls back to raw `response_metadata.usage`
|
|
399
|
+
* (`input_tokens_details.cached_tokens` / `cache_read_input_tokens`,
|
|
400
|
+
* `cache_creation_input_tokens`, `output_tokens_details.reasoning_tokens`).
|
|
401
|
+
* Missing cache/reasoning sub-fields default to `0`. Returns `undefined` when
|
|
402
|
+
* neither source is present.
|
|
403
|
+
*/
|
|
404
|
+
export function extractUsage(run) {
|
|
405
|
+
const kwargs = getMessageKwargs(run);
|
|
406
|
+
const normalize = (input, output, total, cached, cacheCreation, reasoning) => {
|
|
407
|
+
const inputN = toFiniteNumber(input);
|
|
408
|
+
const outputN = toFiniteNumber(output);
|
|
409
|
+
if (inputN === undefined || outputN === undefined)
|
|
410
|
+
return undefined;
|
|
411
|
+
const totalN = toFiniteNumber(total) ?? inputN + outputN;
|
|
412
|
+
return {
|
|
413
|
+
input_tokens: inputN,
|
|
414
|
+
output_tokens: outputN,
|
|
415
|
+
total_tokens: totalN,
|
|
416
|
+
cached_tokens: toFiniteNumber(cached) ?? 0,
|
|
417
|
+
cache_creation_tokens: toFiniteNumber(cacheCreation) ?? 0,
|
|
418
|
+
reasoning_tokens: toFiniteNumber(reasoning) ?? 0,
|
|
419
|
+
};
|
|
420
|
+
};
|
|
421
|
+
if (kwargs) {
|
|
422
|
+
const usageMetadata = kwargs.usage_metadata;
|
|
423
|
+
if (usageMetadata && typeof usageMetadata === "object") {
|
|
424
|
+
const inputDetails = usageMetadata.input_token_details;
|
|
425
|
+
const outputDetails = usageMetadata.output_token_details;
|
|
426
|
+
const result = normalize(usageMetadata.input_tokens, usageMetadata.output_tokens, usageMetadata.total_tokens, inputDetails?.cache_read, inputDetails?.cache_creation, outputDetails?.reasoning);
|
|
427
|
+
if (result)
|
|
428
|
+
return result;
|
|
429
|
+
}
|
|
430
|
+
const responseMeta = kwargs.response_metadata;
|
|
431
|
+
const usage = responseMeta?.usage;
|
|
432
|
+
if (usage && typeof usage === "object") {
|
|
433
|
+
const inputDetails = usage.input_tokens_details;
|
|
434
|
+
const outputDetails = usage.output_tokens_details;
|
|
435
|
+
// Azure-raw nests cached under input_tokens_details; Anthropic-raw exposes
|
|
436
|
+
// cache_read_input_tokens / cache_creation_input_tokens at the usage root.
|
|
437
|
+
const cached = inputDetails && "cached_tokens" in inputDetails
|
|
438
|
+
? inputDetails.cached_tokens
|
|
439
|
+
: usage.cache_read_input_tokens;
|
|
440
|
+
const result = normalize(usage.input_tokens, usage.output_tokens, usage.total_tokens, cached, usage.cache_creation_input_tokens, outputDetails?.reasoning_tokens);
|
|
441
|
+
if (result)
|
|
442
|
+
return result;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Derive the canonical LiteLLM catalog key for an LLM run, aligned with the
|
|
449
|
+
* Python `_extract_model`.
|
|
450
|
+
*
|
|
451
|
+
* Sources, in order: invocation params `model` (probed both top-level on
|
|
452
|
+
* `run.extra.invocation_params` and nested under `run.extra.metadata`), then
|
|
453
|
+
* `response_metadata.model_name` — each qualified via `qualify` (default
|
|
454
|
+
* {@link qualifyDeploymentName}; consumers with non-canonical deployment
|
|
455
|
+
* names supply a resolver via the tracer's `pricingKeyResolver` option).
|
|
456
|
+
*/
|
|
457
|
+
export function extractModel(run, qualify = qualifyDeploymentName) {
|
|
458
|
+
const extra = run.extra;
|
|
459
|
+
let invocationParams = extra?.invocation_params;
|
|
460
|
+
if (!invocationParams || typeof invocationParams !== "object") {
|
|
461
|
+
const meta = extra?.metadata;
|
|
462
|
+
invocationParams = meta?.invocation_params;
|
|
463
|
+
}
|
|
464
|
+
if (invocationParams && typeof invocationParams === "object") {
|
|
465
|
+
const model = invocationParams.model;
|
|
466
|
+
if (typeof model === "string" && model.length > 0) {
|
|
467
|
+
return qualify(model);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const kwargs = getMessageKwargs(run);
|
|
471
|
+
const responseMeta = kwargs?.response_metadata;
|
|
472
|
+
const modelName = responseMeta?.model_name;
|
|
473
|
+
if (typeof modelName === "string" && modelName.length > 0) {
|
|
474
|
+
return qualify(modelName);
|
|
475
|
+
}
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
function createModelRollup() {
|
|
479
|
+
return {
|
|
480
|
+
input_cost: 0,
|
|
481
|
+
output_cost: 0,
|
|
482
|
+
total_cost: 0,
|
|
483
|
+
input_tokens: 0,
|
|
484
|
+
output_tokens: 0,
|
|
485
|
+
cache_read_input_tokens: 0,
|
|
486
|
+
cache_creation_input_tokens: 0,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
function createTraceAggregate(rootTraceId) {
|
|
490
|
+
return {
|
|
491
|
+
rootTraceId,
|
|
492
|
+
inputCost: 0,
|
|
493
|
+
outputCost: 0,
|
|
494
|
+
totalCost: 0,
|
|
495
|
+
unknownModels: new Set(),
|
|
496
|
+
hasAnyCost: false,
|
|
497
|
+
byModel: new Map(),
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
export class DatabricksLangGraphTracer extends BaseTracer {
|
|
501
|
+
name = "databricks_langgraph";
|
|
502
|
+
source;
|
|
503
|
+
qualifyModel;
|
|
504
|
+
contentRefResolver;
|
|
505
|
+
maxStringChars;
|
|
506
|
+
spanMap = new Map();
|
|
507
|
+
traceAggregates = new Map();
|
|
508
|
+
rootByRunId = new Map();
|
|
509
|
+
constructor(options = {}) {
|
|
510
|
+
super();
|
|
511
|
+
this.source = options.source ?? "langgraph";
|
|
512
|
+
const resolver = options.pricingKeyResolver;
|
|
513
|
+
this.qualifyModel = resolver
|
|
514
|
+
? (name) => resolver(name) ?? qualifyDeploymentName(name)
|
|
515
|
+
: qualifyDeploymentName;
|
|
516
|
+
this.contentRefResolver = options.contentRefResolver;
|
|
517
|
+
this.maxStringChars = resolveMaxStringChars(options.maxStringChars);
|
|
518
|
+
}
|
|
519
|
+
async onRunCreate(run) {
|
|
520
|
+
try {
|
|
521
|
+
const spanType = mapLangChainRunTypeToMlflowSpanType(run.run_type);
|
|
522
|
+
const parentSpan = run.parent_run_id !== undefined ? this.spanMap.get(run.parent_run_id) : undefined;
|
|
523
|
+
// Capture structured chat-model message inputs (the TS BaseTracer already
|
|
524
|
+
// records `{ messages }` rather than flattened prompts), then externalize the
|
|
525
|
+
// inline payload of every multimodal image / PDF / file part to a reference and
|
|
526
|
+
// cap over-long text so it does not bloat the trace (issues #16/#21/#23). A
|
|
527
|
+
// no-op for text-only inputs under the cap; runs on the serialized copy, so the
|
|
528
|
+
// live model message is untouched.
|
|
529
|
+
let inputs = serializeTracePayload(run.inputs);
|
|
530
|
+
if (inputs !== undefined) {
|
|
531
|
+
inputs = externalizeContentRefs(inputs, this.contentRefResolver, runMetadata(run), this.maxStringChars);
|
|
532
|
+
}
|
|
533
|
+
const span = startSpan({
|
|
534
|
+
name: run.name?.trim() ? run.name : DEFAULT_SPAN_NAME,
|
|
535
|
+
spanType,
|
|
536
|
+
inputs: inputs,
|
|
537
|
+
parent: parentSpan,
|
|
538
|
+
});
|
|
539
|
+
this.spanMap.set(run.id, span);
|
|
540
|
+
if (run.parent_run_id === undefined) {
|
|
541
|
+
// ROOT
|
|
542
|
+
this.traceAggregates.set(run.id, createTraceAggregate(span.traceId));
|
|
543
|
+
this.rootByRunId.set(run.id, run.id); // self-map
|
|
544
|
+
this.writeRootTraceFields(run, span);
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
// child — propagate the root id for cost aggregation
|
|
548
|
+
const rootId = this.rootByRunId.get(run.parent_run_id);
|
|
549
|
+
if (rootId)
|
|
550
|
+
this.rootByRunId.set(run.id, rootId);
|
|
551
|
+
this.writeChildLangGraphContext(run, span);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
catch (err) {
|
|
555
|
+
console.warn("[DatabricksLangGraphTracer] onRunCreate failed:", err);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
async onRunUpdate(run) {
|
|
559
|
+
if (run.end_time === undefined)
|
|
560
|
+
return;
|
|
561
|
+
const span = this.spanMap.get(run.id);
|
|
562
|
+
if (!span)
|
|
563
|
+
return;
|
|
564
|
+
// Enrichment, status, and the root rollup are each best-effort: a failure in
|
|
565
|
+
// one must degrade only its own fields, never prevent span finalization
|
|
566
|
+
// below (resilient by design, per-run). If span.end() were skipped,
|
|
567
|
+
// the finally would drop the only reference and the span would never export.
|
|
568
|
+
if (run.run_type === "llm") {
|
|
569
|
+
try {
|
|
570
|
+
this.enrichLLMSpan(run, span);
|
|
571
|
+
}
|
|
572
|
+
catch (err) {
|
|
573
|
+
console.warn("[DatabricksLangGraphTracer] LLM span enrichment failed:", err);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
if (run.error) {
|
|
577
|
+
try {
|
|
578
|
+
span.setStatus(SpanStatusCode.ERROR, run.error);
|
|
579
|
+
}
|
|
580
|
+
catch (err) {
|
|
581
|
+
console.warn("[DatabricksLangGraphTracer] setStatus failed:", err);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// Flush the root rollup BEFORE span.end(): the MLflow span processor pops the
|
|
585
|
+
// trace from InMemoryTraceManager synchronously inside onEnd, after which
|
|
586
|
+
// writeTraceFields silently no-ops.
|
|
587
|
+
if (run.parent_run_id === undefined) {
|
|
588
|
+
try {
|
|
589
|
+
const aggregate = this.traceAggregates.get(run.id);
|
|
590
|
+
if (aggregate)
|
|
591
|
+
this.writeRootTraceRollup(aggregate);
|
|
592
|
+
}
|
|
593
|
+
catch (err) {
|
|
594
|
+
console.warn("[DatabricksLangGraphTracer] root trace rollup failed:", err);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
try {
|
|
598
|
+
// Cap over-long text on a copy of the outputs — the #23 spans that breach the
|
|
599
|
+
// inline read limit (final_output / aggregate_results) are output-side. Unlike
|
|
600
|
+
// inputs, the multimodal payload stripping is NOT run here
|
|
601
|
+
// (externalizePayloads=false): outputs are arbitrary chain / tool JSON, so the
|
|
602
|
+
// data/base64 probe would mis-strip legitimate non-binary output (see
|
|
603
|
+
// externalizeContentRefs).
|
|
604
|
+
let outputs = serializeTracePayload(run.outputs ?? {});
|
|
605
|
+
try {
|
|
606
|
+
outputs = externalizeContentRefs(outputs, this.contentRefResolver, runMetadata(run), this.maxStringChars, false);
|
|
607
|
+
}
|
|
608
|
+
catch (err) {
|
|
609
|
+
// Resilient by design: an externalization failure must never skip
|
|
610
|
+
// span.end(). Fall back to the serialized outputs so the span still exports.
|
|
611
|
+
console.warn("[DatabricksLangGraphTracer] output externalization failed; recording raw:", err);
|
|
612
|
+
outputs = serializeTracePayload(run.outputs ?? {});
|
|
613
|
+
}
|
|
614
|
+
span.end({ outputs: outputs });
|
|
615
|
+
}
|
|
616
|
+
catch (err) {
|
|
617
|
+
console.warn("[DatabricksLangGraphTracer] span.end failed:", err);
|
|
618
|
+
}
|
|
619
|
+
finally {
|
|
620
|
+
this.spanMap.delete(run.id);
|
|
621
|
+
// Root aggregate is keyed by root run id; a no-op for child/LLM runs.
|
|
622
|
+
this.traceAggregates.delete(run.id);
|
|
623
|
+
// Unconditionally clear this run's root mapping (self-map for a root,
|
|
624
|
+
// own->root lookup for a child). Never delete the *value*: sibling children
|
|
625
|
+
// of a still-running root rely on the root's self-mapping staying put.
|
|
626
|
+
this.rootByRunId.delete(run.id);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Write the root trace tags + session/user metadata (Python
|
|
631
|
+
* `_write_root_trace_fields`). Always emits `source` + the `langgraph.run_id` /
|
|
632
|
+
* `graph_id` / `env` / `api_revision` tags. A thread id additionally produces
|
|
633
|
+
* the `langgraph.thread_id` tag and the reserved `mlflow.trace.session`
|
|
634
|
+
* metadata; a user identity produces the reserved `mlflow.trace.user` metadata.
|
|
635
|
+
*/
|
|
636
|
+
writeRootTraceFields(run, span) {
|
|
637
|
+
const meta = runMetadata(run);
|
|
638
|
+
const tags = {
|
|
639
|
+
[TraceTagKey.SOURCE]: this.source,
|
|
640
|
+
[TraceTagKey.LANGGRAPH_RUN_ID]: run.id,
|
|
641
|
+
[TraceTagKey.LANGGRAPH_GRAPH_ID]: typeof meta.graph_id === "string" ? meta.graph_id : "agent",
|
|
642
|
+
[TraceTagKey.LANGGRAPH_ENV]: process.env.AGENT_ENV ?? "unknown",
|
|
643
|
+
[TraceTagKey.LANGGRAPH_API_REVISION]: process.env.AGENT_API_REVISION ?? "unknown",
|
|
644
|
+
};
|
|
645
|
+
const metadata = {};
|
|
646
|
+
const threadId = typeof meta.thread_id === "string" ? meta.thread_id : undefined;
|
|
647
|
+
if (threadId) {
|
|
648
|
+
tags[TraceTagKey.LANGGRAPH_THREAD_ID] = threadId;
|
|
649
|
+
metadata[TraceMetadataKey.TRACE_SESSION] = threadId;
|
|
650
|
+
}
|
|
651
|
+
// User identity: LangGraph's auth middleware populates `langgraph_auth_user_id`;
|
|
652
|
+
// callers may instead pass `user_id` / `user`.
|
|
653
|
+
const user = pickString(meta, "langgraph_auth_user_id") ??
|
|
654
|
+
pickString(meta, "user_id") ??
|
|
655
|
+
pickString(meta, "user");
|
|
656
|
+
if (user)
|
|
657
|
+
metadata[TraceMetadataKey.TRACE_USER] = user;
|
|
658
|
+
writeTraceFields(span.traceId, {
|
|
659
|
+
tags,
|
|
660
|
+
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Write `langgraph.node` / `langgraph.step` onto a child span. LangGraph stamps
|
|
665
|
+
* these onto every per-node run's metadata (including the node's nested LLM
|
|
666
|
+
* run), mirroring the Python `_write_child_langgraph_context`.
|
|
667
|
+
*/
|
|
668
|
+
writeChildLangGraphContext(run, span) {
|
|
669
|
+
const meta = runMetadata(run);
|
|
670
|
+
const node = meta.langgraph_node;
|
|
671
|
+
if (typeof node === "string" && node.length > 0) {
|
|
672
|
+
span.setAttribute(SpanAttrKey.LANGGRAPH_NODE, node);
|
|
673
|
+
}
|
|
674
|
+
const step = meta.langgraph_step;
|
|
675
|
+
if (typeof step === "number" && Number.isFinite(step)) {
|
|
676
|
+
span.setAttribute(SpanAttrKey.LANGGRAPH_STEP, step);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Write model / provider / token-usage / cost attrs on a live LLM span (Python
|
|
681
|
+
* `_enrich_llm_span`). Token usage is written whenever usage is extractable;
|
|
682
|
+
* model / provider whenever the model resolves. Cost needs both: the reserved
|
|
683
|
+
* 3-key `mlflow.llm.cost` plus the non-reserved cache-read / cache-creation
|
|
684
|
+
* cost line-items (Option B) are written and folded into the root aggregate; a
|
|
685
|
+
* resolved-but-unpriceable model is recorded for the `cost.unknown_model` tag.
|
|
686
|
+
* Whenever the model resolves with usage, the span is also folded into the
|
|
687
|
+
* per-model `cost.by_model` rollup (tokens always; cost when priceable).
|
|
688
|
+
*
|
|
689
|
+
* The OpenInference-style `llm.*` duplicates (`llm.model_name` /
|
|
690
|
+
* `llm.model_provider` / `llm.usage.prompt_tokens_cost` /
|
|
691
|
+
* `llm.usage.completion_tokens_cost`) mirror the MLflow-convention attributes
|
|
692
|
+
* because the Databricks experiment Overview dashboard aggregates cost from
|
|
693
|
+
* those keys via `variant_get` on the UC span table — without them, Cost
|
|
694
|
+
* Breakdown / Cost Over Time render $0.00. The cost values must
|
|
695
|
+
* stay numbers: `variant_get(..., 'DOUBLE')` returns NULL for strings.
|
|
696
|
+
*/
|
|
697
|
+
enrichLLMSpan(run, span) {
|
|
698
|
+
const usage = extractUsage(run);
|
|
699
|
+
const model = extractModel(run, this.qualifyModel);
|
|
700
|
+
if (usage)
|
|
701
|
+
this.writeTokenUsage(span, usage);
|
|
702
|
+
if (model) {
|
|
703
|
+
const provider = getPricingProvider(model) ?? "azure";
|
|
704
|
+
span.setAttribute(SpanAttrKey.LLM_MODEL, model);
|
|
705
|
+
span.setAttribute(SpanAttrKey.LLM_PROVIDER, provider);
|
|
706
|
+
span.setAttribute(SpanAttrKey.LLM_MODEL_NAME, model);
|
|
707
|
+
span.setAttribute(SpanAttrKey.LLM_MODEL_PROVIDER, provider);
|
|
708
|
+
}
|
|
709
|
+
if (!usage || !model)
|
|
710
|
+
return;
|
|
711
|
+
const rootRunId = this.rootByRunId.get(run.id);
|
|
712
|
+
const aggregate = rootRunId !== undefined ? this.traceAggregates.get(rootRunId) : undefined;
|
|
713
|
+
const cost = calculateLLMCost(model, usage);
|
|
714
|
+
if (cost) {
|
|
715
|
+
span.setAttribute(SpanAttrKey.LLM_COST, spanCostPayload(cost));
|
|
716
|
+
// Non-reserved cost line-items (Option B): the cache-read / cache-creation
|
|
717
|
+
// split the reserved 3-key cost can't carry.
|
|
718
|
+
span.setAttribute(SpanAttrKey.INPUT_TOKENS_CACHED_COST, cost.cached_input_cost);
|
|
719
|
+
span.setAttribute(SpanAttrKey.INPUT_TOKENS_CACHE_CREATION_COST, cost.cache_creation_cost);
|
|
720
|
+
// Dashboard cost keys (numbers, summed by the Overview charts). These
|
|
721
|
+
// mirror the reserved mlflow.llm.cost input/output split, so
|
|
722
|
+
// prompt_tokens_cost is cache-inclusive (folds in the cache-read /
|
|
723
|
+
// cache-creation cost) — not a pure prompt-token figure. The cache split
|
|
724
|
+
// lives on the gen_ai.usage.* slots above.
|
|
725
|
+
span.setAttribute(SpanAttrKey.LLM_PROMPT_TOKENS_COST, cost.input_cost);
|
|
726
|
+
span.setAttribute(SpanAttrKey.LLM_COMPLETION_TOKENS_COST, cost.output_cost);
|
|
727
|
+
}
|
|
728
|
+
if (aggregate) {
|
|
729
|
+
// Fold this span into the per-model rollup (the cost.by_model tag). Tokens
|
|
730
|
+
// always contribute — even for a resolved-but-unpriceable model — so the
|
|
731
|
+
// per-model token split stays complete; cost contributes only when the
|
|
732
|
+
// model priced (otherwise it's recorded for the cost.unknown_model tag,
|
|
733
|
+
// matching the trace-level rollup).
|
|
734
|
+
let bucket = aggregate.byModel.get(model);
|
|
735
|
+
if (!bucket) {
|
|
736
|
+
bucket = createModelRollup();
|
|
737
|
+
aggregate.byModel.set(model, bucket);
|
|
738
|
+
}
|
|
739
|
+
bucket.input_tokens += usage.input_tokens || 0;
|
|
740
|
+
bucket.output_tokens += usage.output_tokens || 0;
|
|
741
|
+
bucket.cache_read_input_tokens += usage.cached_tokens || 0;
|
|
742
|
+
bucket.cache_creation_input_tokens += usage.cache_creation_tokens || 0;
|
|
743
|
+
if (cost) {
|
|
744
|
+
bucket.input_cost += cost.input_cost;
|
|
745
|
+
bucket.output_cost += cost.output_cost;
|
|
746
|
+
bucket.total_cost += cost.total_cost;
|
|
747
|
+
aggregate.inputCost += cost.input_cost;
|
|
748
|
+
aggregate.outputCost += cost.output_cost;
|
|
749
|
+
aggregate.totalCost += cost.total_cost;
|
|
750
|
+
aggregate.hasAnyCost = true;
|
|
751
|
+
}
|
|
752
|
+
else {
|
|
753
|
+
aggregate.unknownModels.add(model);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Write token-usage span attributes. The reserved `mlflow.chat.tokenUsage`
|
|
759
|
+
* attribute carries MLflow's `TokenUsageKey` shape extended with the two cache
|
|
760
|
+
* slots — `{ input_tokens, output_tokens, total_tokens, cache_read_input_tokens,
|
|
761
|
+
* cache_creation_input_tokens }` — so they survive Databricks ingestion and
|
|
762
|
+
* render in the UI usage breakdown. The non-reserved `gen_ai.usage.*`
|
|
763
|
+
* token-count attrs mirror the Python `_write_token_usage`.
|
|
764
|
+
*/
|
|
765
|
+
writeTokenUsage(span, usage) {
|
|
766
|
+
const inputTokens = usage.input_tokens || 0;
|
|
767
|
+
const outputTokens = usage.output_tokens || 0;
|
|
768
|
+
const totalTokens = usage.total_tokens || inputTokens + outputTokens;
|
|
769
|
+
const cached = usage.cached_tokens || 0;
|
|
770
|
+
const cacheCreation = usage.cache_creation_tokens || 0;
|
|
771
|
+
const reasoning = usage.reasoning_tokens || 0;
|
|
772
|
+
span.setAttribute(SpanAttrKey.TOKEN_USAGE, {
|
|
773
|
+
input_tokens: inputTokens,
|
|
774
|
+
output_tokens: outputTokens,
|
|
775
|
+
total_tokens: totalTokens,
|
|
776
|
+
cache_read_input_tokens: cached,
|
|
777
|
+
cache_creation_input_tokens: cacheCreation,
|
|
778
|
+
});
|
|
779
|
+
span.setAttribute(SpanAttrKey.INPUT_TOKENS_CACHED, cached);
|
|
780
|
+
span.setAttribute(SpanAttrKey.INPUT_TOKENS_CACHE_CREATION, cacheCreation);
|
|
781
|
+
span.setAttribute(SpanAttrKey.OUTPUT_TOKENS_REASONING, reasoning);
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Write the trace-level cost rollups + `cost.unknown_model` tag (Python
|
|
785
|
+
* `_write_root_trace_rollup`). `mlflow.trace.cost` is the reserved MLflow
|
|
786
|
+
* `CostKey` shape — exactly `{ input_cost, output_cost, total_cost }`;
|
|
787
|
+
* Databricks strips any extra key on ingestion. `total_cost` already accounts
|
|
788
|
+
* for the cached discount and cache-creation premium; the per-line-item split
|
|
789
|
+
* lives on the spans.
|
|
790
|
+
*
|
|
791
|
+
* `cost.by_model` is the non-reserved per-model breakdown: emitted
|
|
792
|
+
* as a JSON string under a **trace tag** (like `cost.unknown_model`) rather
|
|
793
|
+
* than a custom metadata key — tags are proven to survive Databricks UC
|
|
794
|
+
* ingestion and stay queryable from the `*_trace_unified` view. Each bucket
|
|
795
|
+
* carries cost and tokens; the per-model totals reconcile with
|
|
796
|
+
* `mlflow.trace.cost` / `mlflow.chat.tokenUsage` within rounding. Models are
|
|
797
|
+
* emitted in sorted order, mirroring the Python tracer (the JSON parses to the
|
|
798
|
+
* same structure; the serialized bytes differ — Python `json.dumps` inserts
|
|
799
|
+
* `", "` / `": "` separators and renders integral zero floats as `0.0`).
|
|
800
|
+
*
|
|
801
|
+
* Must run before the root `span.end()` (see {@link onRunUpdate}).
|
|
802
|
+
*/
|
|
803
|
+
writeRootTraceRollup(aggregate) {
|
|
804
|
+
if (aggregate.hasAnyCost) {
|
|
805
|
+
writeTraceFields(aggregate.rootTraceId, {
|
|
806
|
+
metadata: {
|
|
807
|
+
[TraceMetadataKey.TRACE_COST]: JSON.stringify({
|
|
808
|
+
input_cost: aggregate.inputCost,
|
|
809
|
+
output_cost: aggregate.outputCost,
|
|
810
|
+
total_cost: aggregate.totalCost,
|
|
811
|
+
}),
|
|
812
|
+
},
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
if (aggregate.byModel.size > 0) {
|
|
816
|
+
const byModel = {};
|
|
817
|
+
for (const model of [...aggregate.byModel.keys()].sort()) {
|
|
818
|
+
byModel[model] = aggregate.byModel.get(model);
|
|
819
|
+
}
|
|
820
|
+
writeTraceFields(aggregate.rootTraceId, {
|
|
821
|
+
tags: { [TraceTagKey.COST_BY_MODEL]: JSON.stringify(byModel) },
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
if (aggregate.unknownModels.size > 0) {
|
|
825
|
+
writeTraceFields(aggregate.rootTraceId, {
|
|
826
|
+
tags: {
|
|
827
|
+
[TraceTagKey.COST_UNKNOWN_MODEL]: [...aggregate.unknownModels].sort().join(","),
|
|
828
|
+
},
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
async persistRun(_run) {
|
|
833
|
+
// No-op: span lifecycle is handled in onRunCreate / onRunUpdate. Root runs
|
|
834
|
+
// also receive onRunUpdate (Python `_persist_run` parallel).
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
function pickString(meta, key) {
|
|
838
|
+
const v = meta[key];
|
|
839
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
840
|
+
}
|
|
841
|
+
//# sourceMappingURL=databricks-tracer.js.map
|