-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
test(cloudflare): Add e2e test app for @cloudflare/think
#24660
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
RulaKhaled
wants to merge
2
commits into
develop
Choose a base branch
from
feat/cloudflare-think-e2e
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
50 changes: 50 additions & 0 deletions
50
dev-packages/e2e-tests/test-applications/cloudflare-think/package.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| { | ||
| "name": "cloudflare-think", | ||
| "version": "0.0.0", | ||
| "private": true, | ||
| "scripts": { | ||
| "dev": "vite dev", | ||
| "build": "vite build", | ||
| "preview": "vite preview --port 38788", | ||
| "typecheck": "tsc --noEmit", | ||
| "test:build": "pnpm install && pnpm build", | ||
| "test:assert": "pnpm test:prod", | ||
| "test:prod": "TEST_ENV=production playwright test", | ||
| "test:dev": "TEST_ENV=development playwright test", | ||
| "test:build-ai-v6": "pnpm install && pnpm add ai@^6.0.0 @openrouter/ai-sdk-provider@^2.9.1 && pnpm build", | ||
| "test:assert-ai-v6": "AI_SDK_MAJOR=6 pnpm test:prod" | ||
| }, | ||
| "dependencies": { | ||
| "@cloudflare/think": "^0.19.0", | ||
| "@openrouter/ai-sdk-provider": "^3.1.0", | ||
| "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", | ||
| "agents": "^0.24.0", | ||
| "ai": "^7.0.112", | ||
| "dataloader": "^2.2.3", | ||
| "zod": "^4.0.0" | ||
| }, | ||
| "devDependencies": { | ||
| "@cloudflare/vite-plugin": "1.57.2", | ||
| "@cloudflare/workers-types": "^4.20260426.0", | ||
| "@playwright/test": "~1.63.0", | ||
| "@sentry-internal/test-utils": "link:../../../test-utils", | ||
| "typescript": "^5.5.2", | ||
| "vite": "7.3.5", | ||
| "wrangler": "^4.136.2", | ||
| "ws": "^8.18.3" | ||
| }, | ||
| "volta": { | ||
| "node": "24.15.0", | ||
| "extends": "../../package.json" | ||
| }, | ||
| "sentryTest": { | ||
| "optional": true, | ||
| "optionalVariants": [ | ||
| { | ||
| "build-command": "pnpm test:build-ai-v6", | ||
| "assert-command": "pnpm test:assert-ai-v6", | ||
| "label": "cloudflare-think (ai v6)" | ||
| } | ||
| ] | ||
| } | ||
| } |
23 changes: 23 additions & 0 deletions
23
dev-packages/e2e-tests/test-applications/cloudflare-think/playwright.config.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import { getPlaywrightConfig } from '@sentry-internal/test-utils'; | ||
|
|
||
| const testEnv = process.env.TEST_ENV; | ||
|
|
||
| if (!testEnv) { | ||
| throw new Error('No test env defined'); | ||
| } | ||
|
|
||
| const APP_PORT = 38788; | ||
|
|
||
| const config = getPlaywrightConfig( | ||
| { | ||
| startCommand: `pnpm preview`, | ||
| port: APP_PORT, | ||
| }, | ||
| // Each test drives a real OpenRouter tool-calling turn (up to two model calls) and then waits for | ||
| // the spans to flush, which does not fit the default 30s timeout when the provider is slow. The | ||
| // serial default from `getPlaywrightConfig` is kept: these turns share one worker and one event | ||
| // proxy, so running them in parallel only makes traces harder to tell apart. | ||
| { timeout: 90_000 }, | ||
| ); | ||
|
|
||
| export default config; |
9 changes: 9 additions & 0 deletions
9
dev-packages/e2e-tests/test-applications/cloudflare-think/src/env.d.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| declare namespace Cloudflare { | ||
| interface Env { | ||
| E2E_TEST_DSN: string; | ||
| E2E_OPENROUTER_API_KEY: string; | ||
| ThinkAgent: DurableObjectNamespace; | ||
| } | ||
| } | ||
|
|
||
| interface Env extends Cloudflare.Env {} |
79 changes: 79 additions & 0 deletions
79
dev-packages/e2e-tests/test-applications/cloudflare-think/src/index.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| import { createOpenRouter } from '@openrouter/ai-sdk-provider'; | ||
| import { Think } from '@cloudflare/think'; | ||
| import * as Sentry from '@sentry/cloudflare'; | ||
| import { routeAgentRequest } from 'agents'; | ||
| import type { LanguageModel, ToolSet } from 'ai'; | ||
| import { tool } from 'ai'; | ||
| import DataLoader from 'dataloader'; | ||
| import { z } from 'zod'; | ||
|
|
||
| /** | ||
| * Not wrapped by hand: `@sentry/cloudflare/vite` detects `extends Think` and rewrites this export | ||
| * into `instrumentAgentWithSentry(...)` at build time. Wrapping it here would prove nothing about | ||
| * the zero-config path. | ||
| */ | ||
| export class ThinkAgent extends Think<Env> { | ||
| public getModel(): LanguageModel { | ||
| // Call OpenRouter directly (rather than the default Vercel AI Gateway) so the e2e test needs | ||
| // only a single OpenRouter key, reusing `E2E_OPENROUTER_API_KEY` like the other AI apps. A real | ||
| // provider is also what makes the outgoing model request observable as an `http.client` span. | ||
| const openrouter = createOpenRouter({ apiKey: this.env.E2E_OPENROUTER_API_KEY ?? '' }); | ||
|
|
||
| return openrouter('openai/gpt-4o-mini'); | ||
| } | ||
|
|
||
| public getSystemPrompt(): string { | ||
| return [ | ||
| 'You are a concise assistant used by an automated end-to-end test.', | ||
| '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.', | ||
| 'When the user asks you to trigger a failure, call the `fail_now` tool.', | ||
| 'Do not ask follow-up questions.', | ||
| ].join('\n'); | ||
| } | ||
|
|
||
| public getTools(): ToolSet { | ||
| return { | ||
| get_weather: tool({ | ||
| description: 'Get the current weather for a location', | ||
| inputSchema: z.object({ location: z.string() }), | ||
| execute: async ({ location }: { location: string }) => { | ||
| // A manual span raised inside a tool must nest under that tool's `gen_ai.execute_tool` | ||
| // span, which only holds if Think runs the tool inside the SDK's async context. | ||
| return Sentry.startSpan({ name: 'lookup-forecast', op: 'gen_ai.tool.manual' }, async () => { | ||
| // `dataloader` is instrumented through the orchestrion module transform rather than by | ||
| // patching a global, so its spans are the probe for whether channel injection reached | ||
| // this bundled worker at all. | ||
| const loader = new DataLoader<string, string>(async keys => keys.map(key => `forecast:${key}`)); | ||
| await Promise.all([loader.load(location), loader.load(location)]); | ||
|
|
||
| return { city: location, condition: 'Sunny', temperatureC: 22 }; | ||
| }); | ||
| }, | ||
| }), | ||
| fail_now: tool({ | ||
| description: 'Always throws an error. Call this when the user asks to trigger a failure.', | ||
| // A nominal argument rather than `z.object({})`, and an explicit return type: the AI SDK | ||
| // infers `never` for an empty input schema, and a body that only throws infers | ||
| // `Promise<never>`. Either one alone makes the `tool()` overload unresolvable. | ||
| inputSchema: z.object({ reason: z.string() }), | ||
| execute: async (_input: { reason: string }): Promise<string> => { | ||
| throw new Error('Think tool failed on purpose'); | ||
| }, | ||
| }), | ||
| }; | ||
| } | ||
|
|
||
| public async onRequest(request: Request): Promise<Response> { | ||
| const message = new URL(request.url).searchParams.get('message') ?? 'What is the weather in Paris?'; | ||
|
|
||
| const result = await this.runTurn({ input: message }); | ||
|
|
||
| return Response.json({ continuation: result.continuation }); | ||
| } | ||
| } | ||
|
|
||
| export default { | ||
| async fetch(request, env): Promise<Response> { | ||
| return (await routeAgentRequest(request, env)) ?? new Response('Not found', { status: 404 }); | ||
| }, | ||
| } satisfies ExportedHandler<Env>; | ||
8 changes: 8 additions & 0 deletions
8
dev-packages/e2e-tests/test-applications/cloudflare-think/src/instrument.server.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import type { CloudflareOptions } from '@sentry/cloudflare'; | ||
|
|
||
| export default (env: Env): CloudflareOptions => ({ | ||
| dsn: env.E2E_TEST_DSN, | ||
| environment: 'qa', | ||
| tunnel: 'http://localhost:3031/', | ||
| tracesSampleRate: 1.0, | ||
| }); |
7 changes: 7 additions & 0 deletions
7
dev-packages/e2e-tests/test-applications/cloudflare-think/start-event-proxy.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { startEventProxyServer } from '@sentry-internal/test-utils'; | ||
|
|
||
| startEventProxyServer({ | ||
| port: 3031, | ||
| proxyServerName: 'cloudflare-think', | ||
| envelopeDumpPath: process.env.SENTRY_ENVELOPE_DUMP_PATH, | ||
| }); |
44 changes: 44 additions & 0 deletions
44
dev-packages/e2e-tests/test-applications/cloudflare-think/tests/dataloader.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
| import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; | ||
| import { attr, isTurnOf, newAgentId, runAgentTurn, type StreamedSpan } from './utils'; | ||
|
|
||
| const APP = 'cloudflare-think'; | ||
|
|
||
| const isDataloaderSpan = (span: { attributes?: Record<string, { value?: unknown }> }): boolean => | ||
| span.attributes?.['sentry.origin']?.value === 'auto.db.dataloader'; | ||
|
|
||
| /** | ||
| * `dataloader` is instrumented by the orchestrion module transform rather than by patching anything | ||
| * at runtime, so its spans are the probe for whether channel injection reached the worker at all. | ||
| * Libraries that do not need the transform (`node:http`, and the AI spans themselves) would pass | ||
| * even if injection were broken. | ||
| * | ||
| * On Cloudflare the injection is done at build time by `sentryCloudflareVitePlugin()`, so unlike the | ||
| * Node apps this needs no `--import` bootstrap. A Think worker is heavily bundled, which is exactly | ||
| * the situation where a transform can silently stop applying, so this is the test that catches it. | ||
| * | ||
| * The loader runs inside a tool so its spans land in the turn's trace rather than one of their own. | ||
| */ | ||
| test('captures orchestrion-instrumented dataloader spans in the same trace as the AI spans', async ({ baseURL }) => { | ||
| const agentId = newAgentId('dataloader'); | ||
| const ofThisTurn = isTurnOf(agentId); | ||
|
|
||
| const spansPromise = collectStreamedSpans( | ||
| APP, | ||
| spansOfTrace => | ||
| ofThisTurn(spansOfTrace as StreamedSpan[]) && | ||
| spansOfTrace.some(span => attr(span as StreamedSpan, 'gen_ai.tool.name') === 'get_weather') && | ||
| spansOfTrace.some(isDataloaderSpan), | ||
| ); | ||
|
|
||
| await runAgentTurn(baseURL!, agentId, 'What is the weather in Paris?'); | ||
|
|
||
| const spans = await spansPromise; | ||
| const dataloaderSpan = spans.find(isDataloaderSpan); | ||
| const toolSpan = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'get_weather'); | ||
|
|
||
| // Sharing the trace is the point. Not asserting the exact parent: the model may call the tool more | ||
| // than once, so the tool span found here is not reliably the one that ran this loader. | ||
| expect(getSpanOp(dataloaderSpan!)).toBe('cache.get'); | ||
| expect(dataloaderSpan?.trace_id).toBe(toolSpan?.trace_id); | ||
| }); |
51 changes: 51 additions & 0 deletions
51
dev-packages/e2e-tests/test-applications/cloudflare-think/tests/errors.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
| import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/test-utils'; | ||
| import { attr, isTurnOf, newAgentId, runAgentTurn, type StreamedSpan } from './utils'; | ||
|
|
||
| const APP = 'cloudflare-think'; | ||
|
|
||
| /** | ||
| * A tool throw is not an agent failure: the AI SDK catches it, hands the error back to the model as | ||
| * a tool result, and the turn carries on and usually answers. So the error has to reach Sentry from | ||
| * the tool span itself, and the spans above it stay `ok` — the model calls did succeed. | ||
| */ | ||
| test('captures an error thrown inside a Think tool and marks its span errored', async ({ baseURL }) => { | ||
| const agentId = newAgentId('tool-error'); | ||
| const ofThisTurn = isTurnOf(agentId); | ||
|
|
||
| const errorPromise = waitForError( | ||
| APP, | ||
| event => event.exception?.values?.[0]?.value === 'Think tool failed on purpose', | ||
| ); | ||
|
|
||
| const spansPromise = collectStreamedSpans( | ||
| APP, | ||
| spansOfTrace => | ||
| ofThisTurn(spansOfTrace as StreamedSpan[]) && | ||
| spansOfTrace.some(span => attr(span as StreamedSpan, 'gen_ai.tool.name') === 'fail_now'), | ||
| ); | ||
|
|
||
| await runAgentTurn(baseURL!, agentId, 'Please trigger a failure now.'); | ||
|
|
||
| const error = await errorPromise; | ||
| const spans = await spansPromise; | ||
|
|
||
| const exception = error.exception?.values?.[0]; | ||
| expect(exception?.type).toBe('Error'); | ||
| expect(exception?.value).toBe('Think tool failed on purpose'); | ||
| expect(exception?.mechanism?.type).toBe('auto.vercelai.channel'); | ||
|
|
||
| const toolSpan = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'fail_now'); | ||
| expect(getSpanOp(toolSpan!)).toBe('gen_ai.execute_tool'); | ||
| expect(toolSpan?.status).toBe('error'); | ||
|
|
||
| // The issue belongs to the same trace as the turn that produced it. | ||
| expect(error.contexts?.trace?.trace_id).toBe(toolSpan?.trace_id); | ||
|
|
||
| // The model calls around the failing tool succeeded, so only the tool span is errored. | ||
| const modelCalls = spans.filter(span => getSpanOp(span) === 'gen_ai.generate_content'); | ||
| expect(modelCalls.length).toBeGreaterThan(0); | ||
| for (const modelCall of modelCalls) { | ||
| expect(modelCall.status).toBe('ok'); | ||
| } | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: The
runTurnmethod can return a result wherecontinuationisundefineddue to a race condition. This value is used without a check, leading to an incorrect API response.Severity: MEDIUM
Suggested Fix
Add a check to ensure
resultandresult.continuationare notundefinedbefore returning the response. Ifresult.continuationis missing, consider throwing an error or returning a more explicit error response to the client instead of an empty object. For example:if (!result?.continuation) { throw new Error('Failed to get continuation from agent turn.'); } return Response.json({ continuation: result.continuation });.Prompt for AI Agent