Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/mastra-tags-export.md
Original file line number Diff line number Diff line change
@@ -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).
3 changes: 3 additions & 0 deletions e2e/scenarios/mastra-instrumentation/scenario.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"] },
}),
);

Expand Down
34 changes: 34 additions & 0 deletions e2e/scenarios/mastra-instrumentation/scenario.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)?.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) => {
Expand Down
104 changes: 104 additions & 0 deletions js/src/wrappers/mastra.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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"]);
});
});
16 changes: 16 additions & 0 deletions js/src/wrappers/mastra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ function buildMetadata(exported: MastraExportedSpan): Record<string, unknown> {
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;
}
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Deduplicate Mastra tags before logging

When Mastra passes duplicate tracingOptions.tags values, forwarding the array directly to the top-level Braintrust tags field makes Span.log run validateTags(), which throws on duplicates. Because exportTracingEvent catches that exception, a duplicate-tagged root span_started fails before this.spans.set(...), so the Mastra root span is not tracked and later child/end events can be dropped or detached; this was previously only metadata and did not hit tag validation. Deduplicate or otherwise sanitize the top-level tags while preserving the metadata mirror.

Useful? React with 👍 / 👎.

}

if (Object.keys(event).length > 0) {
record.span.log(event);
}
Expand Down
Loading