@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +176 -0
  2. package/LICENSE +5 -0
  3. package/README.md +160 -0
  4. package/THIRD_PARTY_NOTICES +26 -0
  5. package/dist/cost.d.ts +76 -0
  6. package/dist/cost.d.ts.map +1 -0
  7. package/dist/cost.js +124 -0
  8. package/dist/cost.js.map +1 -0
  9. package/dist/databricks-tracer.d.ts +235 -0
  10. package/dist/databricks-tracer.d.ts.map +1 -0
  11. package/dist/databricks-tracer.js +841 -0
  12. package/dist/databricks-tracer.js.map +1 -0
  13. package/dist/errors.d.ts +5 -0
  14. package/dist/errors.d.ts.map +1 -0
  15. package/dist/errors.js +8 -0
  16. package/dist/errors.js.map +1 -0
  17. package/dist/generated-keys.d.ts +39 -0
  18. package/dist/generated-keys.d.ts.map +1 -0
  19. package/dist/generated-keys.js +39 -0
  20. package/dist/generated-keys.js.map +1 -0
  21. package/dist/index.d.ts +65 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +130 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/pricing/model-pricing-overrides.json +28 -0
  26. package/dist/pricing/model-pricing.json +1 -0
  27. package/dist/token-aggregate.d.ts +29 -0
  28. package/dist/token-aggregate.d.ts.map +1 -0
  29. package/dist/token-aggregate.js +63 -0
  30. package/dist/token-aggregate.js.map +1 -0
  31. package/dist/trace-metadata.d.ts +14 -0
  32. package/dist/trace-metadata.d.ts.map +1 -0
  33. package/dist/trace-metadata.js +33 -0
  34. package/dist/trace-metadata.js.map +1 -0
  35. package/dist/uc-export.d.ts +48 -0
  36. package/dist/uc-export.d.ts.map +1 -0
  37. package/dist/uc-export.js +243 -0
  38. package/dist/uc-export.js.map +1 -0
  39. package/package.json +68 -0
  40. package/src/pricing/model-pricing-overrides.json +28 -0
  41. 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