@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/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). Trace export is
17
- * asynchronous, so the just-finished trace may not exist in MLflow yet
18
- * when the user reacts; the log call retries briefly on "not found"
19
- * before giving up softly.
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: params.traceId,
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(params.traceId), {
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 { logFeedback, resolveFeedbackEnabled } from "./mlflow.ts";
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
@@ -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.trim() || undefined;
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) target.span.setAttribute(CHAT_GENIE_USED_ATTR, true);
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
- const fields = error.issues.map((issue) => issue.path.map(String).join(".")).filter(Boolean);
21
- return [...new Set(fields)];
21
+ return [
22
+ ...object
23
+ .sequence(error.issues)
24
+ .map((issue) => issue.path.map(String).join("."))
25
+ .filter(Boolean)
26
+ .distinct(),
27
+ ];
22
28
  }