@dbx-tools/appkit-mastra 0.9.65 → 0.9.68

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/telemetry.ts CHANGED
@@ -20,13 +20,48 @@
20
20
 
21
21
  import { StringDecoder } from "node:string_decoder";
22
22
 
23
+ import { appkit } from "@dbx-tools/appkit";
24
+ import { getRequestTags } from "@dbx-tools/appkit/request-tags";
23
25
  import { json, log, object, stringUtils } from "@dbx-tools/shared-core";
24
26
  import { feedback, thread } from "@dbx-tools/shared-mastra";
25
- import { context, SpanKind, trace, type Span } from "@opentelemetry/api";
27
+ import { context, createContextKey, SpanKind, trace, type Span } from "@opentelemetry/api";
26
28
  import { getRPCMetadata, RPCType } from "@opentelemetry/core";
27
29
  import type express from "express";
28
30
 
29
31
  import { USER_EMAIL_HEADER, USER_ID_HEADER } from "./identity.ts";
32
+ import {
33
+ directMlflowTracingActive,
34
+ persistAppMlflowTraceInfo,
35
+ updateActiveMlflowTrace,
36
+ updateActiveMlflowTraceTags,
37
+ } from "./mlflow.ts";
38
+ import {
39
+ MLFLOW_AGENT_TAG,
40
+ MLFLOW_AGENT_TAG_ATTR,
41
+ MLFLOW_GENIE_TAG,
42
+ MLFLOW_GENIE_TAG_ATTR,
43
+ MLFLOW_MODEL_TAG,
44
+ MLFLOW_LOCAL_TAG,
45
+ MLFLOW_OBO_AUTH_TAG,
46
+ MLFLOW_SP_AUTH_TAG,
47
+ MLFLOW_TRACE_TAG_PREFIX,
48
+ } from "./trace-attributes.ts";
49
+
50
+ export {
51
+ MLFLOW_AGENT_TAG,
52
+ MLFLOW_AGENT_TAG_ATTR,
53
+ MLFLOW_GENIE_TAG,
54
+ MLFLOW_GENIE_TAG_ATTR,
55
+ MLFLOW_MODEL_TAG,
56
+ MLFLOW_MODEL_TAG_ATTR,
57
+ MLFLOW_LOCAL_TAG,
58
+ MLFLOW_LOCAL_TAG_ATTR,
59
+ MLFLOW_OBO_AUTH_TAG,
60
+ MLFLOW_OBO_AUTH_TAG_ATTR,
61
+ MLFLOW_SP_AUTH_TAG,
62
+ MLFLOW_SP_AUTH_TAG_ATTR,
63
+ MLFLOW_TRACE_TAG_PREFIX,
64
+ } from "./trace-attributes.ts";
30
65
 
31
66
  const logger = log.logger("mastra/telemetry");
32
67
 
@@ -74,24 +109,19 @@ export const GEN_AI_OPERATION_NAME_ATTR = "gen_ai.operation.name";
74
109
  export const MLFLOW_USER_ATTR = "user.id";
75
110
  /** Root-span session id that enables the MLflow Sessions tab. */
76
111
  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
112
  /** Root-span type for a Mastra agent turn. */
85
113
  export const MLFLOW_SPAN_TYPE_AGENT = "AGENT";
86
114
  /** Span type for an `ask_genie` tool call. */
87
115
  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}`;
90
116
 
91
117
  /** Root-span metadata known before a chat request is dispatched. */
92
118
  export interface ChatTurnTelemetryOptions {
93
119
  readonly identity?:
94
120
  "obo" | "service-principal" | ((request: express.Request) => "obo" | "service-principal");
121
+ /** Initial trace tags derived from the inbound request. */
122
+ readonly tags?:
123
+ | Readonly<Record<string, ChatTraceTagValue | undefined>>
124
+ | ((request: express.Request) => Readonly<Record<string, ChatTraceTagValue | undefined>>);
95
125
  }
96
126
 
97
127
  interface TraceTarget {
@@ -99,16 +129,20 @@ interface TraceTarget {
99
129
  owned: boolean;
100
130
  }
101
131
 
132
+ interface ChatTraceState {
133
+ readonly models: Set<string>;
134
+ readonly tags: Map<string, string>;
135
+ user?: string;
136
+ }
137
+
138
+ /** Values accepted by request-scoped trace-tag injection helpers. */
139
+ export type ChatTraceTagValue = string | number | boolean;
140
+
141
+ const CHAT_TRACE_STATE_KEY = createContextKey("@dbx-tools/appkit-mastra/chat-trace-state");
142
+
102
143
  /** OpenTelemetry's sentinel for "no valid trace" - 32 zero hex chars. */
103
144
  const INVALID_TRACE_ID = "0".repeat(32);
104
145
 
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
146
  /**
113
147
  * Mark the active span as a Genie call so the experiment UI types it `GENIE`
114
148
  * and tags the trace `genie=true`. Safe no-op when no recording span is active.
@@ -135,14 +169,89 @@ function requestQuery(req: express.Request, name: string): string | undefined {
135
169
  return stringUtils.trimToNull(typeof value === "string" ? value : undefined) ?? undefined;
136
170
  }
137
171
 
138
- function stampMlflowActor(span: Span, req: express.Request): void {
172
+ function stampMlflowActor(span: Span, req: express.Request): string | undefined {
139
173
  const user = requestHeader(req, USER_EMAIL_HEADER) ?? requestHeader(req, USER_ID_HEADER);
140
174
  if (user) span.setAttribute(MLFLOW_USER_ATTR, user);
141
175
  const session =
142
176
  requestHeader(req, thread.THREAD_ID_HEADER) ?? requestQuery(req, thread.THREAD_ID_QUERY);
143
177
  if (session) span.setAttribute(MLFLOW_SESSION_ATTR, session);
178
+ return user;
179
+ }
180
+
181
+ function traceId(span: Span | undefined = trace.getActiveSpan()): string | undefined {
182
+ const value = span?.spanContext().traceId;
183
+ return value && value !== INVALID_TRACE_ID ? value : undefined;
184
+ }
185
+
186
+ function activeChatTraceState(): ChatTraceState | undefined {
187
+ return context.active().getValue(CHAT_TRACE_STATE_KEY) as ChatTraceState | undefined;
188
+ }
189
+
190
+ function setChatTraceTag(
191
+ state: ChatTraceState,
192
+ name: string,
193
+ value: ChatTraceTagValue,
194
+ ): void {
195
+ const tag = stringUtils.trimToNull(name);
196
+ if (!tag) return;
197
+ state.tags.set(tag, String(value));
198
+ }
199
+
200
+ /** Inject one MLflow trace tag into the active chat request. */
201
+ export function recordActiveTraceTag(
202
+ name: string,
203
+ value: ChatTraceTagValue = true,
204
+ ): void {
205
+ const state = activeChatTraceState();
206
+ if (state) setChatTraceTag(state, name, value);
207
+ }
208
+
209
+ /** Inject multiple MLflow trace tags into the active chat request. */
210
+ export function recordActiveTraceTags(
211
+ tags: Readonly<Record<string, ChatTraceTagValue | undefined>>,
212
+ ): void {
213
+ const state = activeChatTraceState();
214
+ if (!state) return;
215
+ for (const [name, value] of Object.entries(tags)) {
216
+ if (value !== undefined) setChatTraceTag(state, name, value);
217
+ }
218
+ }
219
+
220
+ /** Record a selected model on the active chat trace, preserving first-use order. */
221
+ export function recordActiveTraceModel(modelId: string): void {
222
+ const value = stringUtils.trimToNull(modelId);
223
+ if (!value) return;
224
+ activeChatTraceState()?.models.add(value);
144
225
  }
145
226
 
227
+ /** Record the best resolved user identifier on the active chat trace. */
228
+ export function recordActiveTraceUser(user: string | undefined): void {
229
+ const value = stringUtils.trimToNull(user);
230
+ if (!value) return;
231
+ const state = activeChatTraceState();
232
+ if (state) state.user = value;
233
+ }
234
+
235
+ /** Record one Databricks authentication mode used during the active chat trace. */
236
+ export function recordActiveTraceAuth(auth: "obo" | "service-principal"): void {
237
+ recordActiveTraceTag(auth === "obo" ? MLFLOW_OBO_AUTH_TAG : MLFLOW_SP_AUTH_TAG);
238
+ }
239
+
240
+ /** Serialize model tags as a scalar or an ordered JSON array for multiple values. */
241
+ export function modelTraceTag(models: Iterable<string>): string | undefined {
242
+ const values: string[] = [];
243
+ const seen = new Set<string>();
244
+ for (const model of models) {
245
+ const value = stringUtils.trimToNull(model);
246
+ if (!value || seen.has(value)) continue;
247
+ seen.add(value);
248
+ values.push(value);
249
+ }
250
+ if (values.length === 0) return undefined;
251
+ return values.length === 1 ? values[0] : JSON.stringify(values);
252
+ }
253
+
254
+ /** Publish the turn's MLflow trace id before the handler writes the body. */
146
255
  function stampMlflowTraceId(res: express.Response, span: Span): void {
147
256
  if (res.headersSent) return;
148
257
  const traceId = span.spanContext().traceId;
@@ -315,6 +424,28 @@ class AssistantResponseCollector {
315
424
  */
316
425
  function resolveTraceTarget(): TraceTarget | undefined {
317
426
  const activeContext = context.active();
427
+ if (directMlflowTracingActive()) {
428
+ const span = trace
429
+ .getTracer(CHAT_TURN_TRACER)
430
+ .startSpan(
431
+ "mastra.chat_turn",
432
+ {
433
+ kind: SpanKind.SERVER,
434
+ attributes: {
435
+ [MLFLOW_AGENT_TAG_ATTR]: "true",
436
+ [MLFLOW_SPAN_TYPE_ATTR]: MLFLOW_SPAN_TYPE_AGENT,
437
+ [GEN_AI_OPERATION_NAME_ATTR]: "invoke_agent",
438
+ },
439
+ },
440
+ trace.deleteSpan(activeContext),
441
+ );
442
+ if (!span.isRecording()) {
443
+ span.end();
444
+ return undefined;
445
+ }
446
+ return { span, owned: true };
447
+ }
448
+
318
449
  const rpc = getRPCMetadata(activeContext);
319
450
  if (rpc?.type === RPCType.HTTP && rpc.span.isRecording()) {
320
451
  return { span: rpc.span, owned: false };
@@ -325,7 +456,18 @@ function resolveTraceTarget(): TraceTarget | undefined {
325
456
 
326
457
  const span = trace
327
458
  .getTracer(CHAT_TURN_TRACER)
328
- .startSpan("mastra.chat_turn", { kind: SpanKind.SERVER }, activeContext);
459
+ .startSpan(
460
+ "mastra.chat_turn",
461
+ {
462
+ kind: SpanKind.SERVER,
463
+ attributes: {
464
+ [MLFLOW_AGENT_TAG_ATTR]: "true",
465
+ [MLFLOW_SPAN_TYPE_ATTR]: MLFLOW_SPAN_TYPE_AGENT,
466
+ [GEN_AI_OPERATION_NAME_ATTR]: "invoke_agent",
467
+ },
468
+ },
469
+ activeContext,
470
+ );
329
471
  if (!span.isRecording()) {
330
472
  span.end();
331
473
  return undefined;
@@ -358,36 +500,69 @@ export function chatTurnTelemetryMiddleware(
358
500
  next();
359
501
  return;
360
502
  }
503
+ const targetTraceId = traceId(target.span);
504
+ if (!targetTraceId) {
505
+ if (target.owned) target.span.end();
506
+ next();
507
+ return;
508
+ }
509
+ const traceState: ChatTraceState = { models: new Set(), tags: new Map() };
510
+ const traceRequestTime = Date.now();
511
+ setChatTraceTag(traceState, MLFLOW_AGENT_TAG, true);
512
+ if (!appkit.isDatabricksAppEnv()) {
513
+ setChatTraceTag(traceState, MLFLOW_LOCAL_TAG, true);
514
+ }
515
+ for (const [name, value] of Object.entries(getRequestTags(req))) {
516
+ setChatTraceTag(traceState, name, value);
517
+ }
518
+ const initialTags = typeof options.tags === "function" ? options.tags(req) : options.tags;
519
+ if (initialTags) {
520
+ for (const [name, value] of Object.entries(initialTags)) {
521
+ if (value !== undefined) setChatTraceTag(traceState, name, value);
522
+ }
523
+ }
361
524
  stampMlflowTraceId(res, target.span);
362
- stampMlflowActor(target.span, req);
525
+ traceState.user = stampMlflowActor(target.span, req);
363
526
  target.span.setAttribute(MLFLOW_SPAN_TYPE_ATTR, MLFLOW_SPAN_TYPE_AGENT);
364
527
  target.span.setAttribute(GEN_AI_OPERATION_NAME_ATTR, "invoke_agent");
528
+ target.span.setAttribute(MLFLOW_AGENT_TAG_ATTR, "true");
365
529
  const identity =
366
530
  typeof options.identity === "function" ? options.identity(req) : options.identity;
367
531
  if (identity) {
368
532
  target.span.setAttribute(CHAT_IDENTITY_ATTR, identity);
533
+ setChatTraceTag(
534
+ traceState,
535
+ identity === "obo" ? MLFLOW_OBO_AUTH_TAG : MLFLOW_SP_AUTH_TAG,
536
+ true,
537
+ );
369
538
  }
370
539
  target.span.setAttribute(CHAT_GENIE_USED_ATTR, false);
371
540
 
372
541
  const messages = (req.body as { messages?: unknown } | undefined)?.messages;
542
+ let requestPreview: string | undefined;
373
543
  if (messages !== undefined) {
374
544
  const fullInput = serialized(messages);
545
+ requestPreview = textOnlyChatInput(messages) ?? fullInput;
375
546
  target.span.setAttribute(CHAT_MESSAGES_ATTR, fullInput);
376
547
  target.span.setAttribute(
377
548
  MLFLOW_SPAN_INPUTS_ATTR,
378
- (textOnlyChatInput(messages) ?? fullInput).slice(0, TRACE_IO_LIMIT),
549
+ requestPreview.slice(0, TRACE_IO_LIMIT),
379
550
  );
380
551
  }
381
552
 
382
553
  const collector = new AssistantResponseCollector();
554
+ let responsePreview: string | undefined;
383
555
  let outputRecorded = false;
384
556
  let fullOutputRecorded = false;
557
+ let genieRecorded = false;
385
558
  let ownedSpanEnded = false;
559
+ let traceFinalized = false;
386
560
  const passThroughWrite = res.write.bind(res) as (...args: unknown[]) => boolean;
387
561
  const passThroughEnd = res.end.bind(res) as (...args: unknown[]) => unknown;
388
562
  const passThroughJson = res.json.bind(res) as (body?: unknown) => express.Response;
389
563
  const recordOutput = (answer: string): void => {
390
564
  if (outputRecorded || !answer) return;
565
+ responsePreview = answer;
391
566
  target.span.setAttribute(MLFLOW_SPAN_OUTPUTS_ATTR, answer.slice(0, TRACE_IO_LIMIT));
392
567
  outputRecorded = true;
393
568
  };
@@ -397,9 +572,41 @@ export function chatTurnTelemetryMiddleware(
397
572
  fullOutputRecorded = true;
398
573
  };
399
574
  const recordGenie = (used: boolean): void => {
400
- if (!used) return;
575
+ if (!used || genieRecorded) return;
576
+ genieRecorded = true;
401
577
  target.span.setAttribute(CHAT_GENIE_USED_ATTR, true);
402
578
  target.span.setAttribute(MLFLOW_GENIE_TAG_ATTR, "true");
579
+ setChatTraceTag(traceState, MLFLOW_GENIE_TAG, true);
580
+ context.with(trace.setSpan(context.active(), target.span), () => {
581
+ updateActiveMlflowTraceTags({ [MLFLOW_GENIE_TAG]: "true" });
582
+ });
583
+ };
584
+ const finalizeTrace = (): void => {
585
+ if (traceFinalized) return;
586
+ traceFinalized = true;
587
+ const model = modelTraceTag(traceState.models);
588
+ if (model) setChatTraceTag(traceState, MLFLOW_MODEL_TAG, model);
589
+ const tags = Object.fromEntries(traceState.tags);
590
+ for (const [name, value] of traceState.tags) {
591
+ target.span.setAttribute(`${MLFLOW_TRACE_TAG_PREFIX}${name}`, value);
592
+ }
593
+ if (traceState.user) target.span.setAttribute(MLFLOW_USER_ATTR, traceState.user);
594
+ context.with(trace.setSpan(context.active(), target.span), () => {
595
+ updateActiveMlflowTrace({
596
+ ...(Object.keys(tags).length > 0 ? { tags } : {}),
597
+ ...(traceState.user ? { user: traceState.user } : {}),
598
+ });
599
+ });
600
+ persistAppMlflowTraceInfo({
601
+ traceId: targetTraceId,
602
+ requestTime: traceRequestTime,
603
+ executionDuration: Date.now() - traceRequestTime,
604
+ error: res.statusCode >= 500,
605
+ tags,
606
+ ...(traceState.user ? { user: traceState.user } : {}),
607
+ ...(requestPreview ? { requestPreview } : {}),
608
+ ...(responsePreview ? { responsePreview } : {}),
609
+ });
403
610
  };
404
611
  const endOwnedSpan = (): void => {
405
612
  if (!target.owned || ownedSpanEnded) return;
@@ -424,6 +631,7 @@ export function chatTurnTelemetryMiddleware(
424
631
  recordOutput(collector.finish());
425
632
  recordFullOutput(collector.rawBody());
426
633
  recordGenie(collector.usedGenie());
634
+ finalizeTrace();
427
635
  try {
428
636
  return passThroughEnd(chunk, ...rest);
429
637
  } finally {
@@ -435,10 +643,17 @@ export function chatTurnTelemetryMiddleware(
435
643
  recordOutput(collector.finish());
436
644
  recordFullOutput(collector.rawBody());
437
645
  recordGenie(collector.usedGenie());
646
+ finalizeTrace();
438
647
  endOwnedSpan();
439
648
  });
440
649
 
441
- context.with(trace.setSpan(context.active(), target.span), next);
650
+ const chatContext = trace
651
+ .setSpan(context.active(), target.span)
652
+ .setValue(CHAT_TRACE_STATE_KEY, traceState);
653
+ context.with(chatContext, () => {
654
+ updateActiveMlflowTraceTags({ [MLFLOW_AGENT_TAG]: "true" });
655
+ next();
656
+ });
442
657
  }
443
658
 
444
659
  /**
@@ -0,0 +1,38 @@
1
+ /** Prefix Databricks/MLflow copies onto the unified-view trace tags map. */
2
+ export const MLFLOW_TRACE_TAG_PREFIX = "mlflow.traceTag.";
3
+
4
+ /** Experiment-UI tag marking every Mastra chat turn as an agent trace. */
5
+ export const MLFLOW_AGENT_TAG = "agent";
6
+
7
+ /** Experiment-UI tag marking a turn that called Genie. */
8
+ export const MLFLOW_GENIE_TAG = "genie";
9
+
10
+ /** Experiment-UI tag recording the model or ordered models used by a turn. */
11
+ export const MLFLOW_MODEL_TAG = "model";
12
+
13
+ /** Experiment-UI tag marking a turn that invoked OBO authentication. */
14
+ export const MLFLOW_OBO_AUTH_TAG = "obo_auth";
15
+
16
+ /** Experiment-UI tag marking a turn that invoked service-principal authentication. */
17
+ export const MLFLOW_SP_AUTH_TAG = "sp_auth";
18
+
19
+ /** Experiment-UI tag marking a turn emitted outside Databricks Apps. */
20
+ export const MLFLOW_LOCAL_TAG = "local";
21
+
22
+ /** Attribute key for the `agent` trace tag. */
23
+ export const MLFLOW_AGENT_TAG_ATTR = `${MLFLOW_TRACE_TAG_PREFIX}${MLFLOW_AGENT_TAG}`;
24
+
25
+ /** Attribute key for the `genie` trace tag. */
26
+ export const MLFLOW_GENIE_TAG_ATTR = `${MLFLOW_TRACE_TAG_PREFIX}${MLFLOW_GENIE_TAG}`;
27
+
28
+ /** Attribute key for the `model` trace tag. */
29
+ export const MLFLOW_MODEL_TAG_ATTR = `${MLFLOW_TRACE_TAG_PREFIX}${MLFLOW_MODEL_TAG}`;
30
+
31
+ /** Attribute key for the `obo_auth` trace tag. */
32
+ export const MLFLOW_OBO_AUTH_TAG_ATTR = `${MLFLOW_TRACE_TAG_PREFIX}${MLFLOW_OBO_AUTH_TAG}`;
33
+
34
+ /** Attribute key for the `sp_auth` trace tag. */
35
+ export const MLFLOW_SP_AUTH_TAG_ATTR = `${MLFLOW_TRACE_TAG_PREFIX}${MLFLOW_SP_AUTH_TAG}`;
36
+
37
+ /** Attribute key for the `local` trace tag. */
38
+ export const MLFLOW_LOCAL_TAG_ATTR = `${MLFLOW_TRACE_TAG_PREFIX}${MLFLOW_LOCAL_TAG}`;