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); }