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..d5838d66e04a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/playwright.config.ts @@ -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; 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..2a74ba884db9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/think.test.ts @@ -0,0 +1,132 @@ +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: 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. 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 + * 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"]