@dbx-tools/appkit-mastra 0.6.14 → 0.6.27
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 +160 -20
- package/index.ts +8 -3
- package/lib/index.d.ts +8 -3
- package/lib/index.js +7 -2
- package/lib/src/chart.d.ts +39 -9
- package/lib/src/chart.js +460 -77
- package/lib/src/genie.js +24 -24
- package/lib/src/mlflow.d.ts +3 -1
- package/lib/src/mlflow.js +4 -2
- package/lib/src/observability.d.ts +9 -0
- package/lib/src/observability.js +17 -1
- package/lib/src/plugin.js +35 -2
- package/lib/src/remote-skills.js +10 -10
- package/lib/src/skill-paths.d.ts +15 -0
- package/lib/src/skill-paths.js +18 -0
- package/lib/src/trace-io.d.ts +53 -0
- package/lib/src/trace-io.js +117 -0
- package/lib/src/workspaces.d.ts +85 -15
- package/lib/src/workspaces.js +128 -64
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +13 -13
- package/src/chart.ts +519 -83
- package/src/genie.ts +24 -23
- package/src/mlflow.ts +3 -1
- package/src/observability.ts +16 -0
- package/src/plugin.ts +34 -1
- package/src/remote-skills.ts +10 -11
- package/src/skill-paths.ts +19 -0
- package/src/trace-io.ts +129 -0
- package/src/workspaces.ts +194 -84
package/src/genie.ts
CHANGED
|
@@ -48,14 +48,14 @@ import { plugin } from "@dbx-tools/appkit";
|
|
|
48
48
|
import { chat, space as genieSpace } from "@dbx-tools/genie";
|
|
49
49
|
import { error, log, string } from "@dbx-tools/shared-core";
|
|
50
50
|
import { genieModel, type GenieMessage } from "@dbx-tools/shared-genie";
|
|
51
|
-
import {
|
|
51
|
+
import type { MastraWriter, StartedEvent } from "@dbx-tools/shared-mastra";
|
|
52
52
|
import type { RequestContext } from "@mastra/core/request-context";
|
|
53
53
|
import { MASTRA_THREAD_ID_KEY } from "@mastra/core/request-context";
|
|
54
54
|
import { createTool } from "@mastra/core/tools";
|
|
55
55
|
import { z } from "zod";
|
|
56
56
|
|
|
57
57
|
import type { MastraTools } from "./agents.ts";
|
|
58
|
-
import { chartPlannerRequestSchema, prepareChart } from "./chart.ts";
|
|
58
|
+
import { chartPlannerRequestSchema, chartToolOutputSchema, prepareChart } from "./chart.ts";
|
|
59
59
|
import { MASTRA_USER_KEY, resolveUserKey } from "./config.ts";
|
|
60
60
|
import type { MastraPluginConfig, User } from "./config.ts";
|
|
61
61
|
import { fetchStatementData } from "./statement.ts";
|
|
@@ -687,9 +687,9 @@ function buildGetStatementTool() {
|
|
|
687
687
|
* {@link prepareChart} that resolves the dataset by fetching the
|
|
688
688
|
* Genie statement's rows on demand. The tool mints a `chartId`
|
|
689
689
|
* synchronously, caches an empty placeholder, and kicks off the
|
|
690
|
-
* planner in the background so the agent loop never blocks. The
|
|
691
|
-
* host UI resolves
|
|
692
|
-
*
|
|
690
|
+
* planner in the background so the agent loop never blocks. The result carries
|
|
691
|
+
* the complete marker to copy; the host UI resolves it by reading the cached
|
|
692
|
+
* {@link Chart} entry (1h TTL).
|
|
693
693
|
*
|
|
694
694
|
* Space-agnostic: a Genie `statement_id` is workspace-scoped, so
|
|
695
695
|
* one shared `prepare_chart` tool covers every wired Genie space.
|
|
@@ -709,20 +709,21 @@ function buildPrepareChartTool(opts: { config: MastraPluginConfig }) {
|
|
|
709
709
|
description: string.toDescription([
|
|
710
710
|
`
|
|
711
711
|
Queue a chart for the rows of a Genie statement. Mints a
|
|
712
|
-
|
|
712
|
+
\`chartId\` plus its complete \`marker\` synchronously and
|
|
713
|
+
kicks off a BACKGROUND
|
|
713
714
|
task that fetches the statement's rows, runs the
|
|
714
715
|
chart-planner to pick a chart type and Echarts spec, and
|
|
715
716
|
caches the result under the \`chartId\` for one hour. The
|
|
716
717
|
host UI fetches the cached chart on its own once it lands.
|
|
717
718
|
`,
|
|
718
719
|
`
|
|
719
|
-
To display the chart in your reply,
|
|
720
|
-
\`
|
|
721
|
-
want it to appear
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
720
|
+
To display the chart in your reply, copy the returned
|
|
721
|
+
\`marker\` VERBATIM onto its own line at the position you
|
|
722
|
+
want it to appear. Never construct, alter, or invent a
|
|
723
|
+
marker from \`chartId\` (and never use the \`statement_id\`
|
|
724
|
+
or any variation of it) - only the complete value returned
|
|
725
|
+
by this tool resolves to a real chart. The tool returns
|
|
726
|
+
immediately - do NOT wait or call it again to
|
|
726
727
|
"check progress"; the chart resolves asynchronously on the
|
|
727
728
|
host UI's side.
|
|
728
729
|
`,
|
|
@@ -734,7 +735,7 @@ function buildPrepareChartTool(opts: { config: MastraPluginConfig }) {
|
|
|
734
735
|
`,
|
|
735
736
|
]),
|
|
736
737
|
inputSchema: prepareChartRequestSchema,
|
|
737
|
-
outputSchema:
|
|
738
|
+
outputSchema: chartToolOutputSchema,
|
|
738
739
|
execute: async (request, ctxRaw) => {
|
|
739
740
|
const ctx = ctxRaw as ToolExecuteCtx;
|
|
740
741
|
const { client, requestContext } = requireClient(ctx, toolId);
|
|
@@ -835,20 +836,20 @@ export const GENIE_INSTRUCTIONS = string.toDescription([
|
|
|
835
836
|
`,
|
|
836
837
|
`
|
|
837
838
|
\`[chart:<chartId>]\` - render the rows as a chart. To
|
|
838
|
-
get
|
|
839
|
+
get one, call \`prepare_chart\` with the
|
|
839
840
|
statement's id (and an optional \`title\` / one-line
|
|
840
841
|
\`description\` of the insight to surface). The tool
|
|
841
|
-
returns the \`
|
|
842
|
-
chart spec in the background;
|
|
843
|
-
|
|
844
|
-
|
|
842
|
+
returns the complete \`marker\` synchronously and
|
|
843
|
+
prepares the chart spec in the background; copy that
|
|
844
|
+
marker VERBATIM onto its own line wherever the chart
|
|
845
|
+
should appear. Use a chart when the data has a
|
|
845
846
|
story a visual conveys better than a table (trends,
|
|
846
847
|
rankings, distributions, parts-of-a-whole).
|
|
847
848
|
|
|
848
|
-
NEVER invent or hand-build a
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
849
|
+
NEVER invent or hand-build a marker. A valid marker is
|
|
850
|
+
the complete opaque string a \`prepare_chart\` call
|
|
851
|
+
returned to you in THIS turn - nothing else. Its id is
|
|
852
|
+
NOT a \`statement_id\`, and it is NOT a
|
|
852
853
|
\`statement_id\` prefix with a label appended (e.g.
|
|
853
854
|
\`01f1...-region-fill\`). If you have not called
|
|
854
855
|
\`prepare_chart\` and received an id back, do not write
|
package/src/mlflow.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Feedback attaches to a trace, and the plugin's spans reach MLflow
|
|
7
7
|
* through the same OTel pipeline as every other AppKit span (see
|
|
8
|
-
* `observability.ts`).
|
|
8
|
+
* `observability.ts`). On Databricks Apps that pipeline is the UC
|
|
9
|
+
* sidecar injected by `telemetry_export_destinations`, not a direct
|
|
10
|
+
* workspace OTLP URL. MLflow derives its trace id from the OpenTelemetry
|
|
9
11
|
* trace id (`tr-<hex(otelTraceId)>`), so the server stamps the active
|
|
10
12
|
* trace id on each turn's response and the client sends it back here.
|
|
11
13
|
*
|
package/src/observability.ts
CHANGED
|
@@ -30,6 +30,15 @@
|
|
|
30
30
|
* registered at all (unless `observability: true` forces it on), so
|
|
31
31
|
* Mastra does not emit `[OtelBridge] No OTEL span found` warnings.
|
|
32
32
|
*
|
|
33
|
+
* On Databricks Apps the supported sink is Unity Catalog via
|
|
34
|
+
* `telemetry_export_destinations` (the platform injects the local OTLP
|
|
35
|
+
* sidecar). Managed MLflow has no OTLP ingest endpoint - do not point
|
|
36
|
+
* `OTEL_EXPORTER_OTLP_*` at the workspace host. Set
|
|
37
|
+
* `OTEL_PROPAGATORS=none` so Apps ingress `traceparent` does not hide
|
|
38
|
+
* every request-scoped root span from the UC `*_trace_unified` view.
|
|
39
|
+
* Chat request/response on that root span is stamped by
|
|
40
|
+
* `traceIo.attachChatTurnTraceIo` (see `trace-io.ts`).
|
|
41
|
+
*
|
|
33
42
|
* @module
|
|
34
43
|
*/
|
|
35
44
|
|
|
@@ -39,6 +48,7 @@ import { Observability } from "@mastra/observability";
|
|
|
39
48
|
import { OtelBridge } from "@mastra/otel-bridge";
|
|
40
49
|
|
|
41
50
|
import { TRACE_REQUEST_CONTEXT_KEYS } from "./config.ts";
|
|
51
|
+
import { mlflowEnabled } from "./mlflow.ts";
|
|
42
52
|
|
|
43
53
|
const logger = log.logger("mastra/observability");
|
|
44
54
|
|
|
@@ -127,11 +137,17 @@ export async function buildObservability(
|
|
|
127
137
|
: otelBase
|
|
128
138
|
? `${otelBase.replace(/\/+$/, "")}/v1/traces`
|
|
129
139
|
: undefined;
|
|
140
|
+
// `mlflowEnabled` is the same gate that flips chat feedback on, so the
|
|
141
|
+
// boot line is the one-glance check that both halves of the Apps UC
|
|
142
|
+
// pipeline (OTLP sidecar + experiment id) are present.
|
|
143
|
+
const feedback = mlflowEnabled();
|
|
130
144
|
logger.info("Mastra observability wired through OTel bridge", {
|
|
131
145
|
serviceName,
|
|
132
146
|
requestContextKeys,
|
|
133
147
|
otelBase: otelBase ?? "<unset>",
|
|
134
148
|
resolvedTracesUrl: resolvedTracesUrl ?? "<unset>",
|
|
149
|
+
feedback,
|
|
150
|
+
observability: feedback ? "mlflow" : "otel",
|
|
135
151
|
});
|
|
136
152
|
|
|
137
153
|
return new Observability({
|
package/src/plugin.ts
CHANGED
|
@@ -106,6 +106,7 @@ import {
|
|
|
106
106
|
import { resolveServingConfig } from "./serving.ts";
|
|
107
107
|
import { fetchStatementData, STATEMENT_ROW_CAP } from "./statement.ts";
|
|
108
108
|
import { threadsRoute } from "./threads.ts";
|
|
109
|
+
import { attachChatTurnTraceIo } from "./trace-io.ts";
|
|
109
110
|
import { invalidFields } from "./validation.ts";
|
|
110
111
|
|
|
111
112
|
const GENIE_MANIFEST = plugin.data(genie).plugin.manifest;
|
|
@@ -594,10 +595,24 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
|
|
|
594
595
|
const id = string.firstNonEmpty(req.params.id);
|
|
595
596
|
const resolve = embedResolvers[type];
|
|
596
597
|
if (!resolve) {
|
|
598
|
+
this.logger.warn("embed:unsupported", {
|
|
599
|
+
status: 404,
|
|
600
|
+
type,
|
|
601
|
+
id,
|
|
602
|
+
method: req.method,
|
|
603
|
+
path: req.path,
|
|
604
|
+
});
|
|
597
605
|
res.status(404).json({ error: `unsupported embed type: ${type}` });
|
|
598
606
|
return;
|
|
599
607
|
}
|
|
600
608
|
if (!id) {
|
|
609
|
+
this.logger.warn("embed:invalid", {
|
|
610
|
+
status: 400,
|
|
611
|
+
type,
|
|
612
|
+
method: req.method,
|
|
613
|
+
path: req.path,
|
|
614
|
+
error: "id is required",
|
|
615
|
+
});
|
|
601
616
|
res.status(400).json({ error: "id is required" });
|
|
602
617
|
return;
|
|
603
618
|
}
|
|
@@ -615,6 +630,18 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
|
|
|
615
630
|
return;
|
|
616
631
|
}
|
|
617
632
|
if (result.data === undefined) {
|
|
633
|
+
// A cache miss is security-deliberately ambiguous: unknown, expired,
|
|
634
|
+
// or owned by another identity all map to the same 404. Log the
|
|
635
|
+
// opaque id and those possible causes so a fabricated marker is
|
|
636
|
+
// diagnosable from the server without disclosing which cause applied.
|
|
637
|
+
this.logger.warn(`embed:${type}:not-found`, {
|
|
638
|
+
status: 404,
|
|
639
|
+
type,
|
|
640
|
+
id,
|
|
641
|
+
method: req.method,
|
|
642
|
+
path: req.path,
|
|
643
|
+
reason: "unknown, expired, or owned by another identity",
|
|
644
|
+
});
|
|
618
645
|
res.status(404).json({ error: `${type} not found` });
|
|
619
646
|
return;
|
|
620
647
|
}
|
|
@@ -1004,7 +1031,7 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
|
|
|
1004
1031
|
// every default-workspace agent via `extraSkillPaths`.
|
|
1005
1032
|
const provisioned = await provisionRemoteSkills(this.config.remoteSkills);
|
|
1006
1033
|
if (provisioned.skillNames.length > 0) {
|
|
1007
|
-
this.logger.info("remote skills
|
|
1034
|
+
this.logger.info("remote skills configured", {
|
|
1008
1035
|
skills: provisioned.skillNames,
|
|
1009
1036
|
databricksBasePath: provisioned.databricksBasePath,
|
|
1010
1037
|
localSkillPaths: provisioned.localSkillPaths.length,
|
|
@@ -1061,6 +1088,12 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
|
|
|
1061
1088
|
});
|
|
1062
1089
|
this.mastraApp = express();
|
|
1063
1090
|
attachRoutePatchMiddleware(this.mastraApp);
|
|
1091
|
+
// Stamp chat request/response onto the HTTP root span so MLflow's
|
|
1092
|
+
// UC `*_trace_unified` view can surface them (Mastra's own
|
|
1093
|
+
// `mastra.agent_run.*` attributes sit on a child span the view
|
|
1094
|
+
// never reads). Must run before `MastraServer.init` so the layer
|
|
1095
|
+
// sits ahead of the agent routes.
|
|
1096
|
+
attachChatTurnTraceIo(this.mastraApp);
|
|
1064
1097
|
this.mastraServer = new MastraServer(this.config, {
|
|
1065
1098
|
app: this.mastraApp,
|
|
1066
1099
|
mastra: this.mastra,
|
package/src/remote-skills.ts
CHANGED
|
@@ -61,15 +61,9 @@ import { error, hash, json, log, net, object, string } from "@dbx-tools/shared-c
|
|
|
61
61
|
import type { OneOrMany } from "@dbx-tools/shared-core";
|
|
62
62
|
import type { FileSystem } from "@dbx-tools/shared-fs";
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
/** Shared Assistant skills tree in the Databricks workspace (default target). */
|
|
67
|
-
const ASSISTANT_SHARED_SKILLS_PATH = "/Workspace/.assistant/skills";
|
|
64
|
+
import { ASSISTANT_SHARED_SKILLS_PATH, userAssistantSkillsPath } from "./skill-paths.ts";
|
|
68
65
|
|
|
69
|
-
|
|
70
|
-
function userAssistantSkillsPath(userEmail: string): string {
|
|
71
|
-
return `/Users/${userEmail.trim()}/.assistant/skills`;
|
|
72
|
-
}
|
|
66
|
+
const logger = log.logger("mastra/remote-skills");
|
|
73
67
|
|
|
74
68
|
/**
|
|
75
69
|
* Agent id the `skills` CLI installs a bare `SKILL.md` tree under.
|
|
@@ -452,14 +446,19 @@ export async function provisionRemoteSkills(
|
|
|
452
446
|
if (cached && isFresh(cached, resolveRefreshTtl(sourceOptions, options))) {
|
|
453
447
|
skillNames.push(...cached.skills);
|
|
454
448
|
if (!destination) localSkillPaths.push(cacheFS.root);
|
|
455
|
-
logger.
|
|
449
|
+
logger.info("remote skill ready", {
|
|
456
450
|
source: sourceOptions.source,
|
|
451
|
+
destination: databricksBasePath ?? cacheFS.root,
|
|
457
452
|
downloadedAt: cached.downloadedAt,
|
|
458
453
|
skills: cached.skills,
|
|
454
|
+
cached: true,
|
|
459
455
|
});
|
|
460
456
|
continue;
|
|
461
457
|
}
|
|
462
458
|
|
|
459
|
+
logger.info("installing remote skill", {
|
|
460
|
+
source: sourceOptions.source,
|
|
461
|
+
});
|
|
463
462
|
staging ??= await initializedScratch("mastra-remote-skills");
|
|
464
463
|
const stagedDir = await stageSource(sourceOptions, staging.root, options);
|
|
465
464
|
const staged = await collectSkillDirs(stagedDir);
|
|
@@ -482,9 +481,9 @@ export async function provisionRemoteSkills(
|
|
|
482
481
|
localSkillPaths.push(await persistLocally(key, staged, record));
|
|
483
482
|
}
|
|
484
483
|
skillNames.push(...record.skills);
|
|
485
|
-
logger.
|
|
484
|
+
logger.info("remote skill installed", {
|
|
486
485
|
source: sourceOptions.source,
|
|
487
|
-
destination: databricksBasePath ??
|
|
486
|
+
destination: databricksBasePath ?? localSkillPaths.at(-1),
|
|
488
487
|
skills: record.skills,
|
|
489
488
|
});
|
|
490
489
|
} catch (err) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where Databricks Assistant `SKILL.md` trees live.
|
|
3
|
+
*
|
|
4
|
+
* Two modules need these paths and must agree on them: `workspaces.ts` MOUNTS
|
|
5
|
+
* them per request (see `DEFAULT_SKILL_FOLDERS`), and `remote-skills.ts`
|
|
6
|
+
* WRITES provisioned skills into them at startup. They were previously spelled
|
|
7
|
+
* out in both, so a change to one silently provisioned skills into a tree the
|
|
8
|
+
* other never scanned.
|
|
9
|
+
*
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Shared Assistant skills tree, readable by everyone in the workspace. */
|
|
14
|
+
export const ASSISTANT_SHARED_SKILLS_PATH = "/Workspace/.assistant/skills";
|
|
15
|
+
|
|
16
|
+
/** Assistant skills tree owned by one user (the "save this as a skill" target). */
|
|
17
|
+
export function userAssistantSkillsPath(userEmail: string): string {
|
|
18
|
+
return `/Users/${userEmail.trim()}/.assistant/skills`;
|
|
19
|
+
}
|
package/src/trace-io.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy each Mastra chat turn's request/response onto the active OTel
|
|
3
|
+
* root span so Databricks-managed MLflow can display them.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: MLflow's UC `<target>_trace_unified` view picks the
|
|
6
|
+
* one span whose `parent_span_id` is empty and reads request/response
|
|
7
|
+
* from `mlflow.spanInputs` / `mlflow.spanOutputs` (or the `gen_ai.*`
|
|
8
|
+
* equivalents) on THAT span only. Mastra records the turn on its
|
|
9
|
+
* `invoke_agent` child under `mastra.agent_run.input` / `.output`, keys
|
|
10
|
+
* the view never reads, so chat traces arrive with both columns null
|
|
11
|
+
* even when the spans themselves look healthy.
|
|
12
|
+
*
|
|
13
|
+
* The active span inside the Mastra sub-app is still AppKit's HTTP
|
|
14
|
+
* server span (OTel context is async-local). With `OTEL_PROPAGATORS=none`
|
|
15
|
+
* that span is the trace root - see the package README's observability
|
|
16
|
+
* section for why Apps ingress `traceparent` otherwise hides every turn.
|
|
17
|
+
*
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { json, log, object } from "@dbx-tools/shared-core";
|
|
22
|
+
import { trace } from "@opentelemetry/api";
|
|
23
|
+
import type express from "express";
|
|
24
|
+
|
|
25
|
+
const logger = log.logger("mastra/trace-io");
|
|
26
|
+
|
|
27
|
+
/** Cap on each payload copied onto a span, so one turn cannot bloat the export. */
|
|
28
|
+
export const TRACE_IO_LIMIT = 8_000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Mount-relative Mastra agent invoke paths that carry a chat turn body
|
|
32
|
+
* (`messages`) and stream an assistant answer. Resume / approve verbs
|
|
33
|
+
* are intentionally excluded: their bodies are tool decisions, not the
|
|
34
|
+
* user prompt, and they rarely produce a fresh answer worth surfacing.
|
|
35
|
+
*/
|
|
36
|
+
const AGENT_TURN_ROUTE = /^\/agents\/[^/]+\/(stream|generate)(\/|$)/i;
|
|
37
|
+
|
|
38
|
+
/** Attribute keys the MLflow UC `*_trace_unified` view reads from the root span. */
|
|
39
|
+
export const MLFLOW_SPAN_INPUTS_ATTR = "mlflow.spanInputs";
|
|
40
|
+
export const MLFLOW_SPAN_OUTPUTS_ATTR = "mlflow.spanOutputs";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Concatenate the assistant's answer out of an AI SDK SSE transcript.
|
|
44
|
+
*
|
|
45
|
+
* The agent streams its reply as `text-delta` frames
|
|
46
|
+
* (`data: {"type":"text-delta","payload":{"text":"..."}}`), so the full
|
|
47
|
+
* answer only ever exists as deltas on the wire and has to be
|
|
48
|
+
* reassembled here.
|
|
49
|
+
*/
|
|
50
|
+
export function assistantTextFromSse(body: string): string {
|
|
51
|
+
const parts: string[] = [];
|
|
52
|
+
for (const line of body.split("\n")) {
|
|
53
|
+
if (!line.startsWith("data:")) continue;
|
|
54
|
+
// SSE carries comment/keepalive lines that are not JSON; they never hold
|
|
55
|
+
// assistant text, so skipping them is the intended path rather than an error.
|
|
56
|
+
const frame = json.parse(line.slice(5));
|
|
57
|
+
if (!object.isRecord(frame) || frame.type !== "text-delta") continue;
|
|
58
|
+
const text = object.isRecord(frame.payload) ? frame.payload.text : undefined;
|
|
59
|
+
if (typeof text === "string") parts.push(text);
|
|
60
|
+
}
|
|
61
|
+
return parts.join("");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Express middleware that stamps chat turn I/O onto the active OTel span.
|
|
66
|
+
*
|
|
67
|
+
* Tee `res.write` / `res.end` instead of listening for `finish`: the HTTP
|
|
68
|
+
* instrumentation ends the root span on finish, and an attribute set after
|
|
69
|
+
* a span ends never reaches the exporter.
|
|
70
|
+
*
|
|
71
|
+
* `path` is mount-relative (what the Mastra sub-app sees), e.g.
|
|
72
|
+
* `/agents/support/stream`.
|
|
73
|
+
*/
|
|
74
|
+
export function chatTurnTraceIoMiddleware(
|
|
75
|
+
req: express.Request,
|
|
76
|
+
res: express.Response,
|
|
77
|
+
next: express.NextFunction,
|
|
78
|
+
): void {
|
|
79
|
+
if (req.method !== "POST" || !AGENT_TURN_ROUTE.test(req.path)) {
|
|
80
|
+
next();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const span = trace.getActiveSpan();
|
|
84
|
+
if (!span) {
|
|
85
|
+
next();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const messages = (req.body as { messages?: unknown } | undefined)?.messages;
|
|
90
|
+
if (messages !== undefined) {
|
|
91
|
+
span.setAttribute(MLFLOW_SPAN_INPUTS_ATTR, JSON.stringify(messages).slice(0, TRACE_IO_LIMIT));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const chunks: string[] = [];
|
|
95
|
+
const passThroughWrite = res.write.bind(res) as (...args: unknown[]) => boolean;
|
|
96
|
+
const passThroughEnd = res.end.bind(res) as (...args: unknown[]) => unknown;
|
|
97
|
+
const collect = (chunk: unknown): void => {
|
|
98
|
+
if (typeof chunk === "string") chunks.push(chunk);
|
|
99
|
+
else if (Buffer.isBuffer(chunk)) chunks.push(chunk.toString("utf8"));
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
res.write = ((chunk: unknown, ...rest: unknown[]) => {
|
|
103
|
+
collect(chunk);
|
|
104
|
+
return passThroughWrite(chunk, ...rest);
|
|
105
|
+
}) as typeof res.write;
|
|
106
|
+
|
|
107
|
+
res.end = ((chunk?: unknown, ...rest: unknown[]) => {
|
|
108
|
+
collect(chunk);
|
|
109
|
+
const answer = assistantTextFromSse(chunks.join(""));
|
|
110
|
+
if (answer) {
|
|
111
|
+
span.setAttribute(MLFLOW_SPAN_OUTPUTS_ATTR, answer.slice(0, TRACE_IO_LIMIT));
|
|
112
|
+
}
|
|
113
|
+
return passThroughEnd(chunk, ...rest);
|
|
114
|
+
}) as typeof res.end;
|
|
115
|
+
|
|
116
|
+
next();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Install {@link chatTurnTraceIoMiddleware} on a Mastra Express sub-app.
|
|
121
|
+
*
|
|
122
|
+
* Call before `MastraServer.init()` so the layer sits ahead of the agent
|
|
123
|
+
* routes. Safe to call unconditionally: with no active span (local, no
|
|
124
|
+
* OTLP) the middleware is a no-op.
|
|
125
|
+
*/
|
|
126
|
+
export function attachChatTurnTraceIo(app: express.Express): void {
|
|
127
|
+
app.use(chatTurnTraceIoMiddleware);
|
|
128
|
+
logger.info("chat turn I/O middleware attached");
|
|
129
|
+
}
|