Skip to content

Commit bc94aca

Browse files
RulaKhaledclaude
andauthored
test(cloudflare): Add e2e test app for @cloudflare/think (#24660)
Adds a `cloudflare-think` e2e app that records what a Think agent produces with the SDK as it stands today, before any Think-specific code exists. Same shape as `node-eve` and `cloudflare-mastra`. The setup is the whole point: the worker imports no Sentry API at all. ```ts // vite.config.mts export default defineConfig({ plugins: [cloudflare(), sentryCloudflareVitePlugin()], }); ``` ```ts // src/index.ts export class ThinkAgent extends Think<Env> { getModel() { return openrouter('openai/gpt-4o-mini'); } getSystemPrompt() { /* ... */ } getTools() { /* get_weather, fail_now */ } } ``` `sentryCloudflareVitePlugin()` detects `extends Think` and wraps the export at build time, and `ai` publishes the telemetry `vercelAIIntegration` already consumes. Options come from `src/instrument.server.ts` by convention. ## What works today Five checks, the same set the other AI apps cover: 1. **The gen_ai hierarchy.** `gen_ai.invoke_agent`, one `gen_ai.generate_content` per step, and `gen_ai.execute_tool`, correctly nested and all with origin `auto.vercelai.channel`. Token usage is right per step and rolls up onto the agent span; prompts, tool arguments and results, tool definitions and finish reasons are all present. 2. **Tool errors.** A throw becomes an issue with mechanism `auto.vercelai.channel` and marks only its own span errored, leaving the model calls `ok`. 3. **Manual spans.** A `Sentry.startSpan` raised inside a tool nests under that tool's span, so Think runs tools inside the async context the SDK opened. 4. **The provider request.** The outgoing model call joins the turn's trace rather than running beside it. 5. **Other instrumented libraries.** `dataloader` spans land in the same trace, so the orchestrion module transform reaches a bundled Think worker with no `--import` bootstrap, unlike the Node apps. ## What does not The provider HTTP call nests under `gen_ai.generate_content` on `ai` v7 but is a sibling of it on v4 to v6. That difference is ours: only v7's native channel binds the model-call span into async context. Asserted per lane so the gap is recorded rather than hidden. The agent span is named from `this.constructor.name`, which the Vite plugin's class rename currently mangles into `invoke_agent __SENTRY_ORIGINAL_ThinkAgent__`. Not asserted here. A follow-up fixes it and adds the assertion. There is no `gen_ai.agent.name`, and none of the `cloudflare.agents.turn.*` context Think supplies (agent id, Durable Object conversation id, turn trigger, continuation) reaches a span. Think sends all of it, as `runtimeContext` on v7 and `experimental_telemetry.metadata` on v6, and neither subscriber reads either field. ## Notes `sentryTest.optional: true` keeps this in the optional matrix like `node-eve`, since it makes real OpenRouter calls. The v6 lane rides along as an `optionalVariants` entry rather than a second app. `E2E_OPENROUTER_API_KEY` is already wired in `build.yml` from the eve PR, so CI needs no change. ref JS-3568 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 378cccb commit bc94aca

14 files changed

Lines changed: 526 additions & 0 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"name": "cloudflare-think",
3+
"version": "0.0.0",
4+
"private": true,
5+
"scripts": {
6+
"dev": "vite dev",
7+
"build": "vite build",
8+
"preview": "vite preview --port 38788",
9+
"typecheck": "tsc --noEmit",
10+
"test:build": "pnpm install && pnpm build",
11+
"test:assert": "pnpm test:prod",
12+
"test:prod": "TEST_ENV=production playwright test",
13+
"test:dev": "TEST_ENV=development playwright test",
14+
"test:build-ai-v6": "pnpm install && pnpm add ai@^6.0.0 @openrouter/ai-sdk-provider@^2.9.1 && pnpm build",
15+
"test:assert-ai-v6": "AI_SDK_MAJOR=6 pnpm test:prod"
16+
},
17+
"dependencies": {
18+
"@cloudflare/think": "^0.19.0",
19+
"@openrouter/ai-sdk-provider": "^3.1.0",
20+
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
21+
"agents": "^0.24.0",
22+
"ai": "^7.0.112",
23+
"dataloader": "^2.2.3",
24+
"zod": "^4.0.0"
25+
},
26+
"devDependencies": {
27+
"@cloudflare/vite-plugin": "1.57.2",
28+
"@cloudflare/workers-types": "^4.20260426.0",
29+
"@playwright/test": "~1.63.0",
30+
"@sentry-internal/test-utils": "link:../../../test-utils",
31+
"typescript": "^5.5.2",
32+
"vite": "7.3.5",
33+
"wrangler": "^4.136.2",
34+
"ws": "^8.18.3"
35+
},
36+
"volta": {
37+
"node": "24.15.0",
38+
"extends": "../../package.json"
39+
},
40+
"sentryTest": {
41+
"optional": true,
42+
"optionalVariants": [
43+
{
44+
"build-command": "pnpm test:build-ai-v6",
45+
"assert-command": "pnpm test:assert-ai-v6",
46+
"label": "cloudflare-think (ai v6)"
47+
}
48+
]
49+
}
50+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
2+
3+
const testEnv = process.env.TEST_ENV;
4+
5+
if (!testEnv) {
6+
throw new Error('No test env defined');
7+
}
8+
9+
const APP_PORT = 38788;
10+
11+
const config = getPlaywrightConfig(
12+
{
13+
startCommand: `pnpm preview`,
14+
port: APP_PORT,
15+
},
16+
// Each test drives a real OpenRouter tool-calling turn (up to two model calls) and then waits for
17+
// the spans to flush, which does not fit the default 30s timeout when the provider is slow. The
18+
// serial default from `getPlaywrightConfig` is kept: these turns share one worker and one event
19+
// proxy, so running them in parallel only makes traces harder to tell apart.
20+
{ timeout: 90_000 },
21+
);
22+
23+
export default config;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
declare namespace Cloudflare {
2+
interface Env {
3+
E2E_TEST_DSN: string;
4+
E2E_OPENROUTER_API_KEY: string;
5+
ThinkAgent: DurableObjectNamespace;
6+
}
7+
}
8+
9+
interface Env extends Cloudflare.Env {}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { routeAgentRequest } from 'agents';
2+
3+
// Re-exported rather than declared here so the build-time wrapper has to cross a module boundary
4+
// to find and wrap the class. See the note on the class itself.
5+
export { ThinkAgent } from './think-agent';
6+
7+
export default {
8+
async fetch(request, env): Promise<Response> {
9+
return (await routeAgentRequest(request, env)) ?? new Response('Not found', { status: 404 });
10+
},
11+
} satisfies ExportedHandler<Env>;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { defineCloudflareOptions } from '@sentry/cloudflare';
2+
3+
// The Sentry Vite plugin picks this file up by convention, next to the worker entry named in
4+
// wrangler's `main`, and hands its default export to `withSentry`.
5+
export default defineCloudflareOptions((env: Env) => ({
6+
dsn: env.E2E_TEST_DSN,
7+
environment: 'qa',
8+
tunnel: 'http://localhost:3031/',
9+
tracesSampleRate: 1.0,
10+
}));
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
2+
import { Think } from '@cloudflare/think';
3+
import * as Sentry from '@sentry/cloudflare';
4+
import type { LanguageModel, ToolSet } from 'ai';
5+
import { tool } from 'ai';
6+
import DataLoader from 'dataloader';
7+
import { z } from 'zod';
8+
9+
/**
10+
* Not wrapped by hand: `@sentry/cloudflare/vite` detects `extends Think` and rewrites the worker
11+
* entry's export of this class into `instrumentAgentWithSentry(...)` at build time. Wrapping it
12+
* here would prove nothing about the zero-config path.
13+
*
14+
* The class deliberately lives outside the worker entry, which only re-exports it. That is the
15+
* harder case for the plugin: the entry never binds the class, so detection has to follow the
16+
* re-export into this module and the wrapper has to import the class under a private name.
17+
*/
18+
export class ThinkAgent extends Think<Env> {
19+
public getModel(): LanguageModel {
20+
// Call OpenRouter directly (rather than the default Vercel AI Gateway) so the e2e test needs
21+
// only a single OpenRouter key, reusing `E2E_OPENROUTER_API_KEY` like the other AI apps. A real
22+
// provider is also what makes the outgoing model request observable as an `http.client` span.
23+
const openrouter = createOpenRouter({ apiKey: this.env.E2E_OPENROUTER_API_KEY ?? '' });
24+
25+
return openrouter('openai/gpt-4o-mini');
26+
}
27+
28+
public getSystemPrompt(): string {
29+
return [
30+
'You are a concise assistant used by an automated end-to-end test.',
31+
'When the user asks about the weather in a place, call the `get_weather` tool for that place and answer in one short sentence using its result.',
32+
'When the user asks you to trigger a failure, call the `fail_now` tool.',
33+
'Do not ask follow-up questions.',
34+
].join('\n');
35+
}
36+
37+
public getTools(): ToolSet {
38+
return {
39+
get_weather: tool({
40+
description: 'Get the current weather for a location',
41+
inputSchema: z.object({ location: z.string() }),
42+
execute: async ({ location }: { location: string }) => {
43+
// A manual span raised inside a tool must nest under that tool's `gen_ai.execute_tool`
44+
// span, which only holds if Think runs the tool inside the SDK's async context.
45+
return Sentry.startSpan({ name: 'lookup-forecast', op: 'gen_ai.tool.manual' }, async () => {
46+
// `dataloader` is instrumented through the orchestrion module transform rather than by
47+
// patching a global, so its spans are the probe for whether channel injection reached
48+
// this bundled worker at all.
49+
const loader = new DataLoader<string, string>(async keys => keys.map(key => `forecast:${key}`));
50+
await Promise.all([loader.load(location), loader.load(location)]);
51+
52+
return { city: location, condition: 'Sunny', temperatureC: 22 };
53+
});
54+
},
55+
}),
56+
fail_now: tool({
57+
description: 'Always throws an error. Call this when the user asks to trigger a failure.',
58+
// A nominal argument rather than `z.object({})`, and an explicit return type: the AI SDK
59+
// infers `never` for an empty input schema, and a body that only throws infers
60+
// `Promise<never>`. Either one alone makes the `tool()` overload unresolvable.
61+
inputSchema: z.object({ reason: z.string() }),
62+
execute: async (_input: { reason: string }): Promise<string> => {
63+
throw new Error('Think tool failed on purpose');
64+
},
65+
}),
66+
};
67+
}
68+
69+
public async onRequest(request: Request): Promise<Response> {
70+
const message = new URL(request.url).searchParams.get('message') ?? 'What is the weather in Paris?';
71+
72+
const result = await this.runTurn({ input: message });
73+
74+
return Response.json({ continuation: result.continuation });
75+
}
76+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { startEventProxyServer } from '@sentry-internal/test-utils';
2+
3+
startEventProxyServer({
4+
port: 3031,
5+
proxyServerName: 'cloudflare-think',
6+
envelopeDumpPath: process.env.SENTRY_ENVELOPE_DUMP_PATH,
7+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { expect, test } from '@playwright/test';
2+
import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils';
3+
import { attr, isTurnOf, newAgentId, runAgentTurn, type StreamedSpan } from './utils';
4+
5+
const APP = 'cloudflare-think';
6+
7+
const isDataloaderSpan = (span: { attributes?: Record<string, { value?: unknown }> }): boolean =>
8+
span.attributes?.['sentry.origin']?.value === 'auto.db.dataloader';
9+
10+
/**
11+
* `dataloader` is instrumented by the orchestrion module transform rather than by patching anything
12+
* at runtime, so its spans are the probe for whether channel injection reached the worker at all.
13+
* Libraries that do not need the transform (`node:http`, and the AI spans themselves) would pass
14+
* even if injection were broken.
15+
*
16+
* On Cloudflare the injection is done at build time by `sentryCloudflareVitePlugin()`, so unlike the
17+
* Node apps this needs no `--import` bootstrap. A Think worker is heavily bundled, which is exactly
18+
* the situation where a transform can silently stop applying, so this is the test that catches it.
19+
*
20+
* The loader runs inside a tool so its spans land in the turn's trace rather than one of their own.
21+
*/
22+
test('captures orchestrion-instrumented dataloader spans in the same trace as the AI spans', async ({ baseURL }) => {
23+
const agentId = newAgentId('dataloader');
24+
const ofThisTurn = isTurnOf(agentId);
25+
26+
const spansPromise = collectStreamedSpans(
27+
APP,
28+
spansOfTrace =>
29+
ofThisTurn(spansOfTrace as StreamedSpan[]) &&
30+
spansOfTrace.some(span => attr(span as StreamedSpan, 'gen_ai.tool.name') === 'get_weather') &&
31+
spansOfTrace.some(isDataloaderSpan),
32+
);
33+
34+
await runAgentTurn(baseURL!, agentId, 'What is the weather in Paris?');
35+
36+
const spans = await spansPromise;
37+
const dataloaderSpan = spans.find(isDataloaderSpan);
38+
const toolSpan = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'get_weather');
39+
40+
// Sharing the trace is the point. Not asserting the exact parent: the model may call the tool more
41+
// than once, so the tool span found here is not reliably the one that ran this loader.
42+
expect(getSpanOp(dataloaderSpan!)).toBe('cache.get');
43+
expect(dataloaderSpan?.trace_id).toBe(toolSpan?.trace_id);
44+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { expect, test } from '@playwright/test';
2+
import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/test-utils';
3+
import { attr, isTurnOf, newAgentId, runAgentTurn, type StreamedSpan } from './utils';
4+
5+
const APP = 'cloudflare-think';
6+
7+
/**
8+
* A tool throw is not an agent failure: the AI SDK catches it, hands the error back to the model as
9+
* a tool result, and the turn carries on and usually answers. So the error has to reach Sentry from
10+
* the tool span itself, and the spans above it stay `ok` — the model calls did succeed.
11+
*/
12+
test('captures an error thrown inside a Think tool and marks its span errored', async ({ baseURL }) => {
13+
const agentId = newAgentId('tool-error');
14+
const ofThisTurn = isTurnOf(agentId);
15+
16+
const errorPromise = waitForError(
17+
APP,
18+
event => event.exception?.values?.[0]?.value === 'Think tool failed on purpose',
19+
);
20+
21+
const spansPromise = collectStreamedSpans(
22+
APP,
23+
spansOfTrace =>
24+
ofThisTurn(spansOfTrace as StreamedSpan[]) &&
25+
spansOfTrace.some(span => attr(span as StreamedSpan, 'gen_ai.tool.name') === 'fail_now'),
26+
);
27+
28+
await runAgentTurn(baseURL!, agentId, 'Please trigger a failure now.');
29+
30+
const error = await errorPromise;
31+
const spans = await spansPromise;
32+
33+
const exception = error.exception?.values?.[0];
34+
expect(exception?.type).toBe('Error');
35+
expect(exception?.value).toBe('Think tool failed on purpose');
36+
expect(exception?.mechanism?.type).toBe('auto.vercelai.channel');
37+
38+
const toolSpan = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'fail_now');
39+
expect(getSpanOp(toolSpan!)).toBe('gen_ai.execute_tool');
40+
expect(toolSpan?.status).toBe('error');
41+
42+
// The issue belongs to the same trace as the turn that produced it.
43+
expect(error.contexts?.trace?.trace_id).toBe(toolSpan?.trace_id);
44+
45+
// The model calls around the failing tool succeeded, so only the tool span is errored.
46+
const modelCalls = spans.filter(span => getSpanOp(span) === 'gen_ai.generate_content');
47+
expect(modelCalls.length).toBeGreaterThan(0);
48+
for (const modelCall of modelCalls) {
49+
expect(modelCall.status).toBe('ok');
50+
}
51+
});

0 commit comments

Comments
 (0)