From 9796d9c03dcebcf083a40575596817a276920abf Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Wed, 23 Sep 2026 17:49:55 +0300 Subject: [PATCH 1/2] test(cloudflare): Add e2e test app for `@cloudflare/think` Records what a Think agent produces with the current SDK, before any Think-specific code exists. The worker imports no Sentry API at all: `sentryCloudflareVitePlugin()` detects `class ThinkAgent extends Think` and wraps the export at build time, and `ai` publishes the telemetry `vercelAIIntegration` already consumes. Covers the same five things the other AI apps do: the gen_ai hierarchy, a tool throw becoming an issue with an errored span, a manual span nesting under the tool span, the provider request staying inside the turn, and `dataloader` proving the orchestrion transform reaches a bundled worker. Runs on `ai` v7 by default with a v6 lane as an optional variant, because Think's peer range spans both and the two produce different trace shapes. Co-Authored-By: Claude Opus 5 --- .../cloudflare-think/package.json | 50 +++++++ .../cloudflare-think/playwright.config.ts | 22 +++ .../cloudflare-think/src/env.d.ts | 9 ++ .../cloudflare-think/src/index.ts | 79 +++++++++++ .../cloudflare-think/src/instrument.server.ts | 8 ++ .../cloudflare-think/start-event-proxy.mjs | 7 + .../cloudflare-think/tests/dataloader.test.ts | 44 ++++++ .../cloudflare-think/tests/errors.test.ts | 51 +++++++ .../cloudflare-think/tests/think.test.ts | 131 ++++++++++++++++++ .../cloudflare-think/tests/utils.ts | 54 ++++++++ .../cloudflare-think/tsconfig.json | 20 +++ .../cloudflare-think/vite.config.mts | 17 +++ .../cloudflare-think/wrangler.toml | 18 +++ 13 files changed, 510 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-think/package.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-think/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-think/src/env.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-think/src/index.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-think/src/instrument.server.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-think/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-think/tests/dataloader.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-think/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-think/tests/think.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-think/tests/utils.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-think/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-think/vite.config.mts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-think/wrangler.toml diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-think/package.json new file mode 100644 index 000000000000..0bb10a6be700 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/package.json @@ -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)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/playwright.config.ts new file mode 100644 index 000000000000..1bd6d0e2f2e0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/playwright.config.ts @@ -0,0 +1,22 @@ +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, + }, + { + workers: '100%', + retries: 0, + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/src/env.d.ts new file mode 100644 index 000000000000..c18846580a4e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/src/env.d.ts @@ -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 {} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/src/index.ts new file mode 100644 index 000000000000..81440316398d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/src/index.ts @@ -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 { + 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(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`. Either one alone makes the `tool()` overload unresolvable. + inputSchema: z.object({ reason: z.string() }), + execute: async (_input: { reason: string }): Promise => { + throw new Error('Think tool failed on purpose'); + }, + }), + }; + } + + public async onRequest(request: Request): Promise { + 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 { + return (await routeAgentRequest(request, env)) ?? new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/src/instrument.server.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/src/instrument.server.ts new file mode 100644 index 000000000000..009b3f0b5f8f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/src/instrument.server.ts @@ -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, +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-think/start-event-proxy.mjs new file mode 100644 index 000000000000..784fc99f86ba --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/start-event-proxy.mjs @@ -0,0 +1,7 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'cloudflare-think', + envelopeDumpPath: process.env.SENTRY_ENVELOPE_DUMP_PATH, +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/dataloader.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/dataloader.test.ts new file mode 100644 index 000000000000..a2336e3ac52c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/dataloader.test.ts @@ -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 }): 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); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/errors.test.ts new file mode 100644 index 000000000000..76f65c74e01b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/errors.test.ts @@ -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'); + } +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/think.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/think.test.ts new file mode 100644 index 000000000000..a716be6c4977 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/think.test.ts @@ -0,0 +1,131 @@ +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'; + +/** + * The whole point of the app: the user's worker imports no Sentry API at all. `sentryCloudflareVitePlugin()` + * detects `class ThinkAgent extends Think` and wraps the export at build time, and the `ai` SDK publishes + * the telemetry `vercelAIIntegration` consumes. Nothing here is Think-specific on the SDK side, so this + * test is what would catch either half of that chain breaking. + * + * `collectStreamedSpans` rather than `waitForStreamedSpans`: the streamed `invoke_agent` parent stays + * open until its children settle and flushes in a separate envelope from them, so a single envelope + * never holds the whole turn. + */ +test('captures the invoke_agent / generate_content / execute_tool hierarchy for a Think turn', async ({ baseURL }) => { + const agentId = newAgentId('hierarchy'); + const ofThisTurn = isTurnOf(agentId); + + const spansPromise = collectStreamedSpans( + APP, + spansOfTrace => + ofThisTurn(spansOfTrace as StreamedSpan[]) && + ['gen_ai.invoke_agent', 'gen_ai.generate_content', 'gen_ai.execute_tool'].every(op => + spansOfTrace.some(span => getSpanOp(span) === op), + ), + ); + + await runAgentTurn(baseURL!, agentId, 'What is the weather in Paris?'); + + const spans = (await spansPromise) as StreamedSpan[]; + + const invokeAgent = spans.find(span => getSpanOp(span) === 'gen_ai.invoke_agent'); + const generateContent = spans.find(span => getSpanOp(span) === 'gen_ai.generate_content'); + const executeTool = spans.find(span => getSpanOp(span) === 'gen_ai.execute_tool'); + + expect(attr(invokeAgent, 'sentry.origin')).toBe('auto.vercelai.channel'); + expect(attr(invokeAgent, 'gen_ai.operation.name')).toBe('invoke_agent'); + expect(attr(invokeAgent, 'gen_ai.provider.name')).toBe('openrouter'); + expect(typeof attr(invokeAgent, 'gen_ai.usage.input_tokens')).toBe('number'); + expect(typeof attr(invokeAgent, 'gen_ai.usage.output_tokens')).toBe('number'); + expect(typeof attr(invokeAgent, 'gen_ai.usage.total_tokens')).toBe('number'); + + expect(attr(generateContent, 'gen_ai.operation.name')).toBe('generate_content'); + expect(attr(generateContent, 'gen_ai.request.model')).toBe('openai/gpt-4o-mini'); + + expect(attr(executeTool, 'gen_ai.operation.name')).toBe('execute_tool'); + expect(attr(executeTool, 'gen_ai.tool.name')).toBe('get_weather'); + + // Inputs and outputs are recorded under the SDK's default data collection (the app sets no + // `dataCollection` override). + expect(String(attr(invokeAgent, 'gen_ai.input.messages') ?? '')).toContain('Paris'); + expect(String(attr(executeTool, 'gen_ai.tool.call.arguments') ?? '')).toContain('Paris'); + expect(String(attr(executeTool, 'gen_ai.tool.call.result') ?? '')).toContain('Sunny'); + + // Think drives one `streamText` per turn with the step loop inside it, so the model calls are + // children of the single agent span rather than siblings of it. + expect(generateContent?.parent_span_id).toBe(invokeAgent?.span_id); + expect(executeTool?.parent_span_id).toBe(invokeAgent?.span_id); +}); + +/** + * A span the user starts inside a tool has to nest under that tool's span, which only holds if Think + * runs tool execution inside the async context the SDK opened. Nothing in the SDK arranges this for + * Think specifically, so it is worth pinning. + */ +test('nests a manual span raised inside a tool under that tool span', async ({ baseURL }) => { + const agentId = newAgentId('manual-span'); + const ofThisTurn = isTurnOf(agentId); + + const spansPromise = collectStreamedSpans( + APP, + spansOfTrace => + ofThisTurn(spansOfTrace as StreamedSpan[]) && + spansOfTrace.some(span => getSpanOp(span) === 'gen_ai.execute_tool') && + spansOfTrace.some(span => getSpanOp(span) === 'gen_ai.tool.manual'), + ); + + await runAgentTurn(baseURL!, agentId, 'What is the weather in Paris?'); + + const spans = (await spansPromise) as StreamedSpan[]; + const manualSpan = spans.find(span => getSpanOp(span) === 'gen_ai.tool.manual'); + const toolSpan = spans.find(span => span.span_id === manualSpan?.parent_span_id); + + expect(manualSpan?.name).toBe('lookup-forecast'); + expect(getSpanOp(toolSpan!)).toBe('gen_ai.execute_tool'); + expect(attr(toolSpan, 'gen_ai.tool.name')).toBe('get_weather'); +}); + +/** + * The request to the model provider must join the turn's trace rather than run beside it. A mocked + * model cannot show this, which is why the app calls OpenRouter for real. + * + * Where it lands differs by `ai` major, and the difference is ours rather than the framework's. On + * v7 the native `ai:telemetry` channel binds the model-call span into async context, so the fetch it + * wraps nests under `gen_ai.generate_content`. On v4-v6 the model span comes from the orchestrion + * `resolveLanguageModel` patch, which never makes that span active, so the fetch attaches to the + * agent span instead and lands as the model call's sibling. Both keep the request in the turn, which + * is what this asserts for every lane; the tighter parent is asserted only where it holds, so the + * v6 shape is recorded rather than papered over. + */ +test('keeps the provider HTTP call inside the turn, under the model call on ai >= 7', async ({ baseURL }) => { + const agentId = newAgentId('provider-http'); + const ofThisTurn = isTurnOf(agentId); + + const isProviderRequest = (span: StreamedSpan): boolean => + getSpanOp(span) === 'http.client' && String(attr(span, 'server.address') ?? span.name ?? '').includes('openrouter'); + + const spansPromise = collectStreamedSpans( + APP, + spansOfTrace => + ofThisTurn(spansOfTrace as StreamedSpan[]) && + spansOfTrace.some(span => getSpanOp(span) === 'gen_ai.generate_content') && + spansOfTrace.some(span => isProviderRequest(span as StreamedSpan)), + ); + + await runAgentTurn(baseURL!, agentId, 'What is the weather in Paris?'); + + const spans = (await spansPromise) as StreamedSpan[]; + const providerRequest = spans.find(isProviderRequest); + const invokeAgent = spans.find(span => getSpanOp(span) === 'gen_ai.invoke_agent'); + const parent = spans.find(span => span.span_id === providerRequest?.parent_span_id); + + expect(providerRequest?.trace_id).toBe(invokeAgent?.trace_id); + expect(['gen_ai.generate_content', 'gen_ai.invoke_agent']).toContain(getSpanOp(parent!)); + + if ((process.env.AI_SDK_MAJOR ?? '7') !== '6') { + expect(getSpanOp(parent!)).toBe('gen_ai.generate_content'); + } +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/utils.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/utils.ts new file mode 100644 index 000000000000..dc30ad54f95d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/utils.ts @@ -0,0 +1,54 @@ +import { expect } from '@playwright/test'; + +export type StreamedSpan = { + span_id?: string; + parent_span_id?: string; + trace_id?: string; + name?: string; + status?: string; + attributes?: Record; +}; + +export const attr = (span: StreamedSpan | undefined, key: string): unknown => span?.attributes?.[key]?.value; + +/** + * An agent instance id nothing has used yet. + * + * The id is the `:name` path segment `routeAgentRequest` maps to a Durable Object, so reusing one + * across tests would reuse its stored transcript and alarm state. A fresh id per turn keeps each + * test to its own agent, and keeps `test:dev` from inheriting what `test:prod` left behind. + */ +export function newAgentId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * Whether a trace belongs to the turn `agentId` drove. + * + * Every test in this app drives the same agent class through the same two tools, so a predicate + * written only in terms of span ops matches any test's trace. Playwright runs the files in parallel + * against one worker and one event proxy, so that is not hypothetical: a test asserting on + * `get_weather` will happily latch onto the trace of the one asserting on `fail_now`. The agent id + * is in the request path, which is unique per turn, so scoping on it is what keeps each test to its + * own trace. + */ +export function isTurnOf(agentId: string) { + return (spansOfTrace: StreamedSpan[]): boolean => + spansOfTrace.some(span => String(attr(span, 'url.path') ?? '').endsWith(`/${agentId}`)); +} + +/** + * Run one Think turn and wait for it to finish. + * + * The agent's `onRequest` drives `runTurn()` in its default `wait` mode, so unlike frameworks that + * admit the work and return `202`, the response only arrives once the turn has settled. That makes + * this a plain request: no polling needed. + */ +export async function runAgentTurn(baseURL: string, agentId: string, message: string): Promise { + const res = await fetch(`${baseURL}/agents/think-agent/${agentId}?message=${encodeURIComponent(message)}`, { + method: 'POST', + }); + + expect(res.status).toBe(200); + await res.text(); +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-think/tsconfig.json new file mode 100644 index 000000000000..f60672dc80e9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["es2022"], + "module": "es2022", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "types": ["@cloudflare/workers-types/experimental"] + }, + "exclude": ["tests"], + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/vite.config.mts b/dev-packages/e2e-tests/test-applications/cloudflare-think/vite.config.mts new file mode 100644 index 000000000000..46781d245acc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/vite.config.mts @@ -0,0 +1,17 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + resolve: { + alias: { + // `@cloudflare/think` pulls in `just-bash` -> `turndown`, whose Node build calls a bare + // `require('@mixmark-io/domino')` at module scope when `DOMParser` is missing. workerd has no + // `DOMParser` and no `require`, so the worker dies on startup before any handler runs. This is + // upstream of Sentry — it reproduces with `cloudflare()` alone. `turndown` already ships a + // browser build with that branch compiled out; point at it directly. + turndown: 'turndown/lib/turndown.browser.es.js', + }, + }, + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/wrangler.toml b/dev-packages/e2e-tests/test-applications/cloudflare-think/wrangler.toml new file mode 100644 index 000000000000..6be3e1f730ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/wrangler.toml @@ -0,0 +1,18 @@ +#:schema node_modules/wrangler/config-schema.json +name = "cloudflare-think" +main = "src/index.ts" +compatibility_date = "2026-09-15" +compatibility_flags = ["nodejs_compat"] + +# `vite preview` takes no `--var` flag, so the DSN is declared here and read from +# the process environment the Playwright web server inherits. +[secrets] +required = ["E2E_TEST_DSN", "E2E_OPENROUTER_API_KEY"] + +[[durable_objects.bindings]] +name = "ThinkAgent" +class_name = "ThinkAgent" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["ThinkAgent"] From e52780fab9f32d737a8f372fdb9f43f7449c8373 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Thu, 24 Sep 2026 12:06:59 +0300 Subject: [PATCH 2/2] test(cloudflare): Run the Think e2e suite serially with a 90s timeout `getPlaywrightConfig` already defaults to `workers: 1` and a 30s timeout. The app overrode both, so five tests each driving a live OpenRouter turn ran in parallel under a cap meant for mocked models. That came from copying `cloudflare-vercelai-v7`, which mocks its model; the two apps that call a real provider, `cloudflare-mastra` and `node-flue`, keep the serial default and raise the timeout to 90s instead. Also corrects the comment above the first test, which still claimed the worker imports no Sentry API. That stopped being true when the manual-span test was added, and the worker now imports `startSpan` for exactly that. Co-Authored-By: Claude Opus 5 --- .../cloudflare-think/playwright.config.ts | 9 +++++---- .../cloudflare-think/tests/think.test.ts | 7 ++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/playwright.config.ts index 1bd6d0e2f2e0..d5838d66e04a 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-think/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/playwright.config.ts @@ -13,10 +13,11 @@ const config = getPlaywrightConfig( startCommand: `pnpm preview`, port: APP_PORT, }, - { - workers: '100%', - retries: 0, - }, + // 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; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/think.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/think.test.ts index a716be6c4977..2a74ba884db9 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/think.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/think.test.ts @@ -5,10 +5,11 @@ import { attr, isTurnOf, newAgentId, runAgentTurn, type StreamedSpan } from './u const APP = 'cloudflare-think'; /** - * The whole point of the app: the user's worker imports no Sentry API at all. `sentryCloudflareVitePlugin()` + * The whole point of the app: nothing wires Sentry into Think by hand. `sentryCloudflareVitePlugin()` * detects `class ThinkAgent extends Think` and wraps the export at build time, and the `ai` SDK publishes - * the telemetry `vercelAIIntegration` consumes. Nothing here is Think-specific on the SDK side, so this - * test is what would catch either half of that chain breaking. + * the telemetry `vercelAIIntegration` consumes. The worker's only Sentry import is the `startSpan` the + * manual-span test below needs. Nothing here is Think-specific on the SDK side, so this test is what + * would catch either half of that chain breaking. * * `collectStreamedSpans` rather than `waitForStreamedSpans`: the streamed `invoke_agent` parent stays * open until its children settle and flushes in a separate envelope from them, so a single envelope