Skip to content

Commit 7b0ad1f

Browse files
authored
feat(observability): route eval scores through ScoreEvent bus (#16185)
## Description Wires Mastra eval scores through the unified observability bus so every exporter implementing `onScoreEvent` automatically receives them. Replaces the deprecated per-exporter `addScoreToTrace` side-channel that bypassed the pipeline. Also unblocks Mastra spans appearing under Braintrust experiment eval spans (#11097) as a side effect of the unified path. - Producer alignment: `packages/core/src/mastra/hooks.ts` no longer iterates exporters with `addScoreToTrace`. `MastraScorer.run()` (already on main from PR #14920) remains the single producer of `ScoreEvent`s through `mastra.observability.addScore()`. - Consumer side: implemented `onScoreEvent` on Langfuse (`LangfuseClient.score.create`), Laminar (`/v1/evaluators/score`), Braintrust (`logger.logFeedback`), Datadog (`tracer.llmobs.submitEvaluation`), and LangSmith (`Client.createFeedback`). - Backwards compatibility: Langfuse `addScoreToTrace` and Laminar `_addScoreToTrace` are preserved as `@deprecated` wrappers that forward to a shared private `submitScore` helper. - Bug fix in `observability/mastra/src/recorded.ts`: `buildScoreEvent` now forwards `scorerName` and `targetEntityType` from `ScoreInput` to `ExportedScore` (without this, exporters silently fell back to `scorerId`). - Tests: rewrote core hook test to assert the hook no longer publishes a `ScoreEvent`; added per-exporter `onScoreEvent` tests; added a regression test for `buildScoreEvent` field forwarding. - Seven single-package changesets created (`@mastra/core`, `@mastra/observability`, `@mastra/langfuse`, `@mastra/laminar`, `@mastra/braintrust`, `@mastra/datadog`, `@mastra/langsmith`) per the project's changeset style guide. ## Related Issue(s) Fixes #10896 (also unblocks #11097) ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Code refactoring - [ ] Performance improvement - [ ] Test update ## Checklist - [ ] I have made corresponding changes to the documentation (if applicable) - [x] I have added tests that prove my fix is effective or that my feature works - [ ] I have addressed all Coderabbit comments on this PR <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## ELI5 When Mastra assigns an evaluation score, this PR sends that score exactly once into a single observability "bus" so all monitoring/exporter integrations (Langfuse, Laminar, Braintrust, Datadog, LangSmith, etc.) automatically receive it — avoiding exporter-specific wiring and duplicate deliveries. ## Overview Routes Mastra evaluation scores through the unified observability ScoreEvent bus (mastra.observability.addScore) so any exporter implementing onScoreEvent receives them. MastraScorer.run() remains the single producer of ScoreEvent; the core scorer hook no longer iterates exporters to call addScoreToTrace. Per-exporter addScoreToTrace/_addScoreToTrace behaviors are preserved as @deprecated wrappers that forward to shared submit helpers where applicable. Fixes a bug in buildScoreEvent so scorerName and targetEntityType from ScoreInput are forwarded into emitted ExportedScore. ## Changes by Component ### Core (@mastra/core) - Removed exporter-iteration and addScoreToTrace republishing from createOnScorerHook; the hook now only persists scores to the legacy scores store. - Ensures MastraScorer.run() is the single producer of ScoreEvent. - Registers static workflow-step scorers synchronously during addWorkflow and resolves dynamic scorers asynchronously so scorers are registered before use. - Updated core test to assert the hook does not emit ScoreEvent. ### Observability (@mastra/observability) - Fix: buildScoreEvent (observability/mastra/src/recorded.ts) now includes scorerName and targetEntityType in emitted ScoreEvent. - Bus routing: route-event now prefers onScoreEvent; if an exporter lacks onScoreEvent but implements deprecated addScoreToTrace, the bus maps the ScoreEvent into the legacy addScoreToTrace payload and calls it (avoids duplicate delivery when both exist). - Added unit tests for score routing behavior and the buildScoreEvent regression. ### Exporter Integrations (new onScoreEvent handlers + tests) - Langfuse (@mastra/langfuse) - Added onScoreEvent → LangfuseClient.score.create with a shared submitScore helper and centralized error logging. - Preserved addScoreToTrace as a deprecated wrapper forwarding to submitScore. - Tests updated to cover onScoreEvent and mark addScoreToTrace deprecated. - Laminar (@mastra/laminar) - Added onScoreEvent delegating to a private submitScore that POSTS to /v1/evaluators/score. - submitScore converts trace/span IDs to Laminar/OTel UUIDs, merges metadata/reason, enforces timeouts via AbortController, and logs non-OK/timed-out responses. - Preserved _addScoreToTrace as a @deprecated wrapper forwarding to submitScore. - Tests verify POST payload mapping and wrapper behavior. - Braintrust (@mastra/braintrust) - Added onScoreEvent that calls logger.logFeedback using spanId (falling back to traceId) and skips when neither is present. - Maps scorer name/ID, reason, and metadata into the feedback payload and logs failures. - Tests cover span/trace fallback and skip behavior. - Datadog (@mastra/datadog) - Added onScoreEvent: validates traceId/spanId, looks up exported dd-trace span context (traceState), and calls tracer.llmobs.submitEvaluation with mapped fields (label/value/metricType/mlApp/timestamp/reason/metadata). - Drops scores when identifiers or exported span context are missing and warns when scores arrive before the span is exported. - Tests cover successful submission, unknown IDs (no-op), and dropping out-of-order scores. - LangSmith (@mastra/langsmith) - Added onScoreEvent that submits Client.createFeedback when a spanId→LangSmith runId mapping exists. - Introduced a configurable bounded LRU spanId→runId cache (runIdCacheMaxEntries) populated from RunTree building; evicts oldest entries when full and is cleared on shutdown. - Ensures authoritative scorerId/scoreSource are preserved in sourceInfo and ignores events when span/run mapping is absent. - Tests cover mapping semantics, eviction, metadata protection, and skipping unseen spans. ## Tests - Core hook test rewritten to assert the hook does not publish ScoreEvent. - Per-exporter onScoreEvent test suites added for Langfuse, Laminar, Braintrust, Datadog, and LangSmith covering field mapping, identifier fallbacks, ordering/dropping, timeout handling, and cache eviction. - Bus tests added to assert onScoreEvent preferred and legacy addScoreToTrace fallback behavior. - Regression test added to ensure buildScoreEvent forwards scorerName and targetEntityType. ## Backwards Compatibility - Deprecated addScoreToTrace/_addScoreToTrace preserved (where present) as wrappers that forward to the new onScoreEvent/submit helpers to avoid breaking existing callers. - Observability interface docs updated to deprecate addScoreToTrace in favor of onScoreEvent/ScoreEvent bus. - No breaking public API surface changes beyond documented additions and deprecations. ## Commits & Release Notes - Seven package-scoped changesets created for @mastra/core, @mastra/observability, @mastra/langfuse, @mastra/laminar, @mastra/langsmith, @mastra/datadog, and @mastra/braintrust documenting onScoreEvent support and the buildScoreEvent fix. - Exporter-specific notes documented (Datadog ordering caveat; Laminar timeout/error logging; LangSmith runId cache behavior). ## Related Issues Fixes #10896 and unblocks #11097. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 6068a6c commit 7b0ad1f

26 files changed

Lines changed: 829 additions & 247 deletions

File tree

.changeset/dark-rooms-build.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/langfuse': minor
3+
---
4+
5+
Mastra Eval results are now forwarded to Langfuse.

.changeset/five-stars-smile.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/core': minor
3+
---
4+
5+
Mastra Eval results are now emitted once through the unified observability score pipeline.

.changeset/giant-apes-accept.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/langsmith': minor
3+
---
4+
5+
Mastra Eval results are now forwarded to LangSmith.

.changeset/large-yaks-battle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/laminar': minor
3+
---
4+
5+
Mastra Eval results are now forwarded to Laminar.

.changeset/ready-socks-leave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/observability': patch
3+
---
4+
5+
Score events now include scorer names and target entity types.

.changeset/tiny-doodles-hang.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/braintrust': minor
3+
---
4+
5+
Mastra Eval results are now forwarded to Braintrust.

.changeset/violet-rules-design.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/datadog': minor
3+
---
4+
5+
Mastra Eval results are now forwarded to Datadog.

docs/src/content/en/reference/observability/tracing/interfaces.mdx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,12 @@ interface ObservabilityExporter {
156156
/** Export tracing events */
157157
exportTracingEvent(event: TracingEvent): Promise<void>
158158

159-
/** Add score to a trace (optional) */
159+
/**
160+
* @deprecated Implement `onScoreEvent` instead. Eval scores now flow through the
161+
* unified observability bus as `ScoreEvent`s. This method is preserved on the
162+
* interface for backwards compatibility with existing exporters; new exporters
163+
* should not implement it.
164+
*/
160165
addScoreToTrace?({
161166
traceId,
162167
spanId,

observability/braintrust/src/tracing.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2182,6 +2182,61 @@ describe('BraintrustExporter', () => {
21822182
expect(mockSpan.end).toHaveBeenCalledTimes(3);
21832183
});
21842184
});
2185+
2186+
describe('onScoreEvent', () => {
2187+
const baseScore = {
2188+
scoreId: 'score-1',
2189+
timestamp: new Date(),
2190+
traceId: 'trace-1',
2191+
spanId: 'span-1',
2192+
scorerId: 'accuracy',
2193+
scorerName: 'Accuracy',
2194+
scoreSource: 'live',
2195+
score: 0.9,
2196+
reason: 'good',
2197+
metadata: { sessionId: 's-1' },
2198+
};
2199+
2200+
it('forwards score events to logger.logFeedback keyed by spanId', async () => {
2201+
mockLogger.logFeedback = vi.fn();
2202+
mockInitLogger.mockResolvedValue(mockLogger);
2203+
2204+
await exporter.onScoreEvent({ type: 'score', score: { ...baseScore } } as any);
2205+
2206+
expect(mockLogger.logFeedback).toHaveBeenCalledTimes(1);
2207+
const arg = mockLogger.logFeedback.mock.calls[0][0];
2208+
expect(arg).toMatchObject({
2209+
id: 'span-1',
2210+
scores: { Accuracy: 0.9 },
2211+
comment: 'good',
2212+
source: 'external',
2213+
});
2214+
expect(arg.metadata).toMatchObject({ scorerId: 'accuracy', scoreSource: 'live', sessionId: 's-1' });
2215+
});
2216+
2217+
it('falls back to traceId when spanId is missing', async () => {
2218+
mockLogger.logFeedback = vi.fn();
2219+
mockInitLogger.mockResolvedValue(mockLogger);
2220+
2221+
await exporter.onScoreEvent({
2222+
type: 'score',
2223+
score: { ...baseScore, spanId: undefined },
2224+
} as any);
2225+
2226+
expect(mockLogger.logFeedback).toHaveBeenCalledWith(expect.objectContaining({ id: 'trace-1' }));
2227+
});
2228+
2229+
it('skips when both spanId and traceId are missing', async () => {
2230+
mockLogger.logFeedback = vi.fn();
2231+
2232+
await exporter.onScoreEvent({
2233+
type: 'score',
2234+
score: { ...baseScore, spanId: undefined, traceId: undefined },
2235+
} as any);
2236+
2237+
expect(mockLogger.logFeedback).not.toHaveBeenCalled();
2238+
});
2239+
});
21852240
});
21862241

21872242
// ==============================================================================

observability/braintrust/src/tracing.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* Events are handled as zero-duration spans with matching start/end times.
77
*/
88

9-
import type { AnyExportedSpan, ModelGenerationAttributes, SpanErrorInfo } from '@mastra/core/observability';
9+
import type { AnyExportedSpan, ModelGenerationAttributes, ScoreEvent, SpanErrorInfo } from '@mastra/core/observability';
1010
import { SpanType } from '@mastra/core/observability';
1111
import { omitKeys } from '@mastra/core/utils';
1212
import { TrackingExporter } from '@mastra/observability';
@@ -134,6 +134,45 @@ export class BraintrustExporter extends TrackingExporter<
134134
}
135135
}
136136

137+
async onScoreEvent(event: ScoreEvent): Promise<void> {
138+
if (this.isDisabled) return;
139+
140+
const { score } = event;
141+
const rowId = score.spanId ?? score.traceId;
142+
if (!rowId) {
143+
this.logger.debug('Braintrust exporter: skipping score with no spanId or traceId', {
144+
scorerId: score.scorerId,
145+
});
146+
return;
147+
}
148+
149+
const logger = this.#useProvidedLogger ? this.#providedLogger : await this.getLocalLogger();
150+
if (!logger) return;
151+
152+
const name = score.scorerName ?? score.scorerId;
153+
154+
try {
155+
logger.logFeedback({
156+
id: rowId,
157+
scores: { [name]: score.score },
158+
...(score.reason ? { comment: score.reason } : {}),
159+
metadata: {
160+
scorerId: score.scorerId,
161+
...(score.scoreSource ? { scoreSource: score.scoreSource } : {}),
162+
...(score.metadata ?? {}),
163+
},
164+
source: 'external',
165+
});
166+
} catch (err) {
167+
this.logger.error('Braintrust exporter: Failed to submit score', {
168+
error: err,
169+
traceId: score.traceId,
170+
spanId: score.spanId,
171+
scorerId: score.scorerId,
172+
});
173+
}
174+
}
175+
137176
private startSpan(args: { parent: Span | Logger<true>; span: AnyExportedSpan }): BraintrustSpanData {
138177
const { parent, span } = args;
139178
const payload = this.buildSpanPayload(span);

0 commit comments

Comments
 (0)