diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/ai-streaming.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/ai-streaming.test.ts index 7faa10926ce6..f555f5eea9c8 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/ai-streaming.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/ai-streaming.test.ts @@ -21,6 +21,8 @@ function assertGenAiStreamingSpan(span: SerializedStreamedSpan): void { expect(span.attributes['gen_ai.usage.input_tokens']?.value).toBe(15); expect(span.attributes['gen_ai.usage.output_tokens']?.value).toBe(8); expect(span.attributes['gen_ai.usage.total_tokens']?.value).toBe(23); + // Both agents are addressed as `.../test`, so the instance name is the conversation id. + expect(span.attributes['gen_ai.conversation.id']?.value).toBe('test'); } test('captures Workers AI streaming output when driven via an Agent', async ({ request, baseURL }) => { diff --git a/packages/cloudflare/src/instrumentations/agents/index.ts b/packages/cloudflare/src/instrumentations/agents/index.ts index c59ec609c67e..67b4ab5e24a6 100644 --- a/packages/cloudflare/src/instrumentations/agents/index.ts +++ b/packages/cloudflare/src/instrumentations/agents/index.ts @@ -1,4 +1,5 @@ import { instrumentAgentCallableRpc } from './instrumentAgentCallableRpc'; +import { instrumentAgentRequestConversation } from './instrumentAgentRequestConversation'; import { instrumentChatAgentConversation } from './instrumentChatAgentConversation'; import type { AgentInternals } from './types'; @@ -8,9 +9,9 @@ import type { AgentInternals } from './types'; * * - **Callable RPC spans** — a span (op `rpc`) for each `@callable()` method invoked over WebSocket. * - **Conversation correlation** — sets the conversation id on the scope for each unit of agent - * work — chat turn or callable RPC call — so `gen_ai` spans created within it are correlated, for - * chat and plain agents alike. Defaults to the instance `name` and is rotated when the chat is - * cleared (the `message:clear` observability event). + * work — chat turn, callable RPC call, or HTTP request — so `gen_ai` spans created within it are + * correlated, for chat and plain agents alike. Defaults to the instance `name` and is rotated + * when the chat is cleared (the `message:clear` observability event). * * It only hooks the `agents` package internals and uses Sentry's tracing primitives. On Cloudflare * Workers, prefer `instrumentAgentWithSentry`, which additionally instruments the Durable Object @@ -29,6 +30,7 @@ export function instrumentCloudflareAgent(agent: T): T { instrumentAgentCallableRpc(internals); instrumentChatAgentConversation(internals); + instrumentAgentRequestConversation(internals); return agent; } diff --git a/packages/cloudflare/src/instrumentations/agents/instrumentAgentRequestConversation.ts b/packages/cloudflare/src/instrumentations/agents/instrumentAgentRequestConversation.ts new file mode 100644 index 000000000000..45eae682e423 --- /dev/null +++ b/packages/cloudflare/src/instrumentations/agents/instrumentAgentRequestConversation.ts @@ -0,0 +1,28 @@ +import { type AgentInternals, setAgentConversationId } from './types'; + +/** + * Correlates the AI spans of an HTTP-driven agent turn with a conversation id on the active scope. + * + * `onRequest` is the third unit of agent work, alongside chat turns and `@callable()` RPC: the + * `agents` router sends every non-WebSocket request to it, which is how REST endpoints and webhooks + * reach an agent. + * + * `agents` installs `onRequest` as an own property in the `Agent` constructor (as it does + * `onMessage`), and we instrument after construction, so wrapping the own property is what the + * router ends up calling. + */ +export function instrumentAgentRequestConversation(obj: AgentInternals): void { + const original = obj.onRequest; + + if (typeof original !== 'function') { + return; + } + + obj.onRequest = new Proxy(original, { + apply(target, thisArg: AgentInternals, args: unknown[]): unknown { + setAgentConversationId(thisArg); + + return Reflect.apply(target, thisArg, args); + }, + }); +} diff --git a/packages/cloudflare/src/instrumentations/agents/types.ts b/packages/cloudflare/src/instrumentations/agents/types.ts index ba664ce3de69..71c9ce54e29d 100644 --- a/packages/cloudflare/src/instrumentations/agents/types.ts +++ b/packages/cloudflare/src/instrumentations/agents/types.ts @@ -19,6 +19,8 @@ export interface AgentInternals { * does not, so its presence discriminates a chat agent. */ onChatMessage?: (...args: unknown[]) => unknown; + /** HTTP request handler; the router sends every non-WebSocket request here. */ + onRequest?: (...args: unknown[]) => unknown; /** The user's Agent class (used by the SDK for the observability event `agent` field). */ _ParentClass?: { name?: string }; /** The Agent instance name, which in the Agents model identifies the conversation/thread. */ diff --git a/packages/cloudflare/test/instrumentCloudflareAgent.test.ts b/packages/cloudflare/test/instrumentCloudflareAgent.test.ts index 04fe3fa7500e..64bd417f5192 100644 --- a/packages/cloudflare/test/instrumentCloudflareAgent.test.ts +++ b/packages/cloudflare/test/instrumentCloudflareAgent.test.ts @@ -157,5 +157,43 @@ describe('instrumentCloudflareAgent', () => { expect(agent.seenConversationId).toBe('instance-1'); expect('onChatMessage' in agent).toBe(false); }); + + it('sets the conversation id during an HTTP request', () => { + const agent = createFakeAgent({ + onRequest(this: any) { + this.seenConversationId = getCurrentScope().getScopeData().conversationId; + return 'response'; + }, + }); + instrumentCloudflareAgent(agent); + + const result = agent.onRequest(new Request('https://example.com/agents/my-agent/instance-1')); + + expect(result).toBe('response'); + expect(agent.seenConversationId).toBe('instance-1'); + }); + + it('prefers the rotated conversation id over the instance name on the HTTP path', () => { + const agent = createFakeAgent({ + onRequest(this: any) { + this.seenConversationId = getCurrentScope().getScopeData().conversationId; + return 'response'; + }, + }); + instrumentCloudflareAgent(agent); + + agent._emit?.('message:clear'); + agent.__sentryConversationId = 'rotated-id'; + agent.onRequest(new Request('https://example.com/agents/my-agent/instance-1')); + + expect(agent.seenConversationId).toBe('rotated-id'); + }); + + it('does not throw when the agent has no onRequest handler', () => { + const agent = createFakeAgent(); + + expect(() => instrumentCloudflareAgent(agent)).not.toThrow(); + expect('onRequest' in agent).toBe(false); + }); }); });