@dbx-tools/appkit-mastra 0.9.58 → 0.9.61
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 +17 -5
- package/index.ts +2 -2
- package/lib/index.d.ts +2 -2
- package/lib/index.js +3 -3
- package/lib/src/config.d.ts +4 -3
- package/lib/src/config.js +2 -2
- package/lib/src/genie.js +3 -1
- package/lib/src/memory.js +3 -2
- package/lib/src/mlflow.d.ts +40 -4
- package/lib/src/mlflow.js +249 -8
- package/lib/src/plugin.d.ts +7 -0
- package/lib/src/plugin.js +28 -2
- package/lib/src/remote-skills.js +2 -2
- package/lib/src/server.js +3 -1
- package/lib/src/telemetry.d.ts +41 -0
- package/lib/src/telemetry.js +89 -4
- package/lib/src/validation.d.ts +0 -11
- package/lib/src/validation.js +9 -3
- package/package.json +14 -13
- package/src/config.ts +5 -4
- package/src/genie.ts +2 -0
- package/src/memory.ts +2 -1
- package/src/mlflow.ts +289 -7
- package/src/plugin.ts +34 -1
- package/src/remote-skills.ts +1 -1
- package/src/server.ts +1 -0
- package/src/telemetry.ts +90 -2
- package/src/validation.ts +8 -2
package/src/mlflow.ts
CHANGED
|
@@ -13,17 +13,23 @@
|
|
|
13
13
|
*
|
|
14
14
|
* There is no MLflow JS SDK, so this posts to the assessments REST
|
|
15
15
|
* endpoint directly using the OBO-scoped workspace client (the feedback
|
|
16
|
-
* is thus attributed to the signed-in user).
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* is thus attributed to the signed-in user). UC-backed Apps traces need
|
|
17
|
+
* the experiment's table-prefix URI (`trace:/{catalog}.{schema}.{prefix}/{tr-hex}`)
|
|
18
|
+
* rather than the experiment-store id the UI does not store. That prefix is
|
|
19
|
+
* read from the experiment's `mlflow.experiment.databricksTraceDestinationPath`
|
|
20
|
+
* tag (or the spans-table tag); `MLFLOW_UC_TRACE_PREFIX` remains an optional
|
|
21
|
+
* override. Trace export is asynchronous, so the just-finished trace may not
|
|
22
|
+
* exist in MLflow yet when the user reacts; the log call retries briefly on
|
|
23
|
+
* "not found" before giving up softly.
|
|
20
24
|
*
|
|
21
25
|
* @module
|
|
22
26
|
*/
|
|
23
27
|
|
|
28
|
+
import { ConfigurationError } from "@databricks/appkit";
|
|
24
29
|
import { appkit } from "@dbx-tools/appkit";
|
|
25
|
-
import { asyncUtils, errorUtils, log } from "@dbx-tools/shared-core";
|
|
30
|
+
import { asyncUtils, errorUtils, log, object } from "@dbx-tools/shared-core";
|
|
26
31
|
import { feedback } from "@dbx-tools/shared-mastra";
|
|
32
|
+
import { z } from "zod";
|
|
27
33
|
import { databricksFetch, readResponseJson, readResponseText } from "./rest.ts";
|
|
28
34
|
|
|
29
35
|
const logger = log.logger("mastra/mlflow");
|
|
@@ -31,6 +37,98 @@ const logger = log.logger("mastra/mlflow");
|
|
|
31
37
|
/** Workspace client carried on an AppKit execution context. */
|
|
32
38
|
type WorkspaceClient = appkit.WorkspaceClientLike;
|
|
33
39
|
|
|
40
|
+
/** Experiment tag Databricks stores as `catalog.schema.table_prefix`. */
|
|
41
|
+
const TRACE_DESTINATION_PATH_TAG = "mlflow.experiment.databricksTraceDestinationPath";
|
|
42
|
+
/** Experiment tag naming the OTel spans table (`catalog.schema.{prefix}_otel_spans`). */
|
|
43
|
+
const TRACE_SPAN_STORAGE_TABLE_TAG = "mlflow.experiment.databricksTraceSpanStorageTable";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Cached UC table prefix resolved from the experiment. `loaded` distinguishes
|
|
47
|
+
* "not fetched yet" from "fetched, experiment has no UC location". Network
|
|
48
|
+
* failures are not cached so a later thumbs click can retry.
|
|
49
|
+
*/
|
|
50
|
+
let loadedUcPrefix: { prefix: string | undefined } | undefined;
|
|
51
|
+
|
|
52
|
+
/** Strip the `trace:/` URI wrapper and trailing slashes from a UC prefix. */
|
|
53
|
+
function normalizeUcPrefix(raw: string | undefined): string | undefined {
|
|
54
|
+
const value = raw?.trim().replace(/^trace:\//, "").replace(/\/+$/, "");
|
|
55
|
+
return value || undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Derive `catalog.schema.table_prefix` from a spans table name.
|
|
60
|
+
* Databricks names the table `{prefix}_otel_spans`.
|
|
61
|
+
*/
|
|
62
|
+
function prefixFromSpanTable(table: string | undefined): string | undefined {
|
|
63
|
+
const name = table?.trim();
|
|
64
|
+
if (!name) return undefined;
|
|
65
|
+
if (name.endsWith("_otel_spans")) return name.slice(0, -"_otel_spans".length);
|
|
66
|
+
if (name.endsWith("_spans")) return name.slice(0, -"_spans".length);
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function experimentTagMap(raw: unknown): Map<string, string> {
|
|
71
|
+
const tags = new Map<string, string>();
|
|
72
|
+
if (Array.isArray(raw)) {
|
|
73
|
+
for (const tag of raw) {
|
|
74
|
+
if (!object.isRecord(tag) || typeof tag.key !== "string" || typeof tag.value !== "string") {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
tags.set(tag.key, tag.value);
|
|
78
|
+
}
|
|
79
|
+
return tags;
|
|
80
|
+
}
|
|
81
|
+
if (!object.isRecord(raw)) return tags;
|
|
82
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
83
|
+
if (typeof value === "string") tags.set(key, value);
|
|
84
|
+
}
|
|
85
|
+
return tags;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* UC table prefix the assessments API needs, read from experiment tags.
|
|
90
|
+
*
|
|
91
|
+
* Prefers the three-part destination path Databricks writes when the
|
|
92
|
+
* experiment is linked to a table-prefix location. Falls back to stripping
|
|
93
|
+
* `_otel_spans` from the spans-table tag. Two-part schema-linked destinations
|
|
94
|
+
* are ignored (they are not a table prefix).
|
|
95
|
+
*/
|
|
96
|
+
export function ucTracePrefixFromExperimentTags(rawTags: unknown): string | undefined {
|
|
97
|
+
const tags = experimentTagMap(rawTags);
|
|
98
|
+
const destination = normalizeUcPrefix(tags.get(TRACE_DESTINATION_PATH_TAG));
|
|
99
|
+
if (destination && destination.split(".").length >= 3) return destination;
|
|
100
|
+
return prefixFromSpanTable(tags.get(TRACE_SPAN_STORAGE_TABLE_TAG));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Drop the in-process UC prefix cache (tests). */
|
|
104
|
+
export function resetUcTracePrefixCache(): void {
|
|
105
|
+
loadedUcPrefix = undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Trace id the assessments REST API accepts for this deployment.
|
|
110
|
+
*
|
|
111
|
+
* Databricks Apps persist traces in Unity Catalog. The experiment UI lists
|
|
112
|
+
* those rows, but `POST /api/3.0/mlflow/traces/{id}/assessments` looks up the
|
|
113
|
+
* experiment store unless `{id}` is the UC URI `trace:/{catalog}.{schema}.{prefix}/{tr-hex}`.
|
|
114
|
+
* A bare `tr-<hex>` 404s and the assessment never appears.
|
|
115
|
+
*/
|
|
116
|
+
export function mlflowAssessmentTraceId(traceId: string, prefix?: string): string {
|
|
117
|
+
const resolved = normalizeUcPrefix(prefix);
|
|
118
|
+
return resolved ? `trace:/${resolved}/${traceId}` : traceId;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function resolveUcTracePrefix(client: WorkspaceClient): Promise<string | undefined> {
|
|
122
|
+
const fromEnv = normalizeUcPrefix(process.env.MLFLOW_UC_TRACE_PREFIX);
|
|
123
|
+
if (fromEnv) return fromEnv;
|
|
124
|
+
if (loadedUcPrefix) return loadedUcPrefix.prefix;
|
|
125
|
+
const experiment = await fetchExperiment(client);
|
|
126
|
+
if (!experiment) return undefined;
|
|
127
|
+
const prefix = ucTracePrefixFromExperimentTags(experiment.tags);
|
|
128
|
+
loadedUcPrefix = { prefix };
|
|
129
|
+
return prefix;
|
|
130
|
+
}
|
|
131
|
+
|
|
34
132
|
/** Assessments REST path for a trace. `3.0` is the current MLflow API version. */
|
|
35
133
|
const assessmentsPath = (traceId: string): string =>
|
|
36
134
|
`/api/3.0/mlflow/traces/${encodeURIComponent(traceId)}/assessments`;
|
|
@@ -40,6 +138,79 @@ const NOT_FOUND_RETRIES = 3;
|
|
|
40
138
|
/** Base backoff between "trace not found" retries, in ms (grows linearly). */
|
|
41
139
|
const NOT_FOUND_BACKOFF_MS = 1200;
|
|
42
140
|
|
|
141
|
+
const MlflowExperimentResponseSchema = z
|
|
142
|
+
.object({
|
|
143
|
+
experiment: z
|
|
144
|
+
.object({
|
|
145
|
+
experiment_id: z.string().min(1).describe("Workspace MLflow experiment identifier."),
|
|
146
|
+
tags: z.unknown().optional().describe("Experiment tags, including the UC trace location."),
|
|
147
|
+
})
|
|
148
|
+
.passthrough()
|
|
149
|
+
.describe("Configured MLflow experiment."),
|
|
150
|
+
})
|
|
151
|
+
.passthrough()
|
|
152
|
+
.describe("MLflow get-experiment response.");
|
|
153
|
+
|
|
154
|
+
const CurrentUserResponseSchema = z
|
|
155
|
+
.object({
|
|
156
|
+
userName: z.string().optional().describe("Current workspace user name."),
|
|
157
|
+
applicationId: z.string().optional().describe("Current service principal application id."),
|
|
158
|
+
displayName: z.string().optional().describe("Current principal display name."),
|
|
159
|
+
groups: z
|
|
160
|
+
.array(
|
|
161
|
+
z
|
|
162
|
+
.object({
|
|
163
|
+
value: z.string().optional().describe("Workspace group identifier."),
|
|
164
|
+
display: z.string().optional().describe("Workspace group display name."),
|
|
165
|
+
})
|
|
166
|
+
.passthrough(),
|
|
167
|
+
)
|
|
168
|
+
.optional()
|
|
169
|
+
.describe("Groups assigned to the current principal."),
|
|
170
|
+
})
|
|
171
|
+
.passthrough()
|
|
172
|
+
.describe("Databricks Current User API response.");
|
|
173
|
+
|
|
174
|
+
const ExperimentPermissionsResponseSchema = z
|
|
175
|
+
.object({
|
|
176
|
+
access_control_list: z
|
|
177
|
+
.array(
|
|
178
|
+
z
|
|
179
|
+
.object({
|
|
180
|
+
user_name: z.string().optional().describe("User principal name."),
|
|
181
|
+
group_name: z.string().optional().describe("Group principal name."),
|
|
182
|
+
service_principal_name: z
|
|
183
|
+
.string()
|
|
184
|
+
.optional()
|
|
185
|
+
.describe("Service principal application id."),
|
|
186
|
+
all_permissions: z
|
|
187
|
+
.array(
|
|
188
|
+
z
|
|
189
|
+
.object({
|
|
190
|
+
permission_level: z.string().describe("Effective experiment permission level."),
|
|
191
|
+
})
|
|
192
|
+
.passthrough(),
|
|
193
|
+
)
|
|
194
|
+
.optional()
|
|
195
|
+
.describe("Direct and inherited permissions for this principal."),
|
|
196
|
+
})
|
|
197
|
+
.passthrough(),
|
|
198
|
+
)
|
|
199
|
+
.optional()
|
|
200
|
+
.describe("Experiment access control entries."),
|
|
201
|
+
})
|
|
202
|
+
.passthrough()
|
|
203
|
+
.describe("Databricks experiment permissions response.");
|
|
204
|
+
|
|
205
|
+
function configuredExperiment(): { id?: string; name?: string } {
|
|
206
|
+
const id = process.env.MLFLOW_EXPERIMENT_ID?.trim();
|
|
207
|
+
const name = process.env.MLFLOW_EXPERIMENT_NAME?.trim();
|
|
208
|
+
return {
|
|
209
|
+
...(id ? { id } : {}),
|
|
210
|
+
...(name ? { name } : {}),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
43
214
|
/**
|
|
44
215
|
* Whether MLflow feedback logging is available for this deployment.
|
|
45
216
|
*
|
|
@@ -71,6 +242,111 @@ export function resolveFeedbackEnabled(explicit: boolean | undefined): boolean {
|
|
|
71
242
|
return explicit ?? mlflowEnabled();
|
|
72
243
|
}
|
|
73
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Reject an explicitly enabled feedback surface when no MLflow experiment is
|
|
247
|
+
* configured. Auto mode remains non-fatal and simply disables feedback.
|
|
248
|
+
*/
|
|
249
|
+
export function validateFeedbackConfig(explicit: boolean | undefined): void {
|
|
250
|
+
if (explicit !== true) return;
|
|
251
|
+
const experiment = configuredExperiment();
|
|
252
|
+
if (experiment.id || experiment.name) return;
|
|
253
|
+
throw new ConfigurationError(
|
|
254
|
+
"mastra: feedback is enabled but no MLflow experiment is configured. " +
|
|
255
|
+
"Set MLFLOW_EXPERIMENT_ID or MLFLOW_EXPERIMENT_NAME, use feedback: false, " +
|
|
256
|
+
"or omit feedback to use automatic detection.",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Return the configured experiment URL only when the active Databricks
|
|
262
|
+
* principal has effective `CAN_MANAGE` permission. Permission lookup is
|
|
263
|
+
* intentionally best-effort so Apps environments that do not expose the
|
|
264
|
+
* required APIs simply omit the debug affordance.
|
|
265
|
+
*/
|
|
266
|
+
export async function mlflowExperimentManagerUrl(
|
|
267
|
+
client: WorkspaceClient,
|
|
268
|
+
): Promise<string | undefined> {
|
|
269
|
+
try {
|
|
270
|
+
return await resolveMlflowExperimentManagerUrl(client);
|
|
271
|
+
} catch {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function resolveMlflowExperimentManagerUrl(
|
|
277
|
+
client: WorkspaceClient,
|
|
278
|
+
): Promise<string | undefined> {
|
|
279
|
+
const experimentId = await resolveExperimentId(client);
|
|
280
|
+
if (!experimentId) return undefined;
|
|
281
|
+
|
|
282
|
+
const [currentResponse, permissionsResponse] = await Promise.all([
|
|
283
|
+
databricksFetch(client, "/api/2.0/preview/scim/v2/Me", { method: "GET" }),
|
|
284
|
+
databricksFetch(
|
|
285
|
+
client,
|
|
286
|
+
`/api/2.0/permissions/experiments/${encodeURIComponent(experimentId)}`,
|
|
287
|
+
{ method: "GET" },
|
|
288
|
+
),
|
|
289
|
+
]);
|
|
290
|
+
if (!currentResponse.ok || !permissionsResponse.ok) return undefined;
|
|
291
|
+
|
|
292
|
+
const current = CurrentUserResponseSchema.safeParse(await readResponseJson(currentResponse));
|
|
293
|
+
const permissions = ExperimentPermissionsResponseSchema.safeParse(
|
|
294
|
+
await readResponseJson(permissionsResponse),
|
|
295
|
+
);
|
|
296
|
+
if (!current.success || !permissions.success) return undefined;
|
|
297
|
+
|
|
298
|
+
const userNames = new Set(
|
|
299
|
+
[current.data.userName, current.data.applicationId, current.data.displayName].filter(
|
|
300
|
+
(value): value is string => Boolean(value),
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
const groupNames = new Set(
|
|
304
|
+
(current.data.groups ?? []).flatMap(({ value, display }) =>
|
|
305
|
+
[value, display].filter((entry): entry is string => Boolean(entry)),
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
const canManage = (permissions.data.access_control_list ?? []).some((entry) => {
|
|
309
|
+
const matches =
|
|
310
|
+
(entry.user_name !== undefined && userNames.has(entry.user_name)) ||
|
|
311
|
+
(entry.service_principal_name !== undefined && userNames.has(entry.service_principal_name)) ||
|
|
312
|
+
(entry.group_name !== undefined && groupNames.has(entry.group_name));
|
|
313
|
+
return (
|
|
314
|
+
matches &&
|
|
315
|
+
(entry.all_permissions ?? []).some(
|
|
316
|
+
(permission) => permission.permission_level === "CAN_MANAGE",
|
|
317
|
+
)
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
if (!canManage) return undefined;
|
|
321
|
+
|
|
322
|
+
const host = (await client.config.getHost()).toString();
|
|
323
|
+
return new URL(`/ml/experiments/${encodeURIComponent(experimentId)}`, host).toString();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function fetchExperiment(
|
|
327
|
+
client: WorkspaceClient,
|
|
328
|
+
): Promise<{ experiment_id: string; tags?: unknown } | undefined> {
|
|
329
|
+
const experiment = configuredExperiment();
|
|
330
|
+
const path = experiment.id
|
|
331
|
+
? `/api/2.0/mlflow/experiments/get?experiment_id=${encodeURIComponent(experiment.id)}`
|
|
332
|
+
: experiment.name
|
|
333
|
+
? `/api/2.0/mlflow/experiments/get-by-name?experiment_name=${encodeURIComponent(
|
|
334
|
+
experiment.name,
|
|
335
|
+
)}`
|
|
336
|
+
: undefined;
|
|
337
|
+
if (!path) return undefined;
|
|
338
|
+
const response = await databricksFetch(client, path, { method: "GET" });
|
|
339
|
+
if (!response.ok) return undefined;
|
|
340
|
+
const parsed = MlflowExperimentResponseSchema.safeParse(await readResponseJson(response));
|
|
341
|
+
return parsed.success ? parsed.data.experiment : undefined;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function resolveExperimentId(client: WorkspaceClient): Promise<string | undefined> {
|
|
345
|
+
const configured = configuredExperiment();
|
|
346
|
+
if (configured.id) return configured.id;
|
|
347
|
+
return (await fetchExperiment(client))?.experiment_id;
|
|
348
|
+
}
|
|
349
|
+
|
|
74
350
|
/** Parameters for {@link logFeedback}. */
|
|
75
351
|
export interface LogFeedbackParams {
|
|
76
352
|
/** MLflow trace id the assessment attaches to (`tr-<hex>`). */
|
|
@@ -103,8 +379,12 @@ export async function logFeedback(
|
|
|
103
379
|
params.name?.trim() ||
|
|
104
380
|
(hasValue ? feedback.DEFAULT_FEEDBACK_NAME : feedback.DEFAULT_COMMENT_NAME);
|
|
105
381
|
const value = hasValue ? params.value : params.comment;
|
|
382
|
+
const assessmentTraceId = mlflowAssessmentTraceId(
|
|
383
|
+
params.traceId,
|
|
384
|
+
await resolveUcTracePrefix(client),
|
|
385
|
+
);
|
|
106
386
|
const assessment: Record<string, unknown> = {
|
|
107
|
-
trace_id:
|
|
387
|
+
trace_id: assessmentTraceId,
|
|
108
388
|
assessment_name: name,
|
|
109
389
|
source: {
|
|
110
390
|
source_type: "HUMAN",
|
|
@@ -118,13 +398,14 @@ export async function logFeedback(
|
|
|
118
398
|
for (let attempt = 0; attempt <= NOT_FOUND_RETRIES; attempt++) {
|
|
119
399
|
let res: Response;
|
|
120
400
|
try {
|
|
121
|
-
res = await databricksFetch(client, assessmentsPath(
|
|
401
|
+
res = await databricksFetch(client, assessmentsPath(assessmentTraceId), {
|
|
122
402
|
method: "POST",
|
|
123
403
|
body,
|
|
124
404
|
});
|
|
125
405
|
} catch (err) {
|
|
126
406
|
logger.warn("feedback request failed", {
|
|
127
407
|
traceId: params.traceId,
|
|
408
|
+
assessmentTraceId,
|
|
128
409
|
error: errorUtils.errorMessage(err),
|
|
129
410
|
});
|
|
130
411
|
return undefined;
|
|
@@ -144,6 +425,7 @@ export async function logFeedback(
|
|
|
144
425
|
}
|
|
145
426
|
logger.warn("feedback not recorded", {
|
|
146
427
|
traceId: params.traceId,
|
|
428
|
+
assessmentTraceId,
|
|
147
429
|
status: res.status,
|
|
148
430
|
body: await readResponseText(res),
|
|
149
431
|
});
|
package/src/plugin.ts
CHANGED
|
@@ -99,7 +99,12 @@ import {
|
|
|
99
99
|
} from "./identity.ts";
|
|
100
100
|
import { buildMcpServer, type ResolvedMcp } from "./mcp.ts";
|
|
101
101
|
import { createMemoryBuilder, createServicePrincipalPool, needsLakebase } from "./memory.ts";
|
|
102
|
-
import {
|
|
102
|
+
import {
|
|
103
|
+
logFeedback,
|
|
104
|
+
mlflowExperimentManagerUrl,
|
|
105
|
+
resolveFeedbackEnabled,
|
|
106
|
+
validateFeedbackConfig,
|
|
107
|
+
} from "./mlflow.ts";
|
|
103
108
|
import { resolveDefaultModelId } from "./model.ts";
|
|
104
109
|
import { buildObservability, configureOtelPropagation } from "./observability.ts";
|
|
105
110
|
import { provisionRemoteSkills } from "./remote-skills.ts";
|
|
@@ -249,6 +254,7 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
|
|
|
249
254
|
// Resolve the identity mode up front so an invalid `genieIdentity` fails the
|
|
250
255
|
// app boot with a clear message rather than 500-ing the first chat request.
|
|
251
256
|
this.identityMode = resolveIdentityMode(this.config.genieIdentity);
|
|
257
|
+
validateFeedbackConfig(this.config.feedback);
|
|
252
258
|
// Wait until sibling plugins (e.g. `lakebase`) finish `setup()` so
|
|
253
259
|
// the lakebase pool is valid when storage/memory are enabled.
|
|
254
260
|
this.context?.onLifecycle("setup:complete", async () => {
|
|
@@ -466,6 +472,7 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
|
|
|
466
472
|
this.registerEmbedRoutes(router);
|
|
467
473
|
this.registerSuggestionRoutes(router);
|
|
468
474
|
this.registerFeedbackRoutes(router);
|
|
475
|
+
this.registerMlflowExperimentRoute(router);
|
|
469
476
|
this.registerAgentRoutes(router);
|
|
470
477
|
}
|
|
471
478
|
|
|
@@ -770,6 +777,23 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
|
|
|
770
777
|
});
|
|
771
778
|
}
|
|
772
779
|
|
|
780
|
+
/** Register the best-effort per-viewer MLflow experiment debug link. */
|
|
781
|
+
private registerMlflowExperimentRoute(router: IAppRouter): void {
|
|
782
|
+
this.route(router, {
|
|
783
|
+
name: "mlflowExperiment",
|
|
784
|
+
method: "get",
|
|
785
|
+
path: routes.MASTRA_ROUTES.mlflowExperiment,
|
|
786
|
+
handler: async (req, res) => {
|
|
787
|
+
if (!this.feedbackEnabled()) {
|
|
788
|
+
res.json({ url: null });
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
const result = await this.scopedSelf(req).fetchMlflowExperimentUrl();
|
|
792
|
+
res.json({ url: result.ok ? (result.data ?? null) : null });
|
|
793
|
+
},
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
|
|
773
797
|
/** Register the gated Mastra catch-all after every explicit route. */
|
|
774
798
|
private registerAgentRoutes(router: IAppRouter): void {
|
|
775
799
|
// Middleware rather than `this.route`: this is the catch-all that hands
|
|
@@ -917,6 +941,15 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
|
|
|
917
941
|
);
|
|
918
942
|
}
|
|
919
943
|
|
|
944
|
+
/**
|
|
945
|
+
* Resolve an experiment link under the active request identity. The helper
|
|
946
|
+
* returns no URL unless the viewer has effective `CAN_MANAGE` permission.
|
|
947
|
+
*/
|
|
948
|
+
private async fetchMlflowExperimentUrl(): Promise<ExecutionResult<string | undefined>> {
|
|
949
|
+
const client = getExecutionContext().client;
|
|
950
|
+
return this.execute(() => mlflowExperimentManagerUrl(client), feedbackWriteDefaults);
|
|
951
|
+
}
|
|
952
|
+
|
|
920
953
|
/**
|
|
921
954
|
* Implementation backing the `data` embed resolver
|
|
922
955
|
* (`GET /embed/data/:id`). Runs inside the AppKit user-context proxy so
|
package/src/remote-skills.ts
CHANGED
|
@@ -567,7 +567,7 @@ function resolveDatabricksBasePath(
|
|
|
567
567
|
client: WorkspaceClientLike | undefined,
|
|
568
568
|
): string | undefined {
|
|
569
569
|
if (!client) return undefined;
|
|
570
|
-
if (options.databricksBasePath) return options.databricksBasePath
|
|
570
|
+
if (options.databricksBasePath) return stringUtils.trimToUndefined(options.databricksBasePath);
|
|
571
571
|
const email = stringUtils.trimToNull(options.userEmail);
|
|
572
572
|
// A named user targets their personal Assistant tree (the "save a skill"
|
|
573
573
|
// target); otherwise the shared workspace Assistant tree, which the built-in
|
package/src/server.ts
CHANGED
|
@@ -276,6 +276,7 @@ export class MastraServer extends ExpressMastraServer {
|
|
|
276
276
|
*/
|
|
277
277
|
configureMlflowTraceId(res: express.Response) {
|
|
278
278
|
if (!this.feedbackEnabled || res.headersSent) return;
|
|
279
|
+
if (res.getHeader(feedback.MLFLOW_TRACE_ID_HEADER)) return;
|
|
279
280
|
const traceId = trace.getActiveSpan()?.spanContext().traceId;
|
|
280
281
|
if (!traceId || traceId === INVALID_TRACE_ID) return;
|
|
281
282
|
res.setHeader(feedback.MLFLOW_TRACE_ID_HEADER, `tr-${traceId}`);
|
package/src/telemetry.ts
CHANGED
|
@@ -20,11 +20,14 @@
|
|
|
20
20
|
|
|
21
21
|
import { StringDecoder } from "node:string_decoder";
|
|
22
22
|
|
|
23
|
-
import { json, log, object } from "@dbx-tools/shared-core";
|
|
23
|
+
import { json, log, object, stringUtils } from "@dbx-tools/shared-core";
|
|
24
|
+
import { feedback, thread } from "@dbx-tools/shared-mastra";
|
|
24
25
|
import { context, SpanKind, trace, type Span } from "@opentelemetry/api";
|
|
25
26
|
import { getRPCMetadata, RPCType } from "@opentelemetry/core";
|
|
26
27
|
import type express from "express";
|
|
27
28
|
|
|
29
|
+
import { USER_EMAIL_HEADER, USER_ID_HEADER } from "./identity.ts";
|
|
30
|
+
|
|
28
31
|
const logger = log.logger("mastra/telemetry");
|
|
29
32
|
|
|
30
33
|
/** Cap on each payload copied onto a span, so one turn cannot bloat the export. */
|
|
@@ -56,6 +59,34 @@ export const CHAT_RESPONSE_ATTR = "appkit.mastra.chat.response";
|
|
|
56
59
|
export const CHAT_IDENTITY_ATTR = "appkit.mastra.identity.mode";
|
|
57
60
|
/** Whether the streamed turn emitted a Genie tool or progress event. */
|
|
58
61
|
export const CHAT_GENIE_USED_ATTR = "appkit.mastra.genie.used";
|
|
62
|
+
/**
|
|
63
|
+
* MLflow span-type attribute. Databricks maps this (or `gen_ai.operation.name`)
|
|
64
|
+
* onto the type chip in the experiment UI. Custom values such as `GENIE` are
|
|
65
|
+
* allowed.
|
|
66
|
+
*/
|
|
67
|
+
export const MLFLOW_SPAN_TYPE_ATTR = "mlflow.spanType";
|
|
68
|
+
/** GenAI semantic-convention operation name, an alternate span-type source. */
|
|
69
|
+
export const GEN_AI_OPERATION_NAME_ATTR = "gen_ai.operation.name";
|
|
70
|
+
/**
|
|
71
|
+
* Root-span user id the MLflow experiment UI's User column reads from OTel
|
|
72
|
+
* traces. Prefer the forwarded email when present so the column is human.
|
|
73
|
+
*/
|
|
74
|
+
export const MLFLOW_USER_ATTR = "user.id";
|
|
75
|
+
/** Root-span session id that enables the MLflow Sessions tab. */
|
|
76
|
+
export const MLFLOW_SESSION_ATTR = "session.id";
|
|
77
|
+
/**
|
|
78
|
+
* Prefix Databricks/MLflow copies onto the unified-view `tags` map. The
|
|
79
|
+
* suffix after this prefix is the tag name shown in the experiment UI.
|
|
80
|
+
*/
|
|
81
|
+
export const MLFLOW_TRACE_TAG_PREFIX = "mlflow.trace.tag.";
|
|
82
|
+
/** Experiment-UI tag marking a turn that called Genie. */
|
|
83
|
+
export const MLFLOW_GENIE_TAG = "genie";
|
|
84
|
+
/** Root-span type for a Mastra agent turn. */
|
|
85
|
+
export const MLFLOW_SPAN_TYPE_AGENT = "AGENT";
|
|
86
|
+
/** Span type for an `ask_genie` tool call. */
|
|
87
|
+
export const MLFLOW_SPAN_TYPE_GENIE = "GENIE";
|
|
88
|
+
/** Attribute key for the `genie` trace tag. */
|
|
89
|
+
export const MLFLOW_GENIE_TAG_ATTR = `${MLFLOW_TRACE_TAG_PREFIX}${MLFLOW_GENIE_TAG}`;
|
|
59
90
|
|
|
60
91
|
/** Root-span metadata known before a chat request is dispatched. */
|
|
61
92
|
export interface ChatTurnTelemetryOptions {
|
|
@@ -68,6 +99,57 @@ interface TraceTarget {
|
|
|
68
99
|
owned: boolean;
|
|
69
100
|
}
|
|
70
101
|
|
|
102
|
+
/** OpenTelemetry's sentinel for "no valid trace" - 32 zero hex chars. */
|
|
103
|
+
const INVALID_TRACE_ID = "0".repeat(32);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Publish the turn's MLflow trace id before the handler writes the body.
|
|
107
|
+
*
|
|
108
|
+
* The chat UI only shows thumbs when this header is present. Stamping it here
|
|
109
|
+
* (on the same span the middleware just resolved) avoids relying on async
|
|
110
|
+
* Express continuation still sitting inside `context.with()`.
|
|
111
|
+
*/
|
|
112
|
+
/**
|
|
113
|
+
* Mark the active span as a Genie call so the experiment UI types it `GENIE`
|
|
114
|
+
* and tags the trace `genie=true`. Safe no-op when no recording span is active.
|
|
115
|
+
*/
|
|
116
|
+
export function stampGenieToolSpan(span: Span | undefined = trace.getActiveSpan()): void {
|
|
117
|
+
if (!span?.isRecording()) return;
|
|
118
|
+
span.setAttribute(MLFLOW_SPAN_TYPE_ATTR, MLFLOW_SPAN_TYPE_GENIE);
|
|
119
|
+
span.setAttribute(GEN_AI_OPERATION_NAME_ATTR, "execute_tool");
|
|
120
|
+
span.setAttribute(MLFLOW_GENIE_TAG_ATTR, "true");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function requestHeader(req: express.Request, name: string): string | undefined {
|
|
124
|
+
const fromMethod =
|
|
125
|
+
typeof req.header === "function" ? stringUtils.trimToNull(req.header(name)) : undefined;
|
|
126
|
+
if (fromMethod) return fromMethod;
|
|
127
|
+
const raw = req.headers?.[name.toLowerCase()];
|
|
128
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
129
|
+
return stringUtils.trimToNull(typeof value === "string" ? value : undefined) ?? undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function requestQuery(req: express.Request, name: string): string | undefined {
|
|
133
|
+
const raw = req.query?.[name];
|
|
134
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
135
|
+
return stringUtils.trimToNull(typeof value === "string" ? value : undefined) ?? undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function stampMlflowActor(span: Span, req: express.Request): void {
|
|
139
|
+
const user = requestHeader(req, USER_EMAIL_HEADER) ?? requestHeader(req, USER_ID_HEADER);
|
|
140
|
+
if (user) span.setAttribute(MLFLOW_USER_ATTR, user);
|
|
141
|
+
const session =
|
|
142
|
+
requestHeader(req, thread.THREAD_ID_HEADER) ?? requestQuery(req, thread.THREAD_ID_QUERY);
|
|
143
|
+
if (session) span.setAttribute(MLFLOW_SESSION_ATTR, session);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function stampMlflowTraceId(res: express.Response, span: Span): void {
|
|
147
|
+
if (res.headersSent) return;
|
|
148
|
+
const traceId = span.spanContext().traceId;
|
|
149
|
+
if (!traceId || traceId === INVALID_TRACE_ID) return;
|
|
150
|
+
res.setHeader(feedback.MLFLOW_TRACE_ID_HEADER, `tr-${traceId}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
71
153
|
/** Return one raw prompt when the request is exactly one text-only user message. */
|
|
72
154
|
export function textOnlyChatInput(messages: unknown): string | undefined {
|
|
73
155
|
if (typeof messages === "string") return messages || undefined;
|
|
@@ -276,6 +358,10 @@ export function chatTurnTelemetryMiddleware(
|
|
|
276
358
|
next();
|
|
277
359
|
return;
|
|
278
360
|
}
|
|
361
|
+
stampMlflowTraceId(res, target.span);
|
|
362
|
+
stampMlflowActor(target.span, req);
|
|
363
|
+
target.span.setAttribute(MLFLOW_SPAN_TYPE_ATTR, MLFLOW_SPAN_TYPE_AGENT);
|
|
364
|
+
target.span.setAttribute(GEN_AI_OPERATION_NAME_ATTR, "invoke_agent");
|
|
279
365
|
const identity =
|
|
280
366
|
typeof options.identity === "function" ? options.identity(req) : options.identity;
|
|
281
367
|
if (identity) {
|
|
@@ -311,7 +397,9 @@ export function chatTurnTelemetryMiddleware(
|
|
|
311
397
|
fullOutputRecorded = true;
|
|
312
398
|
};
|
|
313
399
|
const recordGenie = (used: boolean): void => {
|
|
314
|
-
if (used)
|
|
400
|
+
if (!used) return;
|
|
401
|
+
target.span.setAttribute(CHAT_GENIE_USED_ATTR, true);
|
|
402
|
+
target.span.setAttribute(MLFLOW_GENIE_TAG_ATTR, "true");
|
|
315
403
|
};
|
|
316
404
|
const endOwnedSpan = (): void => {
|
|
317
405
|
if (!target.owned || ownedSpanEnded) return;
|
package/src/validation.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* @module
|
|
11
11
|
*/
|
|
12
|
+
import { object } from "@dbx-tools/shared-core";
|
|
12
13
|
|
|
13
14
|
/** The subset of a failed parse result these helpers read. */
|
|
14
15
|
export interface SchemaIssues {
|
|
@@ -17,6 +18,11 @@ export interface SchemaIssues {
|
|
|
17
18
|
|
|
18
19
|
/** Distinct dot-joined field paths a failed parse flagged. */
|
|
19
20
|
export function invalidFields(error: SchemaIssues): string[] {
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
return [
|
|
22
|
+
...object
|
|
23
|
+
.sequence(error.issues)
|
|
24
|
+
.map((issue) => issue.path.map(String).join("."))
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.distinct(),
|
|
27
|
+
];
|
|
22
28
|
}
|