@cap-js/agents 0.9.1 → 0.9.3

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 (48) hide show
  1. package/README.md +2 -1
  2. package/cds-plugin.js +2 -2
  3. package/lib/agents/markdown/backends/outputs-backend.js +16 -12
  4. package/lib/agents/markdown/backends/readonly-backend.js +48 -0
  5. package/lib/agents/markdown/backends/uploads-backend.js +30 -13
  6. package/lib/agents/markdown/deep-agent.js +15 -26
  7. package/lib/agents/middleware/content-filter.js +3 -2
  8. package/lib/agents/middleware/index.js +11 -0
  9. package/lib/agents/middleware/remote-mcp.js +62 -0
  10. package/lib/agents/middleware/tool-wrap.js +52 -0
  11. package/lib/compile.js +6 -2
  12. package/lib/eval/Judge.js +239 -0
  13. package/lib/eval/eval-describe.js +69 -0
  14. package/lib/eval/eval-run.js +147 -0
  15. package/lib/eval/index.js +6 -0
  16. package/lib/eval/metrics.js +53 -0
  17. package/lib/eval/span-collector.js +50 -0
  18. package/lib/index.js +4 -3
  19. package/lib/models/aicore.js +23 -6
  20. package/lib/preview/chat.html +394 -69
  21. package/lib/protocol/agent-card.js +6 -2
  22. package/lib/protocol/persistence/checkpoint-saver.js +4 -1
  23. package/lib/protocol/persistence/cleanup.js +84 -0
  24. package/lib/sidecar.js +1 -1
  25. package/lib/telemetry/chat-tracing.js +61 -8
  26. package/lib/telemetry/mlflow/credentials.js +79 -0
  27. package/lib/telemetry/mlflow/evaluation.js +38 -0
  28. package/lib/telemetry/mlflow/exporter/DatabricksExporter.js +75 -0
  29. package/lib/telemetry/mlflow/exporter/MlflowExporter.js +115 -0
  30. package/lib/telemetry/mlflow/exporter/index.js +23 -0
  31. package/lib/telemetry/mlflow/index.js +16 -0
  32. package/lib/telemetry/mlflow/prompts.js +123 -0
  33. package/lib/telemetry/mlflow/tracing.js +264 -0
  34. package/lib/telemetry/tool-tracing.js +27 -54
  35. package/lib/telemetry/tracing.js +3 -1
  36. package/lib/utils/markdown.js +1 -11
  37. package/lib/utils/resilience.js +133 -0
  38. package/lib/utils/utils.js +17 -0
  39. package/package.json +24 -15
  40. package/{index.cds → srv/entities.cds} +11 -0
  41. package/srv/handlers/chat.js +278 -0
  42. package/srv/handlers/graph-executor.js +48 -37
  43. package/srv/handlers/index.js +8 -0
  44. package/srv/handlers/mcp-tools.js +11 -47
  45. package/srv/handlers/sub-agent-tools.js +12 -3
  46. package/srv/handlers/tools.js +9 -5
  47. package/index.js +0 -0
  48. package/lib/telemetry/mlflow.js +0 -290
@@ -1,5 +1,4 @@
1
1
  import cds from "@sap/cds"
2
- import { MultiServerMCPClient } from "@langchain/mcp-adapters"
3
2
  import { generateTools } from "./tools.js"
4
3
  import { toolName } from "../../lib/utils/utils.js"
5
4
 
@@ -42,48 +41,26 @@ async function resolveDestination(destinationName, dest, localUrl) {
42
41
  }
43
42
  }
44
43
 
45
- /**
46
- * Wrap MCP tool invocations to convert errors into plain string results.
47
- * deepagents' wrapToolCall middleware marks errors thrown inside it as
48
- * "middleware errors", which LangChain's ToolNode re-throws rather than
49
- * converting to a ToolMessage (see ToolNode.js: isMiddlewareError check).
50
- * MCP tool schema validation errors therefore crash the graph instead of
51
- * being fed back to the LLM as recoverable feedback.
52
- */
53
- function wrapToolsWithErrorHandling(tools, serviceName) {
54
- return tools.map((tool) => {
55
- const original = tool.invoke.bind(tool)
56
- tool.invoke = async (args, config) => {
57
- try {
58
- return await original(args, config)
59
- } catch (err) {
60
- LOG.warn(`MCP tool "${tool.name}" error: ${err.message}`, { service: serviceName })
61
- return `Error: ${err.message}`
62
- }
63
- }
64
- return tool
65
- })
66
- }
67
-
68
44
  export async function buildMcpToolsLocally(serviceName) {
69
45
  const srv = cds.services[serviceName]
70
46
  const tools = generateTools(srv)
71
47
  const prefix = toolName(`${serviceName}_`)
72
48
  for (const tool of tools) tool.name = `${prefix}${tool.name}`
73
49
 
74
- LOG.info(
50
+ LOG.debug(
75
51
  `Got ${tools.length} MCP tools from ${serviceName}: ${tools.map((t) => t.name).join(", ")}`,
76
52
  )
77
53
  return tools
78
54
  }
79
55
 
80
56
  /**
81
- * Build LangChain tools from a CAP MCP connection.
82
- * Resolves the destination URL and auth headers, connects to the MCP server,
83
- * and returns the tools wrapped with error handling for use in agent graphs.
57
+ * Build a dynamic MCP placeholder from a CAP MCP connection.
58
+ * Resolves the destination URL and auth-header factory; the actual tools/list
59
+ * call is deferred to remoteMcpMiddleware which runs per-request with the
60
+ * current user's credentials.
84
61
  *
85
62
  * @param {string} serviceName - cds.requires service key
86
- * @returns {Promise<import("@langchain/core/tools").StructuredTool[]>}
63
+ * @returns {Promise<{ _mcpDynamic: true, mcpUrl: string, resolveHeaders: () => Promise<object> }>}
87
64
  */
88
65
  export async function buildMcpToolsFromConnection(serviceName) {
89
66
  let endpoints = cds.service.endpoints4({
@@ -94,7 +71,8 @@ export async function buildMcpToolsFromConnection(serviceName) {
94
71
  const { credentials, kind } = cds.requires[serviceName]
95
72
 
96
73
  // dest is either a string (BTP destination name) or an object { name, url, ... }
97
- const destinationName = typeof credentials === "string" ? credentials : credentials?.name
74
+ const destinationName =
75
+ typeof credentials === "string" ? credentials : (credentials?.destination ?? credentials?.name)
98
76
  const localUrl =
99
77
  typeof credentials === "string"
100
78
  ? null
@@ -121,7 +99,8 @@ export async function buildMcpToolsFromConnection(serviceName) {
121
99
  )
122
100
  }
123
101
 
124
- const mcpUrl = url.replace(/\/$/, "")
102
+ const path = typeof credentials === "object" ? credentials?.path : null
103
+ const mcpUrl = url.replace(/\/$/, "") + (path ? `/${path.replace(/^\//, "")}` : "")
125
104
  LOG.info(`Connecting to MCP server at ${mcpUrl}`)
126
105
 
127
106
  const resolveHeaders = async () => {
@@ -134,22 +113,7 @@ export async function buildMcpToolsFromConnection(serviceName) {
134
113
  return token ? { Authorization: `Bearer ${token}` } : {}
135
114
  }
136
115
 
137
- const client = new MultiServerMCPClient({
138
- mcpServers: {
139
- [serviceName]: { url: mcpUrl },
140
- },
141
- beforeToolCall: async () => ({ headers: await resolveHeaders() }),
142
- })
143
-
144
- const tools = await client.getTools()
145
- const prefix = toolName(`${serviceName}_`)
146
- for (const tool of tools) tool.name = `${prefix}${tool.name}`
147
-
148
- LOG.info(
149
- `Got ${tools.length} MCP tools from ${serviceName}: ${tools.map((t) => t.name).join(", ")}`,
150
- )
151
-
152
- return wrapToolsWithErrorHandling(tools, serviceName)
116
+ return { _mcpDynamic: true, mcpUrl, serviceName, resolveHeaders }
153
117
  }
154
118
 
155
119
  export async function buildMcpTools(serviceName) {
@@ -2,7 +2,7 @@ import cds from "@sap/cds"
2
2
  import { tool } from "@langchain/core/tools"
3
3
  import { z } from "zod"
4
4
  import { LangGraphExecutor } from "../langgraph-executor-srv.js"
5
- import { toolName } from "../../lib/utils/utils.js"
5
+ import { short, toolName } from "../../lib/utils/utils.js"
6
6
 
7
7
  const LOG = cds.log("agents:sub-agents")
8
8
 
@@ -164,6 +164,7 @@ export async function buildSubAgentToolLocally(serviceName) {
164
164
  taskId,
165
165
  contextId,
166
166
  )
167
+ const truncated = message?.length > 80 ? message.slice(0, 80) + "..." : message
167
168
 
168
169
  try {
169
170
  // Run the sub-agent detached from the calling agent, in its own root
@@ -172,6 +173,12 @@ export async function buildSubAgentToolLocally(serviceName) {
172
173
  await new Promise((resolve, reject) => {
173
174
  cds
174
175
  .spawn({ user: cds.context?.user, tenant: cds.context?.tenant }, async () => {
176
+ LOG.info("request", {
177
+ conversation: short(contextId),
178
+ service: srv.name,
179
+ text: truncated,
180
+ })
181
+
175
182
  await executor.execute(requestContext, eventBus)
176
183
  await done
177
184
  })
@@ -206,7 +213,8 @@ export async function buildSubAgentToolFromConnection(serviceName) {
206
213
  endpoints = Object.fromEntries(endpoints.map((o) => [o.kind, o.path]))
207
214
  const { credentials, kind } = cds.requires[serviceName]
208
215
 
209
- const destinationName = typeof credentials === "string" ? credentials : credentials?.name
216
+ const destinationName =
217
+ typeof credentials === "string" ? credentials : (credentials?.destination ?? credentials?.name)
210
218
  const localUrl =
211
219
  typeof credentials === "string"
212
220
  ? null
@@ -270,7 +278,8 @@ export async function buildSubAgentToolFromConnection(serviceName) {
270
278
  )
271
279
  }
272
280
 
273
- const base = agentBaseUrl.replace(/\/$/, "")
281
+ const path = typeof credentials === "object" ? credentials?.path : null
282
+ const base = agentBaseUrl.replace(/\/$/, "") + (path ? `/${path.replace(/^\//, "")}` : "")
274
283
  LOG.info(`Connecting to sub-agent at ${base}`)
275
284
 
276
285
  // revisit: a2a agents may be tenant specific, card per tenant?
@@ -11,7 +11,7 @@ import {
11
11
  executeCallActionTool,
12
12
  executePerActionTool,
13
13
  } from "@cap-js/mcp/lib/tools.js"
14
- import { getFilteredEntities, getFilteredActions } from "../../lib/utils/utils.js"
14
+ import { getFilteredEntities, getFilteredActions, getAgentLogger } from "../../lib/utils/utils.js"
15
15
  import { isTextMime } from "../../lib/agents/markdown/backends/mime-utils.js"
16
16
  import { checkAuthorization } from "@cap-js/mcp/lib/auth.js"
17
17
 
@@ -31,6 +31,7 @@ function cachedAuth(srv) {
31
31
 
32
32
  class GenericReadTool extends DynamicStructuredTool {
33
33
  constructor(srv, entities) {
34
+ const log = getAgentLogger(srv)
34
35
  const def = createGenericReadToolDefinition(Object.keys(entities), srv.name, "")
35
36
  super({
36
37
  name: def.name,
@@ -38,7 +39,7 @@ class GenericReadTool extends DynamicStructuredTool {
38
39
  schema: def.inputSchema,
39
40
  responseFormat: "content_and_artifact",
40
41
  func: async (args) => {
41
- return unwrap(await executeGenericReadTool(srv, entities, args, { log: LOG }))
42
+ return unwrap(await executeGenericReadTool(srv, entities, args, { log }))
42
43
  },
43
44
  })
44
45
  this.srv = srv
@@ -67,6 +68,7 @@ class GenericReadTool extends DynamicStructuredTool {
67
68
 
68
69
  class DescribeTool extends DynamicStructuredTool {
69
70
  constructor(srv, entities, actions) {
71
+ const log = getAgentLogger(srv)
70
72
  const def = createDescribeToolDefinition(
71
73
  Object.keys(entities),
72
74
  Object.keys(actions),
@@ -79,7 +81,7 @@ class DescribeTool extends DynamicStructuredTool {
79
81
  schema: def.inputSchema,
80
82
  responseFormat: "content_and_artifact",
81
83
  func: async (args) => {
82
- return unwrap(await executeDescribe(srv, entities, actions, args, { log: LOG }))
84
+ return unwrap(await executeDescribe(srv, entities, actions, args, { log }))
83
85
  },
84
86
  })
85
87
  this.srv = srv
@@ -119,6 +121,7 @@ class DescribeTool extends DynamicStructuredTool {
119
121
 
120
122
  class PerActionTool extends DynamicStructuredTool {
121
123
  constructor(srv, actionName, action) {
124
+ const log = getAgentLogger(srv)
122
125
  const def = createPerActionToolDefinition(actionName, action, srv.name, srv.model, "")
123
126
  super({
124
127
  name: def.name,
@@ -126,7 +129,7 @@ class PerActionTool extends DynamicStructuredTool {
126
129
  schema: def.inputSchema,
127
130
  responseFormat: "content_and_artifact",
128
131
  func: async (args) => {
129
- return unwrap(await executePerActionTool(srv, actionName, action, args, { log: LOG }))
132
+ return unwrap(await executePerActionTool(srv, actionName, action, args, { log }))
130
133
  },
131
134
  })
132
135
  this.srv = srv
@@ -142,6 +145,7 @@ class PerActionTool extends DynamicStructuredTool {
142
145
 
143
146
  class CallActionTool extends DynamicStructuredTool {
144
147
  constructor(srv, actions) {
148
+ const log = getAgentLogger(srv)
145
149
  const def = createCallActionToolDefinition(Object.keys(actions), srv.name, "")
146
150
  super({
147
151
  name: def.name,
@@ -149,7 +153,7 @@ class CallActionTool extends DynamicStructuredTool {
149
153
  schema: def.inputSchema,
150
154
  responseFormat: "content_and_artifact",
151
155
  func: async (args) => {
152
- return unwrap(await executeCallActionTool(srv, actions, args, { log: LOG }))
156
+ return unwrap(await executeCallActionTool(srv, actions, args, { log }))
153
157
  },
154
158
  })
155
159
  this.srv = srv
package/index.js DELETED
File without changes
@@ -1,290 +0,0 @@
1
- /**
2
- * MLflow span attribute helpers for @cap-js/agents.
3
- *
4
- * Adds mlflow.* attributes to existing OTel spans so the MLflow OTLP
5
- * ingestion endpoint can assemble them into proper MLflow traces.
6
- *
7
- * All functions return {} when cds.env.agents.mlflow is falsy (zero overhead).
8
- *
9
- * The exporter itself is configured via setupMlflowExporter() as a
10
- * RoutingSpanProcessor that lazily creates per-experiment-ID exporters —
11
- * existing exporters (Dynatrace, Cloud Logging, Grafana, etc.) continue
12
- * to work unchanged.
13
- */
14
- import cds from "@sap/cds"
15
-
16
- /**
17
- * Resolve the MLflow experiment ID for the current request context.
18
- *
19
- * Resolution order:
20
- * 1. @Core.SchemaVersion annotation on the service (feature-toggleable via cds.context.model)
21
- * 2. cds.env.requires["mlflow"].credentials.MLFLOW_EXPERIMENT_ID
22
- * 3. process.env.MLFLOW_EXPERIMENT_ID
23
- *
24
- * MLflow requires experiment IDs to be numeric (int64). Throws if annotation
25
- * value is not a valid integer string.
26
- */
27
- function resolveExperimentId() {
28
- // Read from @Core.SchemaVersion on the active service (feature-toggle-aware)
29
- const serviceName = cds.context?.["agent.service"]
30
- if (serviceName) {
31
- const definition =
32
- cds.context?.model?.definitions?.[serviceName] || cds.services?.[serviceName]?.definition
33
- const annotated = definition?.["@Core.SchemaVersion"]
34
- if (annotated) {
35
- const id = String(annotated)
36
- if (!/^\d+$/.test(id)) {
37
- throw new Error(
38
- `@Core.SchemaVersion on "${serviceName}" must be a numeric string (MLflow experiment ID requires int64). Got: "${id}"`,
39
- )
40
- }
41
- return id
42
- }
43
- }
44
-
45
- // Fallback: credentials / env
46
- const creds = cds.env.requires?.mlflow?.credentials || cds.env.requires?.mlflow?.credentials
47
- return creds?.MLFLOW_EXPERIMENT_ID || process.env.MLFLOW_EXPERIMENT_ID
48
- }
49
-
50
- /**
51
- * Build mlflow.* span attributes for a given span type.
52
- * @param {"LLM"|"AGENT"|"TOOL"|"CHAIN"|"RETRIEVER"} spanType
53
- * @param {{ inputs?: any, outputs?: any, model?: string, provider?: string, functionName?: string, tokenUsage?: object }} [opts]
54
- * @returns {Record<string, string>} Attributes to set on span, or {} when disabled
55
- */
56
- export function mlflowAttrs(spanType, opts = {}) {
57
- if (!cds.env.agents?.mlflow) return {}
58
- const experimentId = resolveExperimentId()
59
- const attrs = {}
60
- if (experimentId) attrs["mlflow.experimentId"] = String(experimentId)
61
- attrs["mlflow.spanType"] = spanType
62
- if (opts.model) attrs["mlflow.llm.model"] = String(opts.model)
63
- if (opts.provider) attrs["mlflow.llm.provider"] = String(opts.provider)
64
- if (opts.functionName) attrs["mlflow.spanFunctionName"] = String(opts.functionName)
65
- if (opts.inputs !== undefined) attrs["mlflow.spanInputs"] = JSON.stringify(opts.inputs)
66
- if (opts.outputs !== undefined) attrs["mlflow.spanOutputs"] = JSON.stringify(opts.outputs)
67
- if (opts.tokenUsage) {
68
- attrs["mlflow.chat.tokenUsage"] = JSON.stringify({
69
- input_tokens: opts.tokenUsage.input_tokens,
70
- output_tokens: opts.tokenUsage.output_tokens,
71
- total_tokens: opts.tokenUsage.total_tokens,
72
- cache_creation_input_tokens: opts.tokenUsage.cache_creation_input_tokens,
73
- cache_read_input_tokens: opts.tokenUsage.cache_read_input_tokens,
74
- })
75
- }
76
- return attrs
77
- }
78
-
79
- /**
80
- * Build mlflow trace-level tag attributes (set on root/workflow span).
81
- * Uses mlflow.traceTag.* prefix so MLflow server extracts them as trace tags.
82
- * Also sets OTel semconv user.id and session.id which MLflow reads natively.
83
- * @returns {Record<string, string>} Attributes to set on root span, or {} when disabled
84
- */
85
- export function mlflowTraceAttrs() {
86
- if (!cds.env.agents?.mlflow) return {}
87
- const session = String(cds.context?.["agent.context.id"] || "")
88
- const user = String(cds.context?.user?.id || "")
89
- const tenant = String(cds.context?.tenant || "")
90
- return {
91
- // OTel semconv keys MLflow reads natively for user/session display
92
- "session.id": session,
93
- "user.id": user,
94
- // mlflow.traceTag.* prefix for custom trace tags
95
- "mlflow.traceTag.tenant": tenant,
96
- }
97
- }
98
-
99
- /**
100
- * Spread attributes object onto an OTel span (null-safe).
101
- * Coerces all values to strings to prevent [object Object] in downstream systems.
102
- * @param {import("@opentelemetry/api").Span | null} span
103
- * @param {Record<string, string>} attrs
104
- */
105
- export function setSpanAttrs(span, attrs) {
106
- if (!cds.env.agents?.mlflow) return
107
- if (!span) return
108
- for (const [k, v] of Object.entries(attrs)) {
109
- if (v !== undefined && v !== null) span.setAttribute(k, v)
110
- }
111
- }
112
-
113
- /**
114
- * SpanProcessor that routes spans to per-experiment-ID BatchSpanProcessors.
115
- * Lazily creates an OTLPTraceExporter for each unique mlflow.experimentId
116
- * seen on spans, so different services (or feature-toggled @Core.SchemaVersion
117
- * values) export to the correct MLflow experiment.
118
- *
119
- * Supports both static headers (PAT token) and async header factories (OAuth).
120
- */
121
- export class RoutingSpanProcessor {
122
- constructor({ url, headersConfig, ucTableName, BatchSpanProcessor, OTLPTraceExporter }) {
123
- this._url = url
124
- this._headersConfig = headersConfig
125
- this._ucTableName = ucTableName
126
- this._BatchSpanProcessor = BatchSpanProcessor
127
- this._OTLPTraceExporter = OTLPTraceExporter
128
- this._processors = new Map()
129
- }
130
-
131
- onStart() {}
132
-
133
- onEnd(span) {
134
- const expId = span.attributes?.["mlflow.experimentId"]
135
- if (!expId) return
136
- this._getOrCreate(expId).onEnd(span)
137
- }
138
-
139
- _getOrCreate(experimentId) {
140
- if (this._processors.has(experimentId)) return this._processors.get(experimentId)
141
- const ucHeader = this._ucTableName
142
- ? { "X-Databricks-UC-Table-Name": this._ucTableName }
143
- : undefined
144
- // Build headers — async factory or static object
145
- const headers =
146
- typeof this._headersConfig === "function"
147
- ? async () => ({
148
- ...(await this._headersConfig()),
149
- "x-mlflow-experiment-id": experimentId,
150
- ...ucHeader,
151
- })
152
- : {
153
- ...this._headersConfig,
154
- "x-mlflow-experiment-id": experimentId,
155
- ...ucHeader,
156
- }
157
- const exporter = new this._OTLPTraceExporter({ url: this._url, headers })
158
- const proc = new this._BatchSpanProcessor(exporter)
159
- this._processors.set(experimentId, proc)
160
- return proc
161
- }
162
-
163
- async forceFlush() {
164
- await Promise.all([...this._processors.values()].map((p) => p.forceFlush()))
165
- }
166
-
167
- async shutdown() {
168
- await Promise.all([...this._processors.values()].map((p) => p.shutdown()))
169
- this._processors.clear()
170
- }
171
- }
172
-
173
- /**
174
- * Register MLflow OTLP RoutingSpanProcessor. No-op when credentials missing.
175
- * Reads from cds.env.requires["mlflow"].credentials
176
- *
177
- * Auth: OAuth (clientid+clientsecret+url) takes precedence over static MLFLOW_TOKEN.
178
- * UC: set UC_CATALOG + UC_SCHEMA + UC_TABLE_PREFIX for Unity Catalog trace storage.
179
- */
180
- export async function setupMlflowExporter() {
181
- if (!cds.env.agents?.mlflow) return
182
-
183
- const LOG = cds.log("agents")
184
- try {
185
- const creds = cds.env.requires?.mlflow?.credentials || {}
186
- const host = creds.MLFLOW_HOST
187
- const token = creds.MLFLOW_TOKEN
188
- const mlflowEndpoint =
189
- creds.MLFLOW_OTLP_ENDPOINT || (host && `${host.replace(/\/$/, "")}/v1/traces`)
190
-
191
- // Resolve authentication mode
192
- let headersConfig
193
- if (creds.clientid && creds.clientsecret) {
194
- const tokenUrl = `${creds.url ?? host.replace(/\/$/, "")}/oidc/v1/token`
195
- let cached = null
196
- let expiresAt = 0
197
- headersConfig = async function fetchToken() {
198
- // Return cached token if still valid (60s buffer before expiry)
199
- if (cached && Date.now() < expiresAt) return cached
200
- const res = await fetch(tokenUrl, {
201
- method: "POST",
202
- headers: {
203
- Authorization:
204
- "Basic " + Buffer.from(`${creds.clientid}:${creds.clientsecret}`).toString("base64"),
205
- "Content-Type": "application/x-www-form-urlencoded",
206
- },
207
- body: `grant_type=client_credentials&scope=all-apis`,
208
- signal: AbortSignal.timeout(10_000),
209
- })
210
- if (!res.ok) {
211
- const body = await res.text().catch(() => "")
212
- const msg = `MLFlow oauth export failed: HTTP ${res.status} ${res.statusText}`
213
- throw cds.error({ message: msg, response: body })
214
- }
215
- const { access_token, expires_in } = await res.json()
216
- cached = { Authorization: `Bearer ${access_token}` }
217
- expiresAt = Date.now() + (expires_in - 60) * 1000
218
- return cached
219
- }
220
- LOG.debug("MLflow: using OAuth client credentials authentication")
221
- } else if (token) {
222
- headersConfig = { Authorization: `Bearer ${token}` }
223
- } else {
224
- headersConfig = {}
225
- LOG.debug("MLflow: no auth credentials — assuming unauthenticated MLflow server")
226
- }
227
-
228
- if (!mlflowEndpoint) {
229
- LOG.warn(
230
- "MLflow: no endpoint configured (MLFLOW_HOST or MLFLOW_OTLP_ENDPOINT) — export disabled",
231
- )
232
- return
233
- }
234
-
235
- const ucCatalog = creds.UC_CATALOG
236
- const ucSchema = creds.UC_SCHEMA
237
- const ucTablePrefix = creds.UC_TABLE_PREFIX
238
- const ucTableName =
239
- ucCatalog && ucSchema && ucTablePrefix
240
- ? `${ucCatalog}.${ucSchema}.${ucTablePrefix}_otel_spans`
241
- : undefined
242
-
243
- const { trace } = await import("@opentelemetry/api")
244
- const provider = trace.getTracerProvider()
245
- const delegate = provider.getDelegate?.() || provider
246
-
247
- let BatchSpanProcessor, OTLPTraceExporter
248
- try {
249
- const { createRequire } = await import("node:module")
250
- const req = createRequire(process.cwd() + "/")
251
- ;({ BatchSpanProcessor } = req("@opentelemetry/sdk-trace-base"))
252
- ;({ OTLPTraceExporter } = req("@opentelemetry/exporter-trace-otlp-proto"))
253
- } catch (err) {
254
- LOG.warn(
255
- "MLflow: @opentelemetry/exporter-trace-otlp-proto not resolvable from the application. " +
256
- "Install a version matching your @cap-js/telemetry major " +
257
- "(v1 → ^0.57, v2 → ^0.221). Export disabled.",
258
- { error: err.message },
259
- )
260
- return
261
- }
262
-
263
- const routing = new RoutingSpanProcessor({
264
- url: mlflowEndpoint,
265
- headersConfig,
266
- ucTableName,
267
- BatchSpanProcessor,
268
- OTLPTraceExporter,
269
- })
270
-
271
- if (delegate.addSpanProcessor) {
272
- // OTEL SDK v1
273
- delegate.addSpanProcessor(routing)
274
- } else if (delegate._activeSpanProcessor?._spanProcessors) {
275
- // OTEL SDK v2: no addSpanProcessor — push into MultiSpanProcessor
276
- delegate._activeSpanProcessor._spanProcessors.push(routing)
277
- } else {
278
- LOG.warn(
279
- "MLflow: no TracerProvider with addSpanProcessor — ensure @cap-js/telemetry is loaded",
280
- )
281
- return
282
- }
283
- LOG.info("MLflow: routing span processor added (per-experiment export)", {
284
- endpoint: mlflowEndpoint,
285
- ...(ucTableName && { ucTableName }),
286
- })
287
- } catch (err) {
288
- LOG.error("MLflow: failed to configure OTLP export", { error: err.message })
289
- }
290
- }