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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)"
}
]
}
}
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;
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 {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { routeAgentRequest } from 'agents';

// Re-exported rather than declared here so the build-time wrapper has to cross a module boundary
// to find and wrap the class. See the note on the class itself.
export { ThinkAgent } from './think-agent';

export default {
async fetch(request, env): Promise<Response> {
return (await routeAgentRequest(request, env)) ?? new Response('Not found', { status: 404 });
},
} satisfies ExportedHandler<Env>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineCloudflareOptions } from '@sentry/cloudflare';

// The Sentry Vite plugin picks this file up by convention, next to the worker entry named in
// wrangler's `main`, and hands its default export to `withSentry`.
export default defineCloudflareOptions((env: Env) => ({
dsn: env.E2E_TEST_DSN,
environment: 'qa',
tunnel: 'http://localhost:3031/',
tracesSampleRate: 1.0,
}));
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { Think } from '@cloudflare/think';
import * as Sentry from '@sentry/cloudflare';
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 the worker
* entry's export of this class into `instrumentAgentWithSentry(...)` at build time. Wrapping it
* here would prove nothing about the zero-config path.
*
* The class deliberately lives outside the worker entry, which only re-exports it. That is the
* harder case for the plugin: the entry never binds the class, so detection has to follow the
* re-export into this module and the wrapper has to import the class under a private name.
*/
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 });
}
}
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,
});
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);
});
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');
}
});
Loading
Loading