Skip to content

Commit 2b0936b

Browse files
roaminroclaude
andauthored
fix(core): preserve tracingOptions.tags when merging defaultOptions (#12220)
Tags set in `defaultOptions.tracingOptions.tags` were being lost when merging with call-site options due to shallow spreading. Now using `deepMerge` to properly preserve nested properties. **Before (broken):** ```typescript const agent = new Agent({ defaultOptions: { tracingOptions: { tags: ['default-tag'] } } }); // Passing any tracingOptions property would lose the default tags await agent.generate(messages, { tracingOptions: { metadata: { userId: '123' } } }); // tags: undefined ❌ ``` **After (fixed):** ```typescript // Same setup - tags are now preserved await agent.generate(messages, { tracingOptions: { metadata: { userId: '123' } } }); // tags: ['default-tag'] ✓ ``` This fix works for all observability exporters (Langfuse, Langsmith, Braintrust, Datadog, Sentry, Laminar, PostHog) since it's in the core agent code. NOTE: tested with langfuse cloud and brainstrust cloud Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 03bb0e6 commit 2b0936b

File tree

4 files changed

+258
-31
lines changed

4 files changed

+258
-31
lines changed

.changeset/green-singers-share.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@mastra/observability': patch
3+
'@mastra/core': patch
4+
---
5+
6+
Fixed tracingOptions.tags not being preserved when merging defaultOptions with call-site options. Tags set in agent's defaultOptions.tracingOptions are now correctly passed to all observability exporters (Langfuse, Langsmith, Braintrust, Datadog, etc.). Fixes #12209.

observability/mastra/src/integration-tests.test.ts

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2241,4 +2241,220 @@ describe('Tracing Integration Tests', () => {
22412241
});
22422242
},
22432243
);
2244+
2245+
/**
2246+
* Tests that tags set in tracingOptions (either via defaultOptions or in generate/stream call)
2247+
* are properly passed through to the exported spans and received by exporters.
2248+
*/
2249+
describe('tracingOptions.tags support (Issue #12209)', () => {
2250+
it('should pass tags from defaultOptions.tracingOptions to exported spans', async () => {
2251+
const testAgent = new Agent({
2252+
id: 'test-agent-with-tags',
2253+
name: 'Test Agent With Tags',
2254+
instructions: 'You are a test agent',
2255+
model: mockModelV2,
2256+
defaultOptions: {
2257+
tracingOptions: {
2258+
tags: ['production', 'test-tag', 'experiment-v1'],
2259+
},
2260+
},
2261+
});
2262+
2263+
const mastra = new Mastra({
2264+
...getBaseMastraConfig(testExporter),
2265+
agents: { testAgent },
2266+
});
2267+
2268+
const agent = mastra.getAgent('testAgent');
2269+
// Call generate WITHOUT passing tracingOptions - should use defaultOptions
2270+
const result = await agent.generate('Hello');
2271+
2272+
expect(result.text).toBeDefined();
2273+
expect(result.traceId).toBeDefined();
2274+
2275+
const agentRunSpans = testExporter.getSpansByType(SpanType.AGENT_RUN);
2276+
expect(agentRunSpans.length).toBe(1);
2277+
2278+
const agentRunSpan = agentRunSpans[0];
2279+
expect(agentRunSpan?.isRootSpan).toBe(true);
2280+
expect(agentRunSpan?.traceId).toBe(result.traceId);
2281+
2282+
// CRITICAL: Tags from defaultOptions.tracingOptions should appear on the root span
2283+
expect(agentRunSpan?.tags).toBeDefined();
2284+
expect(agentRunSpan?.tags).toEqual(['production', 'test-tag', 'experiment-v1']);
2285+
2286+
testExporter.finalExpectations();
2287+
});
2288+
2289+
it('should pass tags from generate call tracingOptions to exported spans', async () => {
2290+
const testAgent = new Agent({
2291+
id: 'test-agent-tags-call',
2292+
name: 'Test Agent Tags Call',
2293+
instructions: 'You are a test agent',
2294+
model: mockModelV2,
2295+
});
2296+
2297+
const mastra = new Mastra({
2298+
...getBaseMastraConfig(testExporter),
2299+
agents: { testAgent },
2300+
});
2301+
2302+
const agent = mastra.getAgent('testAgent');
2303+
// Call generate WITH explicit tracingOptions.tags
2304+
const result = await agent.generate('Hello', {
2305+
tracingOptions: {
2306+
tags: ['call-tag-1', 'call-tag-2'],
2307+
},
2308+
});
2309+
2310+
expect(result.text).toBeDefined();
2311+
expect(result.traceId).toBeDefined();
2312+
2313+
const agentRunSpans = testExporter.getSpansByType(SpanType.AGENT_RUN);
2314+
expect(agentRunSpans.length).toBe(1);
2315+
2316+
const agentRunSpan = agentRunSpans[0];
2317+
expect(agentRunSpan?.isRootSpan).toBe(true);
2318+
expect(agentRunSpan?.traceId).toBe(result.traceId);
2319+
2320+
// Tags from the generate call should appear on the root span
2321+
expect(agentRunSpan?.tags).toBeDefined();
2322+
expect(agentRunSpan?.tags).toEqual(['call-tag-1', 'call-tag-2']);
2323+
2324+
testExporter.finalExpectations();
2325+
});
2326+
2327+
it('should merge tags from defaultOptions and generate call tracingOptions', async () => {
2328+
const testAgent = new Agent({
2329+
id: 'test-agent-merge-tags',
2330+
name: 'Test Agent Merge Tags',
2331+
instructions: 'You are a test agent',
2332+
model: mockModelV2,
2333+
defaultOptions: {
2334+
tracingOptions: {
2335+
tags: ['default-tag'],
2336+
metadata: { source: 'default' },
2337+
},
2338+
},
2339+
});
2340+
2341+
const mastra = new Mastra({
2342+
...getBaseMastraConfig(testExporter),
2343+
agents: { testAgent },
2344+
});
2345+
2346+
const agent = mastra.getAgent('testAgent');
2347+
// Call generate with additional tracingOptions - tags should be merged or call-site should win
2348+
// The current behavior (shallow merge) will lose the default tags entirely
2349+
// This test documents the expected behavior: call-site tags override defaults
2350+
const result = await agent.generate('Hello', {
2351+
tracingOptions: {
2352+
tags: ['call-tag'],
2353+
metadata: { source: 'call' },
2354+
},
2355+
});
2356+
2357+
expect(result.text).toBeDefined();
2358+
expect(result.traceId).toBeDefined();
2359+
2360+
const agentRunSpans = testExporter.getSpansByType(SpanType.AGENT_RUN);
2361+
expect(agentRunSpans.length).toBe(1);
2362+
2363+
const agentRunSpan = agentRunSpans[0];
2364+
2365+
// With shallow merge, call-site tracingOptions completely replaces defaultOptions.tracingOptions
2366+
// So we expect only the call-site tags to be present
2367+
expect(agentRunSpan?.tags).toBeDefined();
2368+
expect(agentRunSpan?.tags).toEqual(['call-tag']);
2369+
2370+
testExporter.finalExpectations();
2371+
});
2372+
2373+
it('should preserve defaultOptions.tracingOptions.tags when call passes other tracingOptions properties', async () => {
2374+
const testAgent = new Agent({
2375+
id: 'test-agent-preserve-tags',
2376+
name: 'Test Agent Preserve Tags',
2377+
instructions: 'You are a test agent',
2378+
model: mockModelV2,
2379+
defaultOptions: {
2380+
tracingOptions: {
2381+
tags: ['preserve-this-tag'],
2382+
},
2383+
},
2384+
});
2385+
2386+
const mastra = new Mastra({
2387+
...getBaseMastraConfig(testExporter),
2388+
agents: { testAgent },
2389+
});
2390+
2391+
const agent = mastra.getAgent('testAgent');
2392+
// Call generate with tracingOptions that has metadata but NO tags
2393+
// BUG: The shallow merge will replace the entire tracingOptions object,
2394+
// causing the tags from defaultOptions to be lost
2395+
const result = await agent.generate('Hello', {
2396+
tracingOptions: {
2397+
metadata: { someKey: 'someValue' },
2398+
},
2399+
});
2400+
2401+
expect(result.text).toBeDefined();
2402+
expect(result.traceId).toBeDefined();
2403+
2404+
const agentRunSpans = testExporter.getSpansByType(SpanType.AGENT_RUN);
2405+
expect(agentRunSpans.length).toBe(1);
2406+
2407+
const agentRunSpan = agentRunSpans[0];
2408+
2409+
// EXPECTED: Tags from defaultOptions should be preserved when call doesn't specify tags
2410+
// ACTUAL BUG: Tags are undefined because shallow merge replaces entire tracingOptions
2411+
expect(agentRunSpan?.tags).toBeDefined();
2412+
expect(agentRunSpan?.tags).toEqual(['preserve-this-tag']);
2413+
2414+
testExporter.finalExpectations();
2415+
});
2416+
2417+
it('should pass tags from stream call tracingOptions to exported spans', async () => {
2418+
const testAgent = new Agent({
2419+
id: 'test-agent-stream-tags',
2420+
name: 'Test Agent Stream Tags',
2421+
instructions: 'You are a test agent',
2422+
model: mockModelV2,
2423+
defaultOptions: {
2424+
tracingOptions: {
2425+
tags: ['stream-default-tag'],
2426+
},
2427+
},
2428+
});
2429+
2430+
const mastra = new Mastra({
2431+
...getBaseMastraConfig(testExporter),
2432+
agents: { testAgent },
2433+
});
2434+
2435+
const agent = mastra.getAgent('testAgent');
2436+
// Call stream without passing any options - should use defaultOptions
2437+
const result = await agent.stream('Hello');
2438+
// Consume stream to complete
2439+
let fullText = '';
2440+
for await (const chunk of result.textStream) {
2441+
fullText += chunk;
2442+
}
2443+
2444+
expect(fullText).toBeDefined();
2445+
expect(result.traceId).toBeDefined();
2446+
2447+
const agentRunSpans = testExporter.getSpansByType(SpanType.AGENT_RUN);
2448+
expect(agentRunSpans.length).toBe(1);
2449+
2450+
const agentRunSpan = agentRunSpans[0];
2451+
expect(agentRunSpan?.isRootSpan).toBe(true);
2452+
2453+
// Tags from defaultOptions.tracingOptions should appear on the root span
2454+
expect(agentRunSpan?.tags).toBeDefined();
2455+
expect(agentRunSpan?.tags).toEqual(['stream-default-tag']);
2456+
2457+
testExporter.finalExpectations();
2458+
});
2459+
});
22442460
});

packages/core/src/agent/agent.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ import type { FullOutput, MastraModelOutput } from '../stream/base/output';
4343
import { createTool } from '../tools';
4444
import type { CoreTool } from '../tools/types';
4545
import type { DynamicArgument } from '../types';
46-
import { makeCoreTool, createMastraProxy, ensureToolProperties, isZodType } from '../utils';
46+
import { makeCoreTool, createMastraProxy, ensureToolProperties, isZodType, deepMerge } from '../utils';
4747
import type { ToolOptions } from '../utils';
4848
import type { MastraVoice } from '../voice';
4949
import { DefaultVoice } from '../voice';
@@ -3322,10 +3322,10 @@ export class Agent<
33223322
const defaultOptions = await this.getDefaultOptions({
33233323
requestContext: options?.requestContext,
33243324
});
3325-
const mergedOptions = {
3326-
...defaultOptions,
3327-
...(options ?? {}),
3328-
} as unknown as AgentExecutionOptions<any>;
3325+
const mergedOptions = deepMerge(
3326+
defaultOptions as Record<string, unknown>,
3327+
(options ?? {}) as Record<string, unknown>,
3328+
) as AgentExecutionOptions<any>;
33293329

33303330
const llm = await this.getLLM({
33313331
requestContext: mergedOptions.requestContext,
@@ -3412,10 +3412,10 @@ export class Agent<
34123412
const defaultOptions = await this.getDefaultOptions({
34133413
requestContext: streamOptions?.requestContext,
34143414
});
3415-
const mergedOptions = {
3416-
...defaultOptions,
3417-
...(streamOptions ?? {}),
3418-
} as unknown as AgentExecutionOptions<OUTPUT>;
3415+
const mergedOptions = deepMerge(
3416+
defaultOptions as Record<string, unknown>,
3417+
(streamOptions ?? {}) as Record<string, unknown>,
3418+
) as AgentExecutionOptions<OUTPUT>;
34193419

34203420
const llm = await this.getLLM({
34213421
requestContext: mergedOptions.requestContext,
@@ -3513,10 +3513,10 @@ export class Agent<
35133513
requestContext: streamOptions?.requestContext,
35143514
});
35153515

3516-
let mergedStreamOptions = {
3517-
...defaultOptions,
3518-
...streamOptions,
3519-
};
3516+
let mergedStreamOptions = deepMerge(
3517+
defaultOptions as Record<string, unknown>,
3518+
(streamOptions ?? {}) as Record<string, unknown>,
3519+
) as typeof defaultOptions;
35203520

35213521
const llm = await this.getLLM({
35223522
requestContext: mergedStreamOptions.requestContext,
@@ -3591,10 +3591,10 @@ export class Agent<
35913591
requestContext: options?.requestContext,
35923592
});
35933593

3594-
const mergedOptions = {
3595-
...defaultOptions,
3596-
...(options ?? {}),
3597-
};
3594+
const mergedOptions = deepMerge(
3595+
defaultOptions as Record<string, unknown>,
3596+
(options ?? {}) as Record<string, unknown>,
3597+
) as typeof defaultOptions;
35983598

35993599
const llm = await this.getLLM({
36003600
requestContext: mergedOptions.requestContext,

packages/core/src/utils.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,28 +24,33 @@ export { getZodTypeName, getZodDef, isZodArray, isZodObject } from './utils/zod-
2424
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
2525

2626
/**
27-
* Deep merges two objects, recursively merging nested objects and arrays
27+
* Checks if a value is a plain object (not an array, function, Date, RegExp, etc.)
28+
*/
29+
function isPlainObject(value: unknown): value is Record<string, unknown> {
30+
if (value === null || typeof value !== 'object') return false;
31+
const proto = Object.getPrototypeOf(value);
32+
return proto === Object.prototype || proto === null;
33+
}
34+
35+
/**
36+
* Deep merges two objects, recursively merging nested plain objects.
37+
* Arrays, functions, and other non-plain objects are replaced (not merged).
2838
*/
2939
export function deepMerge<T extends object = object>(target: T, source: Partial<T>): T {
3040
const output = { ...target };
3141

3242
if (!source) return output;
3343

3444
Object.keys(source).forEach(key => {
35-
const targetValue = output[key as keyof T];
36-
const sourceValue = source[key as keyof T];
37-
38-
if (Array.isArray(targetValue) && Array.isArray(sourceValue)) {
39-
(output as any)[key] = sourceValue;
40-
} else if (
41-
sourceValue instanceof Object &&
42-
targetValue instanceof Object &&
43-
!Array.isArray(sourceValue) &&
44-
!Array.isArray(targetValue)
45-
) {
46-
(output as any)[key] = deepMerge(targetValue, sourceValue as T);
45+
const targetValue = (output as Record<string, unknown>)[key];
46+
const sourceValue = (source as Record<string, unknown>)[key];
47+
48+
// Only deep merge if both values are plain objects
49+
if (isPlainObject(targetValue) && isPlainObject(sourceValue)) {
50+
(output as Record<string, unknown>)[key] = deepMerge(targetValue, sourceValue);
4751
} else if (sourceValue !== undefined) {
48-
(output as any)[key] = sourceValue;
52+
// For arrays, functions, primitives, and other non-plain objects: replace
53+
(output as Record<string, unknown>)[key] = sourceValue;
4954
}
5055
});
5156

0 commit comments

Comments
 (0)