@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.
- package/README.md +2 -1
- package/cds-plugin.js +2 -2
- package/lib/agents/markdown/backends/outputs-backend.js +16 -12
- package/lib/agents/markdown/backends/readonly-backend.js +48 -0
- package/lib/agents/markdown/backends/uploads-backend.js +30 -13
- package/lib/agents/markdown/deep-agent.js +15 -26
- package/lib/agents/middleware/content-filter.js +3 -2
- package/lib/agents/middleware/index.js +11 -0
- package/lib/agents/middleware/remote-mcp.js +62 -0
- package/lib/agents/middleware/tool-wrap.js +52 -0
- package/lib/compile.js +6 -2
- package/lib/eval/Judge.js +239 -0
- package/lib/eval/eval-describe.js +69 -0
- package/lib/eval/eval-run.js +147 -0
- package/lib/eval/index.js +6 -0
- package/lib/eval/metrics.js +53 -0
- package/lib/eval/span-collector.js +50 -0
- package/lib/index.js +4 -3
- package/lib/models/aicore.js +23 -6
- package/lib/preview/chat.html +394 -69
- package/lib/protocol/agent-card.js +6 -2
- package/lib/protocol/persistence/checkpoint-saver.js +4 -1
- package/lib/protocol/persistence/cleanup.js +84 -0
- package/lib/sidecar.js +1 -1
- package/lib/telemetry/chat-tracing.js +61 -8
- package/lib/telemetry/mlflow/credentials.js +79 -0
- package/lib/telemetry/mlflow/evaluation.js +38 -0
- package/lib/telemetry/mlflow/exporter/DatabricksExporter.js +75 -0
- package/lib/telemetry/mlflow/exporter/MlflowExporter.js +115 -0
- package/lib/telemetry/mlflow/exporter/index.js +23 -0
- package/lib/telemetry/mlflow/index.js +16 -0
- package/lib/telemetry/mlflow/prompts.js +123 -0
- package/lib/telemetry/mlflow/tracing.js +264 -0
- package/lib/telemetry/tool-tracing.js +27 -54
- package/lib/telemetry/tracing.js +3 -1
- package/lib/utils/markdown.js +1 -11
- package/lib/utils/resilience.js +133 -0
- package/lib/utils/utils.js +17 -0
- package/package.json +24 -15
- package/{index.cds → srv/entities.cds} +11 -0
- package/srv/handlers/chat.js +278 -0
- package/srv/handlers/graph-executor.js +48 -37
- package/srv/handlers/index.js +8 -0
- package/srv/handlers/mcp-tools.js +11 -47
- package/srv/handlers/sub-agent-tools.js +12 -3
- package/srv/handlers/tools.js +9 -5
- package/index.js +0 -0
- 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.
|
|
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
|
|
82
|
-
* Resolves the destination URL and auth
|
|
83
|
-
*
|
|
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<
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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?
|
package/srv/handlers/tools.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
package/lib/telemetry/mlflow.js
DELETED
|
@@ -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
|
-
}
|