From a86388fef0400c75dbe15b97cd8bcaa5744bbd6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Tue, 7 Jul 2026 17:08:30 -0700 Subject: [PATCH 1/2] fix(mastra): emit tags as first-class Braintrust tags, not metadata buildMetadata placed exported.tags under metadata.tags, where Braintrust does not surface them as tags. Move root-span tags to the top-level `tags` row field (via ExperimentLogPartialArgs.tags) so they appear as first-class, filterable tags. Matching @mastra/braintrust, tags are only attached to the Mastra root span (Braintrust tags are trace-level). Verified empirically that span.log({ tags }) writes the top-level tags field while metadata.tags stays inert. Adds js/src/wrappers/mastra.test.ts. Ports mastra-ai/mastra#12057 (re-fixes #9849). --- .changeset/mastra-tags-export.md | 13 ++++ js/src/wrappers/mastra.test.ts | 100 +++++++++++++++++++++++++++++++ js/src/wrappers/mastra.ts | 18 +++++- 3 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 .changeset/mastra-tags-export.md create mode 100644 js/src/wrappers/mastra.test.ts diff --git a/.changeset/mastra-tags-export.md b/.changeset/mastra-tags-export.md new file mode 100644 index 000000000..cbb29a265 --- /dev/null +++ b/.changeset/mastra-tags-export.md @@ -0,0 +1,13 @@ +--- +"braintrust": patch +--- + +fix(mastra): Emit Mastra tags as first-class Braintrust tags + +Root-span tags from the Mastra observability exporter are now logged to the +top-level `tags` row field (which Braintrust surfaces as first-class tags and +filters on) instead of being nested under `metadata.tags`, where they were +invisible to the tag UI. Matching `@mastra/braintrust`, tags are attached only +to the Mastra root span. + +Ports mastra-ai/mastra#12057 (re-fixes #9849). diff --git a/js/src/wrappers/mastra.test.ts b/js/src/wrappers/mastra.test.ts new file mode 100644 index 000000000..97b170281 --- /dev/null +++ b/js/src/wrappers/mastra.test.ts @@ -0,0 +1,100 @@ +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "vitest"; +import { configureNode } from "../node/config"; +import { _exportsForTestingOnly, initLogger } from "../logger"; +import { BraintrustObservabilityExporter } from "./mastra"; + +try { + configureNode(); +} catch { + // Best-effort initialization for test environments. +} + +type MastraExportedSpan = Parameters< + BraintrustObservabilityExporter["exportTracingEvent"] +>[0]["exportedSpan"]; + +function makeSpan(overrides: Partial): MastraExportedSpan { + return { + id: "span-1", + traceId: "trace-1", + name: "agent run", + type: "agent_run", + startTime: 1_000_000, + ...overrides, + }; +} + +describe("BraintrustObservabilityExporter tags", () => { + let backgroundLogger: ReturnType< + typeof _exportsForTestingOnly.useTestBackgroundLogger + >; + + beforeAll(async () => { + await _exportsForTestingOnly.simulateLoginForTests(); + }); + + beforeEach(() => { + backgroundLogger = _exportsForTestingOnly.useTestBackgroundLogger(); + initLogger({ projectId: "test-project-id", projectName: "mastra.test.ts" }); + }); + + afterEach(() => { + _exportsForTestingOnly.clearTestBackgroundLogger(); + }); + + // A span is only logged if it was started, so drive both lifecycle events. + const runSpan = async ( + exporter: BraintrustObservabilityExporter, + span: MastraExportedSpan, + ) => { + await exporter.exportTracingEvent({ + type: "span_started", + exportedSpan: span, + }); + await exporter.exportTracingEvent({ + type: "span_ended", + exportedSpan: { ...span, endTime: 1_000_001 }, + }); + }; + + test("tags surface as a top-level field on the root span only", async () => { + const exporter = new BraintrustObservabilityExporter(); + await runSpan( + exporter, + makeSpan({ id: "root", isRootSpan: true, tags: ["production", "beta"] }), + ); + await runSpan( + exporter, + makeSpan({ + id: "child", + name: "tool call", + type: "tool_call", + isRootSpan: false, + tags: ["should-not-appear"], + }), + ); + + const rows = (await backgroundLogger.drain()) as any[]; + const byName = (name: string) => + rows.find((r) => r.span_attributes?.name === name); + + const root = byName("agent run"); + expect(root).toBeDefined(); + // Braintrust surfaces top-level `tags` as first-class tags; not metadata. + expect(root.tags).toEqual(["production", "beta"]); + expect(root.metadata?.tags).toBeUndefined(); + + // Braintrust tags are trace-level, so non-root spans carry none. + const child = byName("tool call"); + expect(child).toBeDefined(); + expect(child.tags).toBeUndefined(); + expect(child.metadata?.tags).toBeUndefined(); + }); +}); diff --git a/js/src/wrappers/mastra.ts b/js/src/wrappers/mastra.ts index 9946a9643..136bd98aa 100644 --- a/js/src/wrappers/mastra.ts +++ b/js/src/wrappers/mastra.ts @@ -186,9 +186,9 @@ function buildMetadata(exported: MastraExportedSpan): Record { if (value !== undefined) out[key] = value; } } - if (exported.tags && exported.tags.length > 0) { - out.tags = exported.tags; - } + // Note: `exported.tags` is intentionally NOT placed in metadata. Braintrust + // surfaces tags from the top-level `tags` row field (see `logPayload`), and + // nesting them under `metadata.tags` would hide them from the tag UI/filters. if (exported.requestContext && isObject(exported.requestContext)) { out.request_context = exported.requestContext; } @@ -351,6 +351,18 @@ export class BraintrustObservabilityExporter implements MastraObservabilityExpor event.metrics = metrics; } + // Emit tags as the top-level `tags` row field (via ExperimentLogPartialArgs) + // so Braintrust surfaces them as first-class tags rather than metadata. + // Braintrust tags are a trace-level concept, so — matching + // `@mastra/braintrust` — we only attach them to the Mastra root span. + if ( + exported.isRootSpan === true && + exported.tags && + exported.tags.length > 0 + ) { + event.tags = exported.tags; + } + if (Object.keys(event).length > 0) { record.span.log(event); } From ca077c6a87c3a9801bc7c34384729115b045d22a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Wed, 8 Jul 2026 18:00:11 -0700 Subject: [PATCH 2/2] fix(mastra): retain metadata.tags mirror for backward compatibility Make the tags fix purely additive: keep writing exported tags to metadata.tags (prior behavior, on any span) while also emitting them to the top-level `tags` row field on the root span, which Braintrust surfaces as first-class, filterable tags. Add wire-level e2e coverage that drives real @mastra/core through tracingOptions.tags and asserts both the top-level `tags` field and the metadata.tags mirror on the root span (and no top-level tags on children). Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/mastra-tags-export.md | 19 ++++++++--- .../mastra-instrumentation/scenario.mjs | 3 ++ .../mastra-instrumentation/scenario.test.ts | 34 +++++++++++++++++++ js/src/wrappers/mastra.test.ts | 12 ++++--- js/src/wrappers/mastra.ts | 10 ++++-- 5 files changed, 66 insertions(+), 12 deletions(-) diff --git a/.changeset/mastra-tags-export.md b/.changeset/mastra-tags-export.md index cbb29a265..c68730960 100644 --- a/.changeset/mastra-tags-export.md +++ b/.changeset/mastra-tags-export.md @@ -4,10 +4,19 @@ fix(mastra): Emit Mastra tags as first-class Braintrust tags -Root-span tags from the Mastra observability exporter are now logged to the -top-level `tags` row field (which Braintrust surfaces as first-class tags and -filters on) instead of being nested under `metadata.tags`, where they were -invisible to the tag UI. Matching `@mastra/braintrust`, tags are attached only -to the Mastra root span. +Root-span tags from the Mastra observability exporter are now additionally +logged to the top-level `tags` row field, which Braintrust surfaces as +first-class tags and filters on. Previously tags were only nested under +`metadata.tags`, where they were invisible to the tag UI. Matching +`@mastra/braintrust`, the first-class tags are attached only to the Mastra root +span. The existing `metadata.tags` field is retained for backward +compatibility, so this change is purely additive. + +Note: Braintrust's trace-list tag filter is scoped to the trace root span +(`is_root`). In the zero-config and manual integrations the Mastra root span is +the Braintrust trace root, so tags are filterable everywhere. When Mastra runs +nested inside an existing Braintrust span, the tags land on a non-root span: +they remain filterable via summary / BTQL (which aggregates tags across all +spans in a trace) but not via the root-scoped trace-list form filter. Ports mastra-ai/mastra#12057 (re-fixes #9849). diff --git a/e2e/scenarios/mastra-instrumentation/scenario.mjs b/e2e/scenarios/mastra-instrumentation/scenario.mjs index 6b10057df..3af7b977a 100644 --- a/e2e/scenarios/mastra-instrumentation/scenario.mjs +++ b/e2e/scenarios/mastra-instrumentation/scenario.mjs @@ -81,6 +81,9 @@ async function runMastraInstrumentationScenario() { registeredAgent.generate("What is the weather in Paris?", { runId: "agent-generate-run", resourceId: "weather-user", + // Mastra applies `tags` only to the trace root span; the exporter + // must surface them on the top-level `tags` row field, not metadata. + tracingOptions: { tags: ["e2e-production", "e2e-beta"] }, }), ); diff --git a/e2e/scenarios/mastra-instrumentation/scenario.test.ts b/e2e/scenarios/mastra-instrumentation/scenario.test.ts index 7fc5ccf5b..85dceaa1e 100644 --- a/e2e/scenarios/mastra-instrumentation/scenario.test.ts +++ b/e2e/scenarios/mastra-instrumentation/scenario.test.ts @@ -199,6 +199,40 @@ describe(`mastra sdk ${mastraVersion} auto-hook instrumentation`, () => { expect(modelSpans.length).toBeGreaterThanOrEqual(1); }); + // Non-circular check of the fix: real @mastra/core emits `tags` on the trace + // root span (via tracingOptions.tags), and the exporter must land them on the + // top-level `tags` row field the mock server receives (which Braintrust + // surfaces as first-class, filterable tags) while — for backward + // compatibility — still mirroring them under metadata.tags. + test("emits agent tags on the top-level tags field of the root span only", () => { + const EXPECTED_TAGS = ["e2e-production", "e2e-beta"]; + + const taggedEvents = events.filter( + (event) => (event.row.tags as unknown[] | undefined) !== undefined, + ); + expect(taggedEvents.length).toBeGreaterThanOrEqual(1); + + for (const event of taggedEvents) { + // Tags only ride on the Mastra agent-run root span. + expect(event.row.metadata?.entity_type).toBe("agent"); + expect(event.span.type).toBe("task"); + // First-class, filterable top-level field (the fix). + expect(event.row.tags).toEqual(EXPECTED_TAGS); + // Backward-compatible mirror retained under metadata. + expect((event.row.metadata as Record)?.tags).toEqual( + EXPECTED_TAGS, + ); + } + + // No child span (tool/model/workflow) carries top-level tags. + const childrenWithTags = events.filter( + (event) => + event.row.tags !== undefined && + event.row.metadata?.entity_type !== "agent", + ); + expect(childrenWithTags).toEqual([]); + }); + test("matches the shared span tree snapshot", async () => { await matchSpanTreeSnapshot( relevantMastraEvents(events).map((event) => { diff --git a/js/src/wrappers/mastra.test.ts b/js/src/wrappers/mastra.test.ts index 97b170281..f88369d45 100644 --- a/js/src/wrappers/mastra.test.ts +++ b/js/src/wrappers/mastra.test.ts @@ -87,14 +87,18 @@ describe("BraintrustObservabilityExporter tags", () => { const root = byName("agent run"); expect(root).toBeDefined(); - // Braintrust surfaces top-level `tags` as first-class tags; not metadata. + // Braintrust surfaces the top-level `tags` field as first-class tags. expect(root.tags).toEqual(["production", "beta"]); - expect(root.metadata?.tags).toBeUndefined(); + // Backward compatibility: prior releases mirrored tags under metadata, so + // that mirror is retained alongside the first-class top-level field. + expect(root.metadata?.tags).toEqual(["production", "beta"]); - // Braintrust tags are trace-level, so non-root spans carry none. + // Top-level `tags` are trace-level, so non-root spans carry none. const child = byName("tool call"); expect(child).toBeDefined(); expect(child.tags).toBeUndefined(); - expect(child.metadata?.tags).toBeUndefined(); + // Backward compatibility: prior releases mirrored any span's tags under + // metadata, regardless of root-ness, so that behavior is preserved here. + expect(child.metadata?.tags).toEqual(["should-not-appear"]); }); }); diff --git a/js/src/wrappers/mastra.ts b/js/src/wrappers/mastra.ts index 136bd98aa..91f7cf88b 100644 --- a/js/src/wrappers/mastra.ts +++ b/js/src/wrappers/mastra.ts @@ -186,9 +186,13 @@ function buildMetadata(exported: MastraExportedSpan): Record { if (value !== undefined) out[key] = value; } } - // Note: `exported.tags` is intentionally NOT placed in metadata. Braintrust - // surfaces tags from the top-level `tags` row field (see `logPayload`), and - // nesting them under `metadata.tags` would hide them from the tag UI/filters. + // Retained for backward compatibility: prior releases surfaced `tags` here, + // so anything referencing `metadata.tags` keeps working. The top-level `tags` + // row field (see `logPayload`) is what Braintrust surfaces as first-class, + // filterable tags; this copy is redundant-but-harmless metadata. + if (exported.tags && exported.tags.length > 0) { + out.tags = exported.tags; + } if (exported.requestContext && isObject(exported.requestContext)) { out.request_context = exported.requestContext; }