Skip to content

Commit f34539e

Browse files
committed
feat(v10/cloudflare): Rotate agent conversation id on chat clear
Backport of: #22720
1 parent 528f5bd commit f34539e

4 files changed

Lines changed: 212 additions & 6 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { AgentInternals } from './types';
1010
* - **Conversation correlation** — sets the conversation id on the scope for each unit of agent
1111
* work — chat turn or callable RPC call — so `gen_ai` spans created within it are correlated, for
1212
* chat and plain agents alike. Defaults to the instance `name` and is rotated when the chat is
13-
* cleared (`cf_agent_chat_clear`).
13+
* cleared (the `message:clear` observability event).
1414
*
1515
* It only hooks the `agents` package internals and uses Sentry's tracing primitives. On Cloudflare
1616
* Workers, prefer `instrumentAgentWithSentry`, which additionally instruments the Durable Object

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

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1+
import { uuid4 } from '@sentry/core';
12
import { type AgentInternals, setAgentConversationId } from './types';
23

34
/**
4-
* For chat agents (`AIChatAgent` from `@cloudflare/ai-chat`), sets the conversation id on the active
5-
* scope for the duration of a chat turn. In the Agents model one agent instance is one conversation,
6-
* so the instance `name` is the natural conversation id.
5+
* For chat agents (`AIChatAgent` from `@cloudflare/ai-chat`), correlates each chat turn's AI spans
6+
* with a conversation id on the active scope.
7+
*
8+
* In the Agents model one agent instance is one long-lived conversation, so the instance `name` is
9+
* the base conversation id. When the user clears the chat, they expect a fresh conversation — but
10+
* recreating the Durable Object for that would also drop the MCP/OAuth state stored per instance
11+
* (GitHub/Sentry sign-in). To get a fresh conversation id *without* losing that state, we rotate an
12+
* in-memory id on the instance when the SDK reports a cleared chat, and stamp that (falling back to
13+
* the instance `name` before the first clear).
714
*
815
* The id itself is not attached to spans here — the SDK's `conversationIdIntegration` reads it off
916
* the scope at `spanStart` and stamps `gen_ai.conversation.id` onto the AI spans created inside the
@@ -14,7 +21,26 @@ import { type AgentInternals, setAgentConversationId } from './types';
1421
* instead.
1522
*/
1623
export function instrumentChatAgentConversation(obj: AgentInternals): void {
24+
// Rotate the conversation id when the chat is cleared. `_emit` is the central choke-point through
25+
// which all `agents:*` observability events are published, and it already exists on the base
26+
// `Agent` class — so this hook composes with the RPC instrumentation, which keys off the same
27+
// surface. We shadow it with an own property so the original stays reachable on the prototype.
28+
const originalEmit = obj._emit;
29+
30+
if (typeof originalEmit === 'function') {
31+
obj._emit = new Proxy(originalEmit, {
32+
apply(target, thisArg: AgentInternals, args: [string, Record<string, unknown>?]) {
33+
if (args[0] === 'message:clear') {
34+
thisArg.__sentryConversationId = uuid4();
35+
}
36+
37+
return Reflect.apply(target, thisArg, args);
38+
},
39+
});
40+
}
41+
1742
const original = obj.onChatMessage;
43+
1844
if (typeof original !== 'function') {
1945
return;
2046
}

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ export interface AgentInternals {
2323
_ParentClass?: { name?: string };
2424
/** The Agent instance name, which in the Agents model identifies the conversation/thread. */
2525
name?: string;
26+
/**
27+
* Internal: the active conversation id, rotated when the chat is cleared (the `message:clear`
28+
* observability event) so a reset chat groups as a fresh conversation while the instance (and its
29+
* MCP/OAuth state) stays put. Set by `instrumentChatAgentConversation`; falls back to `name`
30+
* before the first clear.
31+
*/
32+
__sentryConversationId?: string;
2633
}
2734

2835
/** Reads best-effort agent identity attributes from the instance, tolerating missing internals. */
@@ -46,15 +53,17 @@ export function getAgentAttributes(instance: AgentInternals): Record<string, str
4653
* Sets the agent instance's conversation id on the current scope for the duration of the
4754
* surrounding unit of work (chat turn, callable RPC call). In the Agents model one instance is one
4855
* conversation, so the instance `name` is the natural conversation id — for chat and plain agents
49-
* alike, since plain agents run LLM calls too (e.g. inside `@callable()` methods).
56+
* alike, since plain agents run LLM calls too (e.g. inside `@callable()` methods). Once the chat
57+
* has been cleared, the rotated `__sentryConversationId` takes precedence so LLM calls from any
58+
* unit of work group under the fresh conversation.
5059
*
5160
* `conversationIdIntegration` reads the id off the scope at `spanStart` and stamps
5261
* `gen_ai.conversation.id` onto AI spans created within the unit of work, correlating its model
5362
* and tool calls. Callers run inside a per-event forked scope (`wrapMethodWithSentry`), so the id
5463
* does not leak into unrelated events.
5564
*/
5665
export function setAgentConversationId(instance: AgentInternals): void {
57-
const conversationId = instance.name;
66+
const conversationId = instance.__sentryConversationId ?? instance.name;
5867

5968
if (typeof conversationId === 'string' && conversationId) {
6069
getCurrentScope().setConversationId(conversationId);
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { getCurrentScope } from '@sentry/core';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
import { instrumentChatAgentConversation } from '../src/instrumentations/agents/instrumentChatAgentConversation';
4+
import type { AgentInternals } from '../src/instrumentations/agents/types';
5+
6+
describe('instrumentChatAgentConversation', () => {
7+
afterEach(() => {
8+
vi.restoreAllMocks();
9+
});
10+
11+
it('does not set conversation id when agent name is empty string', () => {
12+
const setConversationIdSpy = vi.spyOn(getCurrentScope(), 'setConversationId').mockImplementation(() => {});
13+
14+
const obj: AgentInternals = {
15+
name: '',
16+
onChatMessage() {
17+
return 'response';
18+
},
19+
};
20+
21+
instrumentChatAgentConversation(obj);
22+
23+
obj.onChatMessage!(() => {}, {});
24+
25+
expect(setConversationIdSpy).not.toHaveBeenCalled();
26+
});
27+
28+
it('does not set conversation id when agent name is undefined', () => {
29+
const setConversationIdSpy = vi.spyOn(getCurrentScope(), 'setConversationId').mockImplementation(() => {});
30+
31+
const obj: AgentInternals = {
32+
onChatMessage() {
33+
return 'response';
34+
},
35+
};
36+
37+
instrumentChatAgentConversation(obj);
38+
39+
obj.onChatMessage!(() => {}, {});
40+
41+
expect(setConversationIdSpy).not.toHaveBeenCalled();
42+
});
43+
44+
it('sets conversation id when agent name is present', () => {
45+
const setConversationId = vi.fn();
46+
vi.spyOn(getCurrentScope(), 'setConversationId').mockImplementation(setConversationId);
47+
48+
const obj: AgentInternals = {
49+
name: 'conversation-42',
50+
onChatMessage() {
51+
return 'response';
52+
},
53+
};
54+
55+
instrumentChatAgentConversation(obj);
56+
57+
obj.onChatMessage!(() => {}, {});
58+
59+
expect(setConversationId).toHaveBeenCalledWith('conversation-42');
60+
});
61+
62+
it('forwards the return value from the original onChatMessage', () => {
63+
const obj: AgentInternals = {
64+
name: 'convo-1',
65+
onChatMessage() {
66+
return { output: 'hello' };
67+
},
68+
};
69+
70+
instrumentChatAgentConversation(obj);
71+
72+
const result = obj.onChatMessage!(() => {}, {});
73+
74+
expect(result).toEqual({ output: 'hello' });
75+
});
76+
77+
it('leaves the agent untouched when onChatMessage is not defined', () => {
78+
const obj: AgentInternals = { name: 'agent-1' };
79+
80+
instrumentChatAgentConversation(obj);
81+
82+
expect('onChatMessage' in obj).toBe(false);
83+
});
84+
85+
it('uses the instance name as the conversation id before any clear', () => {
86+
const setConversationId = vi.fn();
87+
vi.spyOn(getCurrentScope(), 'setConversationId').mockImplementation(setConversationId);
88+
89+
const obj: AgentInternals = {
90+
name: 'session-7',
91+
_emit() {
92+
return undefined;
93+
},
94+
onChatMessage() {
95+
return 'response';
96+
},
97+
};
98+
99+
instrumentChatAgentConversation(obj);
100+
101+
obj.onChatMessage!(() => {}, {});
102+
103+
expect(setConversationId).toHaveBeenCalledWith('session-7');
104+
});
105+
106+
it('rotates the conversation id on the message:clear observability event (fresh id, not the instance name)', () => {
107+
const setConversationId = vi.fn();
108+
vi.spyOn(getCurrentScope(), 'setConversationId').mockImplementation(setConversationId);
109+
110+
const obj: AgentInternals = {
111+
name: 'session-7',
112+
_emit() {
113+
return undefined;
114+
},
115+
onChatMessage() {
116+
return 'response';
117+
},
118+
};
119+
120+
instrumentChatAgentConversation(obj);
121+
122+
// Simulate the SDK emitting the chat-clear observability event.
123+
obj._emit!('message:clear');
124+
125+
obj.onChatMessage!(() => {}, {});
126+
127+
expect(setConversationId).toHaveBeenCalledTimes(1);
128+
const rotated = setConversationId.mock.calls[0]?.[0] as string;
129+
expect(typeof rotated).toBe('string');
130+
expect(rotated).not.toBe('session-7');
131+
expect(obj.__sentryConversationId).toBe(rotated);
132+
});
133+
134+
it('forwards message:clear to the original _emit', () => {
135+
const received: Array<{ type: string; payload: unknown }> = [];
136+
const obj: AgentInternals = {
137+
name: 'session-7',
138+
_emit(type: string, payload?: Record<string, unknown>) {
139+
received.push({ type, payload });
140+
return undefined;
141+
},
142+
onChatMessage() {
143+
return 'response';
144+
},
145+
};
146+
147+
instrumentChatAgentConversation(obj);
148+
149+
obj._emit!('message:clear', { source: 'user' });
150+
expect(received).toEqual([{ type: 'message:clear', payload: { source: 'user' } }]);
151+
});
152+
153+
it('does not rotate the conversation id for other observability events', () => {
154+
const obj: AgentInternals = {
155+
name: 'session-7',
156+
_emit() {
157+
return undefined;
158+
},
159+
onChatMessage() {
160+
return 'response';
161+
},
162+
};
163+
164+
instrumentChatAgentConversation(obj);
165+
166+
obj._emit!('message:request');
167+
obj._emit!('rpc', { method: 'greet' });
168+
169+
expect(obj.__sentryConversationId).toBeUndefined();
170+
});
171+
});

0 commit comments

Comments
 (0)