Skip to content

Commit 2f5f58a

Browse files
epinzurclaudeintojhanurag
authored
Add client-side tool execution tracing (#16425)
## Description Implements end-to-end tracing for client-side tools executed via `@mastra/client-js`. When an agent calls a tool that runs in the browser, the server records a `CLIENT_TOOL_CALL` span under the current agent run and propagates W3C trace context to the client. The client SDK buffers child spans, logs, and execution duration while the tool runs, then sends that telemetry back on the next agent request. The server validates and ingests the payload through `@mastra/observability`, so configured exporters see the client-side work in the same trace as the agent run. ### Key changes **`@mastra/core`** - Adds `SpanType.CLIENT_TOOL_CALL` and client observability wire types. - Adds the `ClientObservabilityProxy` interface used by observability implementations. - Injects trace carriers into client tool-call chunks and preserves those carriers through stream output assembly and AI SDK transforms. - Creates the `CLIENT_TOOL_CALL` span early enough for streaming clients to receive the carrier, then ends it once tool args are available so the span shows which args the client tool received. - Extracts `__mastraObservability` from returned tool-result messages before model input, forwards it to the proxy, and strips the metadata. - Adds the `observe` helper to tool execution context with a no-op default. **`@mastra/client-js`** - Adds a browser-safe in-memory observability collector. - Automatically wraps client tool execution when a server trace carrier is present. - Provides `observe.span()` and `observe.log()` inside client tool `execute(input, context)`. - Sends buffered OTLP/JSON telemetry back with the tool result. - Keeps client tool continuation routing correct for `stream`, `resumeStream`, and `streamUntilIdle` flows. **`@mastra/observability`** - Implements `ClientObservabilityProxy.inject()` and `receive()`. - Validates W3C trace context, trace IDs, parent relationships, payload size, span count, and log count. - Maps client OTLP/JSON spans and logs into the existing observability bus. - Emits `mastra_tool_duration_ms` with `toolType: "client"` for the measured browser-side execution duration. ### Architecture notes Client-side tool execution spans two agent requests: 1. **Server to client:** the agent loop emits a client tool call, creates a `CLIENT_TOOL_CALL` span, injects a W3C carrier into the outgoing tool-call chunk, and later ends the span with tool args. 2. **Client execution:** `@mastra/client-js` runs the tool with an `observe` helper, buffering child spans, logs, and duration. 3. **Client to server:** the SDK attaches `__mastraObservability` to the tool-result message. The server extracts and strips that metadata before model input, then ingests it through the observability proxy. If `@mastra/observability` is not installed or no carrier is present, client-side tool tracing degrades to a no-op. ## Related issue(s) Fixes #10889 ## Type of change - [x] New feature (non-breaking change that adds functionality) ## Checklist - [x] I have linked the related issue(s) in the description above - [x] 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 - [x] I have addressed all Coderabbit comments on this PR --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Anurag Ojha <aojharaj2004@gmail.com> Co-authored-by: Anurag Ojha <160232626+intojhanurag@users.noreply.github.com>
1 parent 473da1b commit 2f5f58a

42 files changed

Lines changed: 3112 additions & 76 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@mastra/client-js': minor
3+
---
4+
5+
Client-side tool tracing is now built in. When server-side observability is configured, the SDK automatically measures execution duration and ships it back to the server. To add child spans and structured logs from inside your tool's `execute(input, context)` function, use the `observe` helper on the execution context:
6+
7+
```ts
8+
execute: async ({ userId }, { observe }) => {
9+
observe.log('info', 'fetching user', { userId })
10+
return observe.span('fetch user', () => fetch(`/api/users/${userId}`))
11+
}
12+
```
13+
14+
The `createTool()` helper now calls `execute(input, context)` so client tools receive the same execution context shape as core tools.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@mastra/core': minor
3+
---
4+
5+
Client-side tools now appear in your traces when observability is configured. When an agent calls a tool that executes in the browser via `@mastra/client-js`, a `CLIENT_TOOL_CALL` span is recorded on the server trace so you can see which client tools were invoked, what arguments they received, and how they relate to the rest of the agent run.
6+
7+
Tools also gain an `observe` helper on their execution context for recording child spans and logs from inside `execute`:
8+
9+
```ts
10+
execute: async ({ userId }, { observe }) => {
11+
observe.log('info', 'fetching user', { userId })
12+
return observe.span('fetch user', () => fetch(`/api/users/${userId}`))
13+
}
14+
```
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/observability': minor
3+
---
4+
5+
Support ingesting client-side tool telemetry. Spans, logs, and duration metrics captured by the client SDK during tool execution are forwarded through the observability bus to your existing exporters. Client tool durations are reported via the existing `mastra_tool_duration_ms` metric with a `toolType: 'client'` label to distinguish them from server-side tool durations.

client-sdks/client-js/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ export type {
1616
VerifyAgentCardSignatureOptions,
1717
} from './resources/a2a';
1818
export { RequestContext } from '@mastra/core/request-context';
19+
// ObservabilityCollector type is available for power users but most
20+
// users interact via `observe` on the tool execution context.
21+
export type { ObservabilityCollector } from './observability/types';
1922
export type { UIMessageWithMetadata } from '@mastra/core/agent';
2023
export type {
2124
Body,
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { createObservabilityCollector, getCurrentObservabilityCollector } from './collector';
4+
5+
const TRACE_ID = '11111111111111111111111111111111';
6+
const PARENT_SPAN_ID = 'aaaaaaaaaaaaaaaa';
7+
8+
function makeCarrier() {
9+
return { traceparent: `00-${TRACE_ID}-${PARENT_SPAN_ID}-01` };
10+
}
11+
12+
interface OtlpSpan {
13+
traceId: string;
14+
spanId: string;
15+
parentSpanId: string;
16+
name: string;
17+
startTimeUnixNano: string;
18+
endTimeUnixNano: string;
19+
status: { code: number; message?: string };
20+
attributes: unknown[];
21+
}
22+
23+
interface OtlpLogRecord {
24+
traceId: string;
25+
spanId: string;
26+
severityText: string;
27+
body: { stringValue: string };
28+
attributes: unknown[];
29+
}
30+
31+
function flushSpans(payload: ReturnType<ReturnType<typeof createObservabilityCollector>['flush']>) {
32+
const spans = (payload.spans as { resourceSpans: { scopeSpans: { spans: OtlpSpan[] }[] }[] } | undefined)
33+
?.resourceSpans?.[0]?.scopeSpans?.[0]?.spans;
34+
return spans ?? [];
35+
}
36+
37+
function flushLogs(payload: ReturnType<ReturnType<typeof createObservabilityCollector>['flush']>) {
38+
const logs = (payload.logs as { resourceLogs: { scopeLogs: { logRecords: OtlpLogRecord[] }[] }[] } | undefined)
39+
?.resourceLogs?.[0]?.scopeLogs?.[0]?.logRecords;
40+
return logs ?? [];
41+
}
42+
43+
describe('ObservabilityCollector', () => {
44+
it('exposes the original parentContext on the collector', () => {
45+
const carrier = makeCarrier();
46+
const collector = createObservabilityCollector(carrier);
47+
expect(collector.parentContext).toBe(carrier);
48+
});
49+
50+
it('captures a single span parented under the carrier spanId', async () => {
51+
const collector = createObservabilityCollector(makeCarrier());
52+
await collector.withContext(async () => {
53+
await collector.span('inner work', async () => 42);
54+
});
55+
const spans = flushSpans(collector.flush());
56+
expect(spans).toHaveLength(1);
57+
expect(spans[0]!.traceId).toBe(TRACE_ID);
58+
expect(spans[0]!.parentSpanId).toBe(PARENT_SPAN_ID);
59+
expect(spans[0]!.name).toBe('inner work');
60+
expect(spans[0]!.status.code).toBe(1);
61+
});
62+
63+
it('nests spans correctly when called recursively', async () => {
64+
const collector = createObservabilityCollector(makeCarrier());
65+
await collector.withContext(async () => {
66+
await collector.span('outer', async () => {
67+
await collector.span('inner', async () => 'ok');
68+
});
69+
});
70+
const spans = flushSpans(collector.flush());
71+
expect(spans).toHaveLength(2);
72+
const outer = spans.find(s => s.name === 'outer')!;
73+
const inner = spans.find(s => s.name === 'inner')!;
74+
expect(outer.parentSpanId).toBe(PARENT_SPAN_ID);
75+
expect(inner.parentSpanId).toBe(outer.spanId);
76+
});
77+
78+
it('records error status when the wrapped function throws', async () => {
79+
const collector = createObservabilityCollector(makeCarrier());
80+
await collector.withContext(async () => {
81+
await expect(
82+
collector.span('failing', async () => {
83+
throw new Error('boom');
84+
}),
85+
).rejects.toThrow('boom');
86+
});
87+
const spans = flushSpans(collector.flush());
88+
expect(spans).toHaveLength(1);
89+
expect(spans[0]!.status.code).toBe(2);
90+
expect(spans[0]!.status.message).toBe('boom');
91+
});
92+
93+
it('serializes attributes as OTLP tagged values', async () => {
94+
const collector = createObservabilityCollector(makeCarrier());
95+
await collector.withContext(async () => {
96+
await collector.span('with attrs', async () => null, {
97+
'http.method': 'GET',
98+
'http.status_code': 200,
99+
'cache.hit': true,
100+
'request.duration_ms': 12.5,
101+
});
102+
});
103+
const spans = flushSpans(collector.flush());
104+
const attrs = spans[0]!.attributes as Array<{ key: string; value: Record<string, unknown> }>;
105+
const byKey: Record<string, unknown> = {};
106+
for (const a of attrs) byKey[a.key] = a.value;
107+
expect(byKey['http.method']).toEqual({ stringValue: 'GET' });
108+
expect(byKey['http.status_code']).toEqual({ intValue: 200 });
109+
expect(byKey['cache.hit']).toEqual({ boolValue: true });
110+
expect(byKey['request.duration_ms']).toEqual({ doubleValue: 12.5 });
111+
});
112+
113+
it('captures logs against the active span', async () => {
114+
const collector = createObservabilityCollector(makeCarrier());
115+
await collector.withContext(async () => {
116+
collector.log('info', 'before span');
117+
await collector.span('work', async () => {
118+
collector.log('warn', 'inside span', { extra: 'context' });
119+
});
120+
});
121+
const logs = flushLogs(collector.flush());
122+
expect(logs).toHaveLength(2);
123+
expect(logs[0]!.spanId).toBe(PARENT_SPAN_ID);
124+
expect(logs[0]!.severityText).toBe('INFO');
125+
expect(logs[0]!.body.stringValue).toBe('before span');
126+
expect(logs[1]!.severityText).toBe('WARN');
127+
// The inner log should be parented under the work span, not the carrier.
128+
expect(logs[1]!.spanId).not.toBe(PARENT_SPAN_ID);
129+
});
130+
131+
it('measures wall-clock execution duration in withContext', async () => {
132+
const collector = createObservabilityCollector(makeCarrier());
133+
await collector.withContext(async () => {
134+
await new Promise(resolve => setTimeout(resolve, 25));
135+
});
136+
const payload = collector.flush();
137+
expect(payload.executionDurationMs).toBeDefined();
138+
// Allow some slack for slow CI; the floor is what matters.
139+
expect(payload.executionDurationMs!).toBeGreaterThanOrEqual(20);
140+
});
141+
142+
it('does not include duration when withContext was never called', () => {
143+
const collector = createObservabilityCollector(makeCarrier());
144+
const payload = collector.flush();
145+
expect(payload.executionDurationMs).toBeUndefined();
146+
});
147+
148+
it('flush() returns empty payload after first call', async () => {
149+
const collector = createObservabilityCollector(makeCarrier());
150+
await collector.withContext(async () => {
151+
await collector.span('once', async () => null);
152+
});
153+
const first = collector.flush();
154+
expect(flushSpans(first)).toHaveLength(1);
155+
const second = collector.flush();
156+
expect(second).toEqual({});
157+
});
158+
159+
it('returns empty payload when no spans or logs were captured', () => {
160+
const collector = createObservabilityCollector(makeCarrier());
161+
expect(collector.flush()).toEqual({});
162+
});
163+
164+
describe('getCurrentObservabilityCollector', () => {
165+
it('returns undefined outside withContext', () => {
166+
expect(getCurrentObservabilityCollector()).toBeUndefined();
167+
});
168+
169+
it('returns the active collector inside withContext', async () => {
170+
const collector = createObservabilityCollector(makeCarrier());
171+
let observed: ReturnType<typeof getCurrentObservabilityCollector> = undefined;
172+
await collector.withContext(async () => {
173+
observed = getCurrentObservabilityCollector();
174+
});
175+
expect(observed).toBe(collector);
176+
// Cleared after withContext returns.
177+
expect(getCurrentObservabilityCollector()).toBeUndefined();
178+
});
179+
180+
it('restores the previous collector after a nested withContext', async () => {
181+
const outer = createObservabilityCollector(makeCarrier());
182+
const inner = createObservabilityCollector(makeCarrier());
183+
await outer.withContext(async () => {
184+
expect(getCurrentObservabilityCollector()).toBe(outer);
185+
await inner.withContext(async () => {
186+
expect(getCurrentObservabilityCollector()).toBe(inner);
187+
});
188+
expect(getCurrentObservabilityCollector()).toBe(outer);
189+
});
190+
expect(getCurrentObservabilityCollector()).toBeUndefined();
191+
});
192+
193+
it('isolates overlapping async spans through explicit collector instances', async () => {
194+
const first = createObservabilityCollector(makeCarrier());
195+
const second = createObservabilityCollector(makeCarrier());
196+
let releaseFirst!: () => void;
197+
let releaseSecond!: () => void;
198+
199+
const firstRun = first.withContext(async () => {
200+
await first.span('first span', async () => {
201+
await new Promise<void>(resolve => {
202+
releaseFirst = resolve;
203+
});
204+
});
205+
});
206+
207+
const secondRun = second.withContext(async () => {
208+
await second.span('second span', async () => {
209+
await new Promise<void>(resolve => {
210+
releaseSecond = resolve;
211+
});
212+
});
213+
});
214+
215+
releaseFirst();
216+
await firstRun;
217+
218+
releaseSecond();
219+
await secondRun;
220+
expect(getCurrentObservabilityCollector()).toBeUndefined();
221+
222+
expect(flushSpans(first.flush()).map(span => span.name)).toEqual(['first span']);
223+
expect(flushSpans(second.flush()).map(span => span.name)).toEqual(['second span']);
224+
});
225+
});
226+
227+
it('degrades to a synthetic root when traceparent is malformed', async () => {
228+
const collector = createObservabilityCollector({ traceparent: 'not-a-traceparent' });
229+
await collector.withContext(async () => {
230+
await collector.span('orphan', async () => null);
231+
});
232+
const spans = flushSpans(collector.flush());
233+
expect(spans).toHaveLength(1);
234+
// Synthetic IDs are all zeros so the server-side ingest can detect
235+
// and reject these (it validates traceId match against the actual
236+
// parentContext).
237+
expect(spans[0]!.traceId).toBe('00000000000000000000000000000000');
238+
});
239+
240+
it('degrades to a synthetic root when traceparent components are W3C-invalid', async () => {
241+
for (const traceparent of [
242+
`ff-${TRACE_ID}-${PARENT_SPAN_ID}-01`,
243+
`00-00000000000000000000000000000000-${PARENT_SPAN_ID}-01`,
244+
`00-${TRACE_ID}-0000000000000000-01`,
245+
]) {
246+
const collector = createObservabilityCollector({ traceparent });
247+
await collector.withContext(async () => {
248+
await collector.span('orphan', async () => null);
249+
});
250+
const spans = flushSpans(collector.flush());
251+
expect(spans).toHaveLength(1);
252+
expect(spans[0]!.traceId).toBe('00000000000000000000000000000000');
253+
expect(spans[0]!.parentSpanId).toBe('0000000000000000');
254+
}
255+
});
256+
});

0 commit comments

Comments
 (0)