Skip to content

Commit 96ff33c

Browse files
sergicalclaude
andauthored
fix(cloudflare): Set agent conversation id on the onRequest path (#22846)
An Agent that handles a plain HTTP request — a webhook, a REST endpoint — produces AI spans with no `gen_ai.conversation.id`, so Sentry can't group its turns into a conversation. Chat agents and `@callable()` RPC agents work fine. | How the agent is reached | Handler | `gen_ai.conversation.id` | | --- | --- | --- | | Chat | `onChatMessage` | set | | `@callable()` over WebSocket | `onMessage` | set | | HTTP | `onRequest` | **missing** — fixed here | We set the id inside the `onChatMessage` and `onMessage` proxies. An HTTP request touches neither: it arrives through the `obj.fetch` proxy in `durableobject.ts`, which never sets it. This PR wraps `onRequest` the same way the other two are wrapped. Review notes: - Wrapping after construction is safe — `agents` installs `onRequest` as an own property in the `Agent` constructor, same as `onMessage`. - It stays out of the `obj.fetch` proxy, which `instrumentDurableObjectWithSentry` shares — plain Durable Objects shouldn't run agent-specific code. `ai-streaming.test.ts` already drove both fixture agents over HTTP but never asserted this attribute, which is why CI was green. It does now, plus unit tests that fail without the `src` change. Confirmed on a deployed Worker. Closes #22845 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent eaaee16 commit 96ff33c

5 files changed

Lines changed: 75 additions & 3 deletions

File tree

dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/ai-streaming.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ function assertGenAiStreamingSpan(span: SerializedStreamedSpan): void {
2121
expect(span.attributes['gen_ai.usage.input_tokens']?.value).toBe(15);
2222
expect(span.attributes['gen_ai.usage.output_tokens']?.value).toBe(8);
2323
expect(span.attributes['gen_ai.usage.total_tokens']?.value).toBe(23);
24+
// Both agents are addressed as `.../test`, so the instance name is the conversation id.
25+
expect(span.attributes['gen_ai.conversation.id']?.value).toBe('test');
2426
}
2527

2628
test('captures Workers AI streaming output when driven via an Agent', async ({ request, baseURL }) => {

packages/cloudflare/src/instrumentations/agents/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { instrumentAgentCallableRpc } from './instrumentAgentCallableRpc';
2+
import { instrumentAgentRequestConversation } from './instrumentAgentRequestConversation';
23
import { instrumentChatAgentConversation } from './instrumentChatAgentConversation';
34
import type { AgentInternals } from './types';
45

@@ -8,9 +9,9 @@ import type { AgentInternals } from './types';
89
*
910
* - **Callable RPC spans** — a span (op `rpc`) for each `@callable()` method invoked over WebSocket.
1011
* - **Conversation correlation** — sets the conversation id on the scope for each unit of agent
11-
* work — chat turn or callable RPC call — so `gen_ai` spans created within it are correlated, for
12-
* chat and plain agents alike. Defaults to the instance `name` and is rotated when the chat is
13-
* cleared (the `message:clear` observability event).
12+
* work — chat turn, callable RPC call, or HTTP request — so `gen_ai` spans created within it are
13+
* correlated, for chat and plain agents alike. Defaults to the instance `name` and is rotated
14+
* when the chat is cleared (the `message:clear` observability event).
1415
*
1516
* It only hooks the `agents` package internals and uses Sentry's tracing primitives. On Cloudflare
1617
* Workers, prefer `instrumentAgentWithSentry`, which additionally instruments the Durable Object
@@ -29,6 +30,7 @@ export function instrumentCloudflareAgent<T extends object>(agent: T): T {
2930

3031
instrumentAgentCallableRpc(internals);
3132
instrumentChatAgentConversation(internals);
33+
instrumentAgentRequestConversation(internals);
3234

3335
return agent;
3436
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { type AgentInternals, setAgentConversationId } from './types';
2+
3+
/**
4+
* Correlates the AI spans of an HTTP-driven agent turn with a conversation id on the active scope.
5+
*
6+
* `onRequest` is the third unit of agent work, alongside chat turns and `@callable()` RPC: the
7+
* `agents` router sends every non-WebSocket request to it, which is how REST endpoints and webhooks
8+
* reach an agent.
9+
*
10+
* `agents` installs `onRequest` as an own property in the `Agent` constructor (as it does
11+
* `onMessage`), and we instrument after construction, so wrapping the own property is what the
12+
* router ends up calling.
13+
*/
14+
export function instrumentAgentRequestConversation(obj: AgentInternals): void {
15+
const original = obj.onRequest;
16+
17+
if (typeof original !== 'function') {
18+
return;
19+
}
20+
21+
obj.onRequest = new Proxy(original, {
22+
apply(target, thisArg: AgentInternals, args: unknown[]): unknown {
23+
setAgentConversationId(thisArg);
24+
25+
return Reflect.apply(target, thisArg, args);
26+
},
27+
});
28+
}

packages/cloudflare/src/instrumentations/agents/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export interface AgentInternals {
1919
* does not, so its presence discriminates a chat agent.
2020
*/
2121
onChatMessage?: (...args: unknown[]) => unknown;
22+
/** HTTP request handler; the router sends every non-WebSocket request here. */
23+
onRequest?: (...args: unknown[]) => unknown;
2224
/** The user's Agent class (used by the SDK for the observability event `agent` field). */
2325
_ParentClass?: { name?: string };
2426
/** The Agent instance name, which in the Agents model identifies the conversation/thread. */

packages/cloudflare/test/instrumentCloudflareAgent.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,5 +157,43 @@ describe('instrumentCloudflareAgent', () => {
157157
expect(agent.seenConversationId).toBe('instance-1');
158158
expect('onChatMessage' in agent).toBe(false);
159159
});
160+
161+
it('sets the conversation id during an HTTP request', () => {
162+
const agent = createFakeAgent({
163+
onRequest(this: any) {
164+
this.seenConversationId = getCurrentScope().getScopeData().conversationId;
165+
return 'response';
166+
},
167+
});
168+
instrumentCloudflareAgent(agent);
169+
170+
const result = agent.onRequest(new Request('https://example.com/agents/my-agent/instance-1'));
171+
172+
expect(result).toBe('response');
173+
expect(agent.seenConversationId).toBe('instance-1');
174+
});
175+
176+
it('prefers the rotated conversation id over the instance name on the HTTP path', () => {
177+
const agent = createFakeAgent({
178+
onRequest(this: any) {
179+
this.seenConversationId = getCurrentScope().getScopeData().conversationId;
180+
return 'response';
181+
},
182+
});
183+
instrumentCloudflareAgent(agent);
184+
185+
agent._emit?.('message:clear');
186+
agent.__sentryConversationId = 'rotated-id';
187+
agent.onRequest(new Request('https://example.com/agents/my-agent/instance-1'));
188+
189+
expect(agent.seenConversationId).toBe('rotated-id');
190+
});
191+
192+
it('does not throw when the agent has no onRequest handler', () => {
193+
const agent = createFakeAgent();
194+
195+
expect(() => instrumentCloudflareAgent(agent)).not.toThrow();
196+
expect('onRequest' in agent).toBe(false);
197+
});
160198
});
161199
});

0 commit comments

Comments
 (0)