From a27ebf6e02dfbf44e63e503b821dbf19928ee0b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Tue, 7 Jul 2026 16:54:45 -0700 Subject: [PATCH] fix(mastra): use Mastra span id as Braintrust row id for logFeedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BraintrustObservabilityExporter.onStart let SpanImpl auto-generate the row id (this._id = eventId ?? idGenerator.getSpanId()), so the id was unknowable to anyone outside the exporter. A Mastra user calling logger.logFeedback({ id: }) — or Mastra's score-event bus forwarding score.spanId — targeted a row that didn't exist, and the feedback landed as a stray row instead of merging into the generation it was meant to score. Pass event: { id: exported.id } on startSpan so the Mastra span id is used as the Braintrust row id, matching the upstream @mastra/braintrust fix (mastra-ai/mastra#11927, fixes #11899). The Braintrust span_id is left auto-generated so W3C trace-context propagation still works for subspans; only the row id (which logFeedback/mergeRowBatch key on) is aliased to the Mastra span id. Adds js/src/wrappers/mastra.test.ts covering both the row-id aliasing and a subsequent logFeedback merge. Refs: mastra-ai/mastra#11927 --- .changeset/mastra-logfeedback-row-id.md | 5 ++ js/src/wrappers/mastra.test.ts | 81 +++++++++++++++++++++++++ js/src/wrappers/mastra.ts | 6 ++ 3 files changed, 92 insertions(+) create mode 100644 .changeset/mastra-logfeedback-row-id.md create mode 100644 js/src/wrappers/mastra.test.ts diff --git a/.changeset/mastra-logfeedback-row-id.md b/.changeset/mastra-logfeedback-row-id.md new file mode 100644 index 000000000..0f5230ca1 --- /dev/null +++ b/.changeset/mastra-logfeedback-row-id.md @@ -0,0 +1,5 @@ +--- +"braintrust": patch +--- + +fix(mastra): Use the Mastra span id as the Braintrust row id so `logFeedback` attaches to the right row instead of landing as a stray. Matches the upstream `@mastra/braintrust` fix (mastra-ai/mastra#11927). diff --git a/js/src/wrappers/mastra.test.ts b/js/src/wrappers/mastra.test.ts new file mode 100644 index 000000000..dcced568b --- /dev/null +++ b/js/src/wrappers/mastra.test.ts @@ -0,0 +1,81 @@ +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 ExportedSpan = Parameters< + BraintrustObservabilityExporter["exportTracingEvent"] +>[0]["exportedSpan"]; + +const span = (overrides: Partial): ExportedSpan => ({ + id: "span-1", + traceId: "trace-1", + name: "agent run", + type: "agent_run", + startTime: 1_000_000, + ...overrides, +}); + +describe("BraintrustObservabilityExporter", () => { + let backgroundLogger: ReturnType< + typeof _exportsForTestingOnly.useTestBackgroundLogger + >; + let logger: ReturnType; + + beforeAll(async () => { + await _exportsForTestingOnly.simulateLoginForTests(); + }); + + beforeEach(() => { + backgroundLogger = _exportsForTestingOnly.useTestBackgroundLogger(); + logger = initLogger({ + projectId: "test-project-id", + projectName: "mastra.test.ts", + }); + }); + + afterEach(() => { + _exportsForTestingOnly.clearTestBackgroundLogger(); + }); + + test("logFeedback keyed on the Mastra span id merges into the span row", async () => { + const exporter = new BraintrustObservabilityExporter(); + const id = "mastra-span-123"; + + await exporter.exportTracingEvent({ + type: "span_started", + exportedSpan: span({ id }), + }); + await exporter.exportTracingEvent({ + type: "span_ended", + exportedSpan: span({ id, endTime: 1_000_001 }), + }); + // A Mastra user (or Mastra's score-event bus) knows only the Mastra span id. + logger.logFeedback({ id, scores: { quality: 0.9 }, source: "external" }); + + await backgroundLogger.flush(); + const rows = (await backgroundLogger.drain()) as any[]; + + // The exporter aliases the row id to the Mastra span id, so the feedback + // merge row keys onto the span row and `mergeRowBatch` collapses them into a + // single row carrying both the span and the score. Without the aliasing the + // feedback would land as a separate orphan row. + const merged = rows.filter((r) => r.id === id); + expect(merged).toHaveLength(1); + expect(merged[0].span_attributes?.name).toBe("agent run"); + expect(merged[0].scores?.quality).toBe(0.9); + }); +}); diff --git a/js/src/wrappers/mastra.ts b/js/src/wrappers/mastra.ts index 9946a9643..28bba3410 100644 --- a/js/src/wrappers/mastra.ts +++ b/js/src/wrappers/mastra.ts @@ -272,6 +272,12 @@ export class BraintrustObservabilityExporter implements MastraObservabilityExpor name: exported.name, spanAttributes: { type: spanTypeFor(exported.type) }, startTime: epochSeconds(exported.startTime), + // Use the Mastra span id as the Braintrust row id so that + // `logFeedback({ id: })` (and Mastra's score events) + // attach to the right row. Without this, `SpanImpl` auto-generates a + // row id (`this._id = eventId ?? idGenerator.getSpanId()`) that no + // external caller could know. + event: { id: exported.id }, }; const parentRecord = exported.parentSpanId