Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,32 @@
"test:dev": "TEST_ENV=development playwright test"
},
"dependencies": {
"@cloudflare/ai-chat": "^0.7.1",
"@sentry/cloudflare": "^10.53.1",
"agents": "^0.13.1",
"react": "^19.2.6",
"react-dom": "^19.2.6"
"@cloudflare/ai-chat": "^0.10.0",
"@sentry/cloudflare": "^10.68.0",
"@sentry/core": "^10.68.0",
"agents": "^0.20.0",
"ai": "^6.0.235",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"workers-ai-provider": "^3.3.1"
},
"devDependencies": {
"@playwright/test": "~1.56.0",
"@cloudflare/vite-plugin": "^1.37.2",
"@cloudflare/workers-types": "^4.20260520.1",
"@cloudflare/vite-plugin": "^1.47.0",
"@cloudflare/workers-types": "^5.20260727.1",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@types/node": "^24.12.4",
"@types/react": "^19.2.15",
"@types/node": "^26.1.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"globals": "^17.6.0",
"@types/ws": "^8.18.1",
"@babel/core": "^8.0.1",
"@babel/plugin-proposal-decorators": "^8.0.2",
"@vitejs/plugin-react": "^6.0.4",
"globals": "^17.8.0",
"typescript": "~6.0.3",
"vite": "^8.0.14",
"wrangler": "^4.93.0",
"ws": "^8.20.1"
"vite": "^8.1.5",
"wrangler": "^4.114.0",
"ws": "^8.21.1"
},
"volta": {
"node": "24.15.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import WebSocket from 'ws';

type AgentReply = { type?: string; id?: string; done?: boolean };

/** Sends a single frame over a WS to the given agent instance and resolves once `isDone` matches a reply. */
function driveAgentSocket(
baseURL: string,
binding: string,
instance: string,
frame: unknown,
isDone: (reply: AgentReply) => boolean,
timeoutLabel: string,
): Promise<void> {
const wsUrl = `${baseURL.replace(/^http/, 'ws')}/agents/${binding}/${instance}`;

return new Promise<void>((resolveSocket, rejectSocket) => {
const socket = new WebSocket(wsUrl);
const timeout = setTimeout(() => {
socket.close();
rejectSocket(new Error(`Timed out waiting for ${timeoutLabel}`));
}, 15_000);

socket.on('open', () => {
socket.send(JSON.stringify(frame));
});

socket.on('message', data => {
try {
const parsed = JSON.parse(data.toString()) as AgentReply;
if (isDone(parsed)) {
clearTimeout(timeout);
socket.close();
resolveSocket();
}
} catch {
// Ignore non-JSON / unrelated frames.
}
});

socket.on('error', err => {
clearTimeout(timeout);
rejectSocket(err);
});
});
}

/** Opens a chat WebSocket to `/agents/<binding>/<instance>`, sends one `cf_agent_use_chat_request` frame. */
export function sendChatMessage(
baseURL: string,
options: { binding: string; instance: string; prompt: string },
): Promise<void> {
const id = `chat-${options.instance}`;
const frame = {
type: 'cf_agent_use_chat_request',
id,
init: {
method: 'POST',
body: JSON.stringify({
messages: [{ id: 'msg-1', role: 'user', parts: [{ type: 'text', text: options.prompt }] }],
}),
},
};

return driveAgentSocket(
baseURL,
options.binding,
options.instance,
frame,
reply => reply.type === 'cf_agent_use_chat_response' && reply.id === id && !!reply.done,
'chat response',
);
}

/** Opens a WebSocket to `/agents/<binding>/<instance>`, sends one RPC frame, resolves on the reply. */
export function callRpc(
baseURL: string,
options: { binding: string; instance: string; method: string; args: unknown[] },
): Promise<void> {
const id = `rpc-${options.method}`;
const frame = { type: 'rpc', id, method: options.method, args: options.args };

return driveAgentSocket(
baseURL,
options.binding,
options.instance,
frame,
reply => reply.type === 'rpc' && reply.id === id && !!reply.done,
`RPC reply to "${options.method}"`,
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

// Drives Workers AI through the real Cloudflare Agents SDK + Vercel AI SDK + `workers-ai-provider`
// stack (the OpenAI-compatible SSE shape, `choices[].delta.content`) from an Agent's `onRequest`.
// Asserts the streaming response text is captured on the gen_ai span — the regression seen in
// production where only input + usage survived. The model output is read from
// `gen_ai.output.messages`, so the streaming instrumentation must emit it alongside the
// deprecated `gen_ai.response.text`.
function assertGenAiStreamingSpan(spans: Array<Record<string, any>> | undefined): void {
const genAiSpan = (spans ?? []).find(span => span.op === 'gen_ai.chat');

expect(genAiSpan).toBeDefined();
expect(genAiSpan.origin).toBe('auto.ai.cloudflare.workers_ai');
expect(genAiSpan.data).toEqual(
expect.objectContaining({
'sentry.origin': 'auto.ai.cloudflare.workers_ai',
'gen_ai.operation.name': 'chat',
'gen_ai.request.model': '@cf/meta/llama-3.1-8b-instruct',
'gen_ai.response.streaming': true,
'gen_ai.response.text': 'The capital of France is Paris.',
'gen_ai.output.messages': JSON.stringify([
{ role: 'assistant', parts: [{ type: 'text', content: 'The capital of France is Paris.' }] },
]),
'gen_ai.usage.input_tokens': 15,
'gen_ai.usage.output_tokens': 8,
'gen_ai.usage.total_tokens': 23,
}),
);
}

test('captures Workers AI streaming output when driven via an Agent', async ({ request, baseURL }) => {
const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => {
return (
transactionEvent.transaction === 'GET /agents/my-agent/test' &&
(transactionEvent.spans ?? []).some(span => span.op === 'gen_ai.chat')
);
});

const response = await request.get(`${baseURL}/agents/my-agent/test`);
expect(response.ok()).toBe(true);

const transaction = await transactionPromise;
assertGenAiStreamingSpan(transaction.spans);
});

test('captures Workers AI streaming output when driven via an AIChatAgent', async ({ request, baseURL }) => {
const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => {
return (
transactionEvent.transaction === 'GET /agents/my-chat-agent/test' &&
(transactionEvent.spans ?? []).some(span => span.op === 'gen_ai.chat')
);
});

const response = await request.get(`${baseURL}/agents/my-chat-agent/test`);
expect(response.ok()).toBe(true);

const transaction = await transactionPromise;
assertGenAiStreamingSpan(transaction.spans);
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

test('@callable() methods work correctly with Sentry instrumentDurableObjectWithSentry', async ({ page, baseURL }) => {
test('@callable() methods work correctly with Sentry instrumentAgentWithSentry', async ({ page, baseURL }) => {
const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => {
return (
transactionEvent.transaction === 'GET /agents/my-agent/user-123' &&
Expand Down Expand Up @@ -127,3 +127,63 @@ test('@callable() methods work correctly with Sentry instrumentDurableObjectWith
}),
]);
});

test('does not emit db.query spans for the agents runtime `cf_`-prefixed internal tables', async ({
page,
baseURL,
}) => {
const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => {
return (
transactionEvent.transaction === 'GET /agents/my-agent/user-123' &&
transactionEvent.contexts?.trace?.parent_span_id !== undefined
);
});

await page.goto(baseURL!);

await expect(page.getByText('Connected')).toBeVisible();
await page.getByRole('button', { name: 'Call Agent' }).click();
await expect(page.getByText('Hello, World!')).toBeVisible();

const transaction = await transactionPromise;

// The agents runtime constantly queries its own `cf_agents_*` / `cf_agent_*` bookkeeping tables.
// These are framework internals and are filtered out by default, so no such span should leak.
const internalTableSpans = (transaction.spans ?? []).filter(
span => span.op === 'db.query' && /\bcf_/.test((span.data?.['db.query.summary'] as string) ?? ''),
);

expect(internalTableSpans).toEqual([]);
});

test('creates an rpc span named after the @callable() method', async ({ page, baseURL }) => {
const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => {
return (
transactionEvent.transaction === 'webSocketMessage' &&
(transactionEvent.spans ?? []).some(span => span.op === 'rpc' && span.description === 'greet')
);
});

await page.goto(baseURL!);

await expect(page.getByText('Connected')).toBeVisible();
await page.getByRole('button', { name: 'Call Agent' }).click();
await expect(page.getByText('Hello, World!')).toBeVisible();

const transaction = await transactionPromise;

const rpcSpans = (transaction.spans ?? []).filter(span => span.op === 'rpc');
expect(rpcSpans).toHaveLength(1);

expect(rpcSpans[0]).toEqual(
expect.objectContaining({
op: 'rpc',
description: 'greet',
origin: 'auto.faas.cloudflare.agents',
data: expect.objectContaining({
'cloudflare.agent.class': 'MyBaseAgent',
'cloudflare.agent.name': 'user-123',
}),
}),
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { sendChatMessage } from './agent-socket';

// In the Agents model one agent instance is one conversation, so the instance name is the
// conversation id the SDK correlates the turn's gen_ai spans with.
const CONVERSATION_ID = 'chat-conv-instance';

test('stamps the conversation id on gen_ai spans created inside a chat turn', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => {
return (
transactionEvent.transaction === 'webSocketMessage' &&
(transactionEvent.spans ?? []).some(span => span.op === 'gen_ai.chat')
);
});

await sendChatMessage(baseURL!, {
binding: 'my-chat-agent',
instance: CONVERSATION_ID,
prompt: 'What is the capital of France?',
});

const transaction = await transactionPromise;

const genAiSpan = (transaction.spans ?? []).find(span => span.op === 'gen_ai.chat');
expect(genAiSpan).toBeDefined();
expect(genAiSpan?.data).toEqual(
expect.objectContaining({
'gen_ai.conversation.id': CONVERSATION_ID,
}),
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { callRpc } from './agent-socket';

const AGENT_INSTANCE = 'chat-rpc-instance';

test('creates an rpc span for a @callable() invocation on an AIChatAgent', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => {
return (
transactionEvent.transaction === 'webSocketMessage' &&
(transactionEvent.spans ?? []).some(span => span.op === 'rpc' && span.description === 'greet')
);
});

await callRpc(baseURL!, { binding: 'my-chat-agent', instance: AGENT_INSTANCE, method: 'greet', args: ['World'] });

const transaction = await transactionPromise;

const rpcSpan = (transaction.spans ?? []).find(span => span.op === 'rpc' && span.description === 'greet');
expect(rpcSpan).toEqual(
expect.objectContaining({
op: 'rpc',
description: 'greet',
origin: 'auto.faas.cloudflare.agents',
data: expect.objectContaining({
'cloudflare.agent.name': AGENT_INSTANCE,
}),
}),
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ interface __BaseEnv_Env {
CF_VERSION_METADATA: WorkerVersionMetadata;
E2E_TEST_DSN: string;
MyAgent: DurableObjectNamespace<import('./worker/index').MyAgent>;
MyChatAgent: DurableObjectNamespace<import('./worker/index').MyChatAgent>;
}
declare namespace Cloudflare {
interface GlobalProps {
mainModule: typeof import('./worker/index');
durableNamespaces: 'MyAgent';
durableNamespaces: 'MyAgent' | 'MyChatAgent';
}
interface Env extends __BaseEnv_Env {}
}
Expand Down
Loading
Loading