From ae848fdc116e1ea63a8d7f0bced2752e3084bf78 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Wed, 29 Jul 2026 23:00:04 -0400 Subject: [PATCH 1/2] fix(cloudflare): Set agent conversation id on the `onRequest` path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `instrumentAgentWithSentry` set the conversation id for chat turns and `@callable()` RPC, but not for agents entered over plain HTTP. Agents reached through `onRequest` — REST endpoints, webhooks, any non-WebSocket caller — produced `gen_ai` spans with no `gen_ai.conversation.id`, so their turns could not be grouped. Wrap `onRequest` alongside the existing two hooks. `agents` installs it as an own property in the `Agent` constructor (the same treatment `onMessage` gets) and we instrument after construction, so the wrapped property is what the routing layer calls. Kept out of the `obj.fetch` proxy in `durableobject.ts`, which is shared with `instrumentDurableObjectWithSentry` — plain Durable Objects should not run agent-specific code. Verified on a deployed Cloudflare Worker: before, all 14 spans of a trace were unstamped; after, every `gen_ai` span carries the instance name, with only `http.server` and `rpc` spans unstamped as `conversationIdIntegration` intends. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/ai-streaming.test.ts | 3 ++ .../src/instrumentations/agents/index.ts | 8 ++-- .../instrumentAgentRequestConversation.ts | 34 ++++++++++++++++ .../src/instrumentations/agents/types.ts | 5 +++ .../test/instrumentCloudflareAgent.test.ts | 39 +++++++++++++++++++ 5 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 packages/cloudflare/src/instrumentations/agents/instrumentAgentRequestConversation.ts 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..2fdd8326ec21 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,9 @@ 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 — the + // HTTP (`onRequest`) path correlates its AI spans just like a chat turn or a callable RPC does. + 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..a940d5d2ebc3 --- /dev/null +++ b/packages/cloudflare/src/instrumentations/agents/instrumentAgentRequestConversation.ts @@ -0,0 +1,34 @@ +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 way into an agent, alongside chat turns and `@callable()` RPC: the + * `agents` package routes any non-WebSocket request to it, which is how REST endpoints, webhooks + * and other server-to-server callers reach an agent. Those turns run LLM calls just like the other + * two, so they need the same correlation — without this, an agent reached over HTTP produces + * `gen_ai` spans with no `gen_ai.conversation.id`. + * + * As with the other hooks, the id is not attached to spans here — `conversationIdIntegration` reads + * it off the scope at `spanStart` and stamps `gen_ai.conversation.id` onto the AI spans created + * inside the request. + * + * `agents` installs `onRequest` as an own property on the instance in the `Agent` constructor (the + * same treatment `onMessage` gets), and we instrument after construction, so wrapping the own + * property is what the routing layer 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..d60a8b3fcfc4 100644 --- a/packages/cloudflare/src/instrumentations/agents/types.ts +++ b/packages/cloudflare/src/instrumentations/agents/types.ts @@ -19,6 +19,11 @@ export interface AgentInternals { * does not, so its presence discriminates a chat agent. */ onChatMessage?: (...args: unknown[]) => unknown; + /** + * HTTP request handler, called for any non-WebSocket request routed to the agent (REST + * endpoints, webhooks). Installed as an own property by the `Agent` constructor. + */ + 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..adac72dc021d 100644 --- a/packages/cloudflare/test/instrumentCloudflareAgent.test.ts +++ b/packages/cloudflare/test/instrumentCloudflareAgent.test.ts @@ -157,5 +157,44 @@ 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) { + // Capture what the scope sees while the request is being handled. + 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); + }); }); }); From 2e1322ef8659c462f5c7c6a373759a3c3e384912 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Wed, 29 Jul 2026 23:06:59 -0400 Subject: [PATCH 2/2] ref(cloudflare): Trim comments on the agent onRequest hook Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/ai-streaming.test.ts | 3 +-- .../instrumentAgentRequestConversation.ts | 18 ++++++------------ .../src/instrumentations/agents/types.ts | 5 +---- .../test/instrumentCloudflareAgent.test.ts | 1 - 4 files changed, 8 insertions(+), 19 deletions(-) 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 2fdd8326ec21..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,8 +21,7 @@ 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 — the - // HTTP (`onRequest`) path correlates its AI spans just like a chat turn or a callable RPC does. + // Both agents are addressed as `.../test`, so the instance name is the conversation id. expect(span.attributes['gen_ai.conversation.id']?.value).toBe('test'); } diff --git a/packages/cloudflare/src/instrumentations/agents/instrumentAgentRequestConversation.ts b/packages/cloudflare/src/instrumentations/agents/instrumentAgentRequestConversation.ts index a940d5d2ebc3..45eae682e423 100644 --- a/packages/cloudflare/src/instrumentations/agents/instrumentAgentRequestConversation.ts +++ b/packages/cloudflare/src/instrumentations/agents/instrumentAgentRequestConversation.ts @@ -3,19 +3,13 @@ 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 way into an agent, alongside chat turns and `@callable()` RPC: the - * `agents` package routes any non-WebSocket request to it, which is how REST endpoints, webhooks - * and other server-to-server callers reach an agent. Those turns run LLM calls just like the other - * two, so they need the same correlation — without this, an agent reached over HTTP produces - * `gen_ai` spans with no `gen_ai.conversation.id`. + * `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. * - * As with the other hooks, the id is not attached to spans here — `conversationIdIntegration` reads - * it off the scope at `spanStart` and stamps `gen_ai.conversation.id` onto the AI spans created - * inside the request. - * - * `agents` installs `onRequest` as an own property on the instance in the `Agent` constructor (the - * same treatment `onMessage` gets), and we instrument after construction, so wrapping the own - * property is what the routing layer ends up calling. + * `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; diff --git a/packages/cloudflare/src/instrumentations/agents/types.ts b/packages/cloudflare/src/instrumentations/agents/types.ts index d60a8b3fcfc4..71c9ce54e29d 100644 --- a/packages/cloudflare/src/instrumentations/agents/types.ts +++ b/packages/cloudflare/src/instrumentations/agents/types.ts @@ -19,10 +19,7 @@ export interface AgentInternals { * does not, so its presence discriminates a chat agent. */ onChatMessage?: (...args: unknown[]) => unknown; - /** - * HTTP request handler, called for any non-WebSocket request routed to the agent (REST - * endpoints, webhooks). Installed as an own property by the `Agent` constructor. - */ + /** 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 }; diff --git a/packages/cloudflare/test/instrumentCloudflareAgent.test.ts b/packages/cloudflare/test/instrumentCloudflareAgent.test.ts index adac72dc021d..64bd417f5192 100644 --- a/packages/cloudflare/test/instrumentCloudflareAgent.test.ts +++ b/packages/cloudflare/test/instrumentCloudflareAgent.test.ts @@ -161,7 +161,6 @@ describe('instrumentCloudflareAgent', () => { it('sets the conversation id during an HTTP request', () => { const agent = createFakeAgent({ onRequest(this: any) { - // Capture what the scope sees while the request is being handled. this.seenConversationId = getCurrentScope().getScopeData().conversationId; return 'response'; },