Skip to content

Commit b8837ee

Browse files
epinzurclaude
andauthored
Add requestContext snapshot to tracing spans (#14020)
## Description This PR adds automatic capture of `RequestContext` snapshots to tracing spans. Each span now stores a serialized snapshot of the active `RequestContext` at the time of span creation, making request-scoped values (user IDs, tenant IDs, feature flags, etc.) available when viewing traces. ### Key Changes - **Span Enhancement**: Added `requestContext` field to `BaseSpan` that captures a snapshot of the `RequestContext` when a span is created - **Serialization**: Request context values are serialized using `deepClean` to handle non-serializable values (functions are replaced with `'[Function]'`) - **Empty Context Handling**: Empty request contexts are not stored (undefined if no data) - **Child Span Support**: Child spans can optionally capture their own request context snapshots, allowing different context states at different span levels - **Database Schema Updates**: Added `requestContext` column to spans table across all storage backends (ClickHouse, LibSQL, MSSQL, PostgreSQL) with backwards compatibility via `alterTable` with `ifNotExists` - **Exporter Updates**: Updated exporters (Cloud, Default) to include `requestContext` in exported span records - **Type Updates**: Updated TypeScript interfaces and Zod schemas to include the new field ### Testing Added comprehensive test coverage including: - Root span request context capture - Exported span serialization - Child span context snapshots (with independent state capture) - Child spans without explicit context - Empty context handling - Non-serializable value filtering Integration tests verify request context propagation through agent generation with multiple span types. ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update - [x] Test update ## Checklist - [x] I have made corresponding changes to the documentation - [x] I have added tests that prove my feature works - [x] All existing tests pass https://claude.ai/code/session_01QaSLa999aBbRDenrGqtgic <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Request context snapshot is now captured in tracing spans, making request-scoped values (user IDs, tenant IDs, feature flags) available for enhanced observability. * **Documentation** * Updated tracing spans reference documentation. * **Tests** * Added comprehensive tests verifying request context propagation and serialization across traces. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8296332 commit b8837ee

24 files changed

Lines changed: 288 additions & 0 deletions

File tree

.changeset/blue-carrots-count.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@mastra/core': minor
3+
'@mastra/observability': minor
4+
---
5+
6+
Added `requestContext` field to tracing spans. Each span now automatically captures a snapshot of the active `RequestContext`, making request-scoped values like user IDs, tenant IDs, and feature flags available when viewing traces.

.changeset/slick-swans-train.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@mastra/clickhouse': minor
3+
'@mastra/libsql': minor
4+
'@mastra/mssql': minor
5+
'@mastra/pg': minor
6+
---
7+
8+
Added `requestContext` column to the spans table. Request context data from tracing is now persisted alongside other span data.

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ interface BaseSpan<TType extends SpanType> {
5454
details?: Record<string, any>
5555
}
5656

57+
/** Snapshot of the RequestContext */
58+
requestContext?: Record<string, any>
5759
/** Is an event span? (occurs at startTime, has no endTime) */
5860
isEvent: boolean
5961
}

observability/mastra/src/exporters/cloud.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ interface MastraCloudSpanRecord {
3030
spanType: string;
3131
attributes: Record<string, any> | null;
3232
metadata: Record<string, any> | null;
33+
requestContext: Record<string, any> | null;
3334
startedAt: Date;
3435
endedAt: Date | null;
3536
input: any;
@@ -119,6 +120,7 @@ export class CloudExporter extends BaseExporter {
119120
spanType: span.type,
120121
attributes: span.attributes ?? null,
121122
metadata: span.metadata ?? null,
123+
requestContext: span.requestContext ?? null,
122124
startedAt: span.startTime,
123125
endedAt: span.endTime ?? null,
124126
input: span.input ?? null,

observability/mastra/src/exporters/default.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,7 @@ export class DefaultExporter extends BaseExporter {
444444
input: span.input ?? null,
445445
output: span.output ?? null,
446446
error: span.errorInfo ?? null,
447+
requestContext: span.requestContext ?? null,
447448
isEvent: span.isEvent,
448449

449450
// Timestamps
@@ -463,6 +464,7 @@ export class DefaultExporter extends BaseExporter {
463464
input: span.input,
464465
output: span.output,
465466
error: span.errorInfo ?? null,
467+
requestContext: span.requestContext ?? null,
466468
};
467469
}
468470

observability/mastra/src/instances/base.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ export abstract class BaseObservabilityInstance extends MastraBase implements Ob
204204
metadata: enrichedMetadata,
205205
traceState,
206206
tags,
207+
requestContext,
207208
});
208209

209210
if (span.isEvent) {

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { MockLanguageModelV2, convertArrayToReadableStream } from '@internal/ai-
22
import { Agent } from '@mastra/core/agent';
33
import type { StructuredOutputOptions } from '@mastra/core/agent';
44
import type { MastraDBMessage } from '@mastra/core/agent/message-list';
5+
import { RequestContext } from '@mastra/core/di';
56
import { Mastra } from '@mastra/core/mastra';
67
import { SpanType } from '@mastra/core/observability';
78
import type { TracingContext } from '@mastra/core/observability';
@@ -1792,4 +1793,49 @@ describe('Tracing Integration Tests', () => {
17921793
await testExporter.assertMatchesSnapshot('tags-from-stream-default-options-trace.json');
17931794
});
17941795
});
1796+
1797+
describe('requestContext snapshot on spans', () => {
1798+
it('should propagate requestContext to all spans in agent generate', async () => {
1799+
const testAgent = new Agent({
1800+
id: 'test-agent-ctx',
1801+
name: 'Test Agent Ctx',
1802+
instructions: 'You are a test agent',
1803+
model: mockModelV2,
1804+
tools: { calculator: calculatorTool },
1805+
});
1806+
1807+
const mastra = new Mastra({
1808+
...getBaseMastraConfig(testExporter),
1809+
agents: { testAgent },
1810+
});
1811+
1812+
const requestContext = new RequestContext();
1813+
requestContext.set('userId', 'user-123');
1814+
requestContext.set('tenantId', 'tenant-456');
1815+
requestContext.set('environment', 'production');
1816+
1817+
const agent = mastra.getAgent('testAgent');
1818+
const result = await agent.generate('Calculate 5 + 3', { requestContext });
1819+
1820+
expect(result.text).toBeDefined();
1821+
expect(result.traceId).toBeDefined();
1822+
1823+
// Root AGENT_RUN span should have requestContext snapshot
1824+
const agentRunSpans = testExporter.getSpansByType(SpanType.AGENT_RUN);
1825+
expect(agentRunSpans).toHaveLength(1);
1826+
expect(agentRunSpans[0]?.requestContext).toEqual({
1827+
userId: 'user-123',
1828+
tenantId: 'tenant-456',
1829+
environment: 'production',
1830+
});
1831+
1832+
// Child spans (TOOL_CALL, MODEL_GENERATION) should also have requestContext
1833+
// since the framework passes requestContext when creating child spans
1834+
const allSpans = testExporter.getAllSpans();
1835+
const spansWithContext = allSpans.filter(s => s.requestContext);
1836+
expect(spansWithContext.length).toBeGreaterThanOrEqual(1);
1837+
1838+
finalExpectations(testExporter);
1839+
});
1840+
});
17951841
});

observability/mastra/src/spans/base.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ export abstract class BaseSpan<TType extends SpanType = any> implements Span<TTy
130130
details?: Record<string, any>;
131131
};
132132
public metadata?: Record<string, any>;
133+
public requestContext?: Record<string, any>;
133134
public tags?: string[];
134135
public traceState?: TraceState;
135136
/** Entity type that created the span (e.g., agent, workflow) */
@@ -152,6 +153,9 @@ export abstract class BaseSpan<TType extends SpanType = any> implements Span<TTy
152153
this.type = options.type;
153154
this.attributes = deepClean(options.attributes, this.deepCleanOptions) || ({} as SpanTypeMap[TType]);
154155
this.metadata = deepClean(options.metadata, this.deepCleanOptions);
156+
if (options.requestContext && options.requestContext.size() > 0) {
157+
this.requestContext = deepClean(options.requestContext.all, this.deepCleanOptions);
158+
}
155159
this.parent = options.parent;
156160
this.startTime = options.startTime ?? new Date();
157161
this.observabilityInstance = observabilityInstance;
@@ -274,6 +278,7 @@ export abstract class BaseSpan<TType extends SpanType = any> implements Span<TTy
274278
input: hideInput ? undefined : this.input,
275279
output: hideOutput ? undefined : this.output,
276280
errorInfo: this.errorInfo,
281+
requestContext: this.requestContext,
277282
isEvent: this.isEvent,
278283
isRootSpan: this.isRootSpan,
279284
parentSpanId: this.getParentSpanId(includeInternalSpans),

observability/mastra/src/tracing.test.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1797,6 +1797,180 @@ describe('Tracing', () => {
17971797
});
17981798
});
17991799

1800+
describe('RequestContext Snapshot on Spans', () => {
1801+
it('should store serialized requestContext on root span', () => {
1802+
const observability = new DefaultObservabilityInstance({
1803+
serviceName: 'test-service',
1804+
name: 'test',
1805+
exporters: [testExporter],
1806+
});
1807+
1808+
const requestContext = new RequestContext();
1809+
requestContext.set('userId', 'user-123');
1810+
requestContext.set('tenantId', 'tenant-456');
1811+
1812+
const span = observability.startSpan({
1813+
type: SpanType.AGENT_RUN,
1814+
name: 'test-agent',
1815+
attributes: {},
1816+
requestContext,
1817+
});
1818+
1819+
expect(span.requestContext).toEqual({
1820+
userId: 'user-123',
1821+
tenantId: 'tenant-456',
1822+
});
1823+
1824+
span.end();
1825+
});
1826+
1827+
it('should include requestContext in exported span', () => {
1828+
const observability = new DefaultObservabilityInstance({
1829+
serviceName: 'test-service',
1830+
name: 'test',
1831+
exporters: [testExporter],
1832+
});
1833+
1834+
const requestContext = new RequestContext();
1835+
requestContext.set('userId', 'user-123');
1836+
1837+
const span = observability.startSpan({
1838+
type: SpanType.AGENT_RUN,
1839+
name: 'test-agent',
1840+
attributes: {},
1841+
requestContext,
1842+
});
1843+
1844+
const exported = span.exportSpan();
1845+
expect(exported?.requestContext).toEqual({
1846+
userId: 'user-123',
1847+
});
1848+
1849+
span.end();
1850+
});
1851+
1852+
it('should store requestContext on child spans when passed', () => {
1853+
const observability = new DefaultObservabilityInstance({
1854+
serviceName: 'test-service',
1855+
name: 'test',
1856+
exporters: [testExporter],
1857+
});
1858+
1859+
const requestContext = new RequestContext();
1860+
requestContext.set('userId', 'user-123');
1861+
1862+
const rootSpan = observability.startSpan({
1863+
type: SpanType.AGENT_RUN,
1864+
name: 'test-agent',
1865+
attributes: {},
1866+
requestContext,
1867+
});
1868+
1869+
// Mutate requestContext before creating child
1870+
requestContext.set('stepData', 'step-specific');
1871+
1872+
const childSpan = rootSpan.createChildSpan({
1873+
type: SpanType.TOOL_CALL,
1874+
name: 'tool-call',
1875+
attributes: {},
1876+
requestContext,
1877+
});
1878+
1879+
// Child should capture the mutated state
1880+
expect(childSpan.requestContext).toEqual({
1881+
userId: 'user-123',
1882+
stepData: 'step-specific',
1883+
});
1884+
1885+
// Root should still have the original snapshot
1886+
expect(rootSpan.requestContext).toEqual({
1887+
userId: 'user-123',
1888+
});
1889+
1890+
rootSpan.end();
1891+
});
1892+
1893+
it('should not include requestContext on child spans when not passed', () => {
1894+
const observability = new DefaultObservabilityInstance({
1895+
serviceName: 'test-service',
1896+
name: 'test',
1897+
exporters: [testExporter],
1898+
});
1899+
1900+
const requestContext = new RequestContext();
1901+
requestContext.set('userId', 'user-123');
1902+
1903+
const rootSpan = observability.startSpan({
1904+
type: SpanType.AGENT_RUN,
1905+
name: 'test-agent',
1906+
attributes: {},
1907+
requestContext,
1908+
});
1909+
1910+
const childSpan = rootSpan.createChildSpan({
1911+
type: SpanType.MODEL_GENERATION,
1912+
name: 'llm-call',
1913+
attributes: {},
1914+
// No requestContext passed
1915+
});
1916+
1917+
expect(childSpan.requestContext).toBeUndefined();
1918+
1919+
rootSpan.end();
1920+
});
1921+
1922+
it('should not include requestContext when empty', () => {
1923+
const observability = new DefaultObservabilityInstance({
1924+
serviceName: 'test-service',
1925+
name: 'test',
1926+
exporters: [testExporter],
1927+
});
1928+
1929+
const requestContext = new RequestContext();
1930+
// Empty requestContext
1931+
1932+
const span = observability.startSpan({
1933+
type: SpanType.AGENT_RUN,
1934+
name: 'test-agent',
1935+
attributes: {},
1936+
requestContext,
1937+
});
1938+
1939+
expect(span.requestContext).toBeUndefined();
1940+
1941+
span.end();
1942+
});
1943+
1944+
it('should filter non-serializable values from requestContext', () => {
1945+
const observability = new DefaultObservabilityInstance({
1946+
serviceName: 'test-service',
1947+
name: 'test',
1948+
exporters: [testExporter],
1949+
});
1950+
1951+
const requestContext = new RequestContext();
1952+
requestContext.set('userId', 'user-123');
1953+
requestContext.set('callback', () => {});
1954+
requestContext.set('nested', { data: 'value' });
1955+
1956+
const span = observability.startSpan({
1957+
type: SpanType.AGENT_RUN,
1958+
name: 'test-agent',
1959+
attributes: {},
1960+
requestContext,
1961+
});
1962+
1963+
// Functions should be replaced with '[Function]' by deepClean
1964+
expect(span.requestContext).toEqual({
1965+
userId: 'user-123',
1966+
callback: '[Function]',
1967+
nested: { data: 'value' },
1968+
});
1969+
1970+
span.end();
1971+
});
1972+
});
1973+
18001974
describe('hideInput/hideOutput Support', () => {
18011975
it('should hide input from exported spans when hideInput is true', () => {
18021976
const observability = new DefaultObservabilityInstance({

packages/core/src/evals/scoreTraces/runScorerOnTarget.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ function createMockSpanRecord(overrides: Partial<SpanRecord> = {}): SpanRecord {
2929
metadata: {},
3030
links: null,
3131
error: null,
32+
requestContext: null,
3233
isEvent: false,
3334
...overrides,
3435
} as SpanRecord;

0 commit comments

Comments
 (0)