diff --git a/.changeset/mastra-tags-export.md b/.changeset/mastra-tags-export.md new file mode 100644 index 000000000..c68730960 --- /dev/null +++ b/.changeset/mastra-tags-export.md @@ -0,0 +1,22 @@ +--- +"braintrust": patch +--- + +fix(mastra): Emit Mastra tags as first-class Braintrust tags + +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 new file mode 100644 index 000000000..f88369d45 --- /dev/null +++ b/js/src/wrappers/mastra.test.ts @@ -0,0 +1,104 @@ +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 the top-level `tags` field as first-class tags. + expect(root.tags).toEqual(["production", "beta"]); + // 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"]); + + // Top-level `tags` are trace-level, so non-root spans carry none. + const child = byName("tool call"); + expect(child).toBeDefined(); + expect(child.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 9946a9643..91f7cf88b 100644 --- a/js/src/wrappers/mastra.ts +++ b/js/src/wrappers/mastra.ts @@ -186,6 +186,10 @@ function buildMetadata(exported: MastraExportedSpan): Record { if (value !== undefined) out[key] = value; } } + // 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; } @@ -351,6 +355,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); }