Skip to content

Commit 33b0769

Browse files
RulaKhaledclaude
andauthored
test(e2e): Add a cloudflare-flue end-to-end application (#24477)
Stacked on #24476 — review that first. A `flue init` Cloudflare app with no `instrument()` call anywhere: registration comes from the build, so any `gen_ai` span here is itself proof the auto-wiring worked. Covers AI spans, tool-error capture, a manual span nesting under its tool, and an orchestrion `dataloader` span in the agent's trace. Secrets go through `.dev.vars` rather than `--var` because Flue resolves the provider key inside `pi-ai` at runtime, leaving nothing for Vite to inline, and `vite preview` is what serves the worker with Flue's generated Durable Object config. Two Flue constraints the app works around, both commented in place: `agents` is imported by Flue's generated worker entry without being declared, so it only resolves under npm's hoisting and needs declaring for pnpm; and the `'use agent'` scan parses every source file as plain JavaScript, so generics and return types fail the build. _Worth a docs note_: the Sentry wrapper has to be re-exported as `cloudflare` from the agent module. Defining it elsewhere leaves the Durable Object unwrapped — the agent runs, turns settle, and nothing is traced. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 72bc0df commit 33b0769

17 files changed

Lines changed: 357 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { defineConfig } from '@flue/runtime/config';
2+
3+
export default defineConfig({
4+
target: 'cloudflare',
5+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"name": "cloudflare-flue",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"build": "vite build",
8+
"preview": "vite preview --port 4112",
9+
"typecheck": "tsc --noEmit",
10+
"clean": "npx rimraf node_modules dist .wrangler pnpm-lock.yaml",
11+
"test:build": "pnpm install && OPENROUTER_API_KEY=$E2E_OPENROUTER_API_KEY pnpm build",
12+
"test:assert": "pnpm test:prod",
13+
"test:prod": "TEST_ENV=production OPENROUTER_API_KEY=$E2E_OPENROUTER_API_KEY playwright test"
14+
},
15+
"dependencies": {
16+
"@flue/runtime": "2.0.5",
17+
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
18+
"agents": "0.20.1",
19+
"dataloader": "~2.2.3",
20+
"hono": "^4.13.8",
21+
"valibot": "~1.5.0"
22+
},
23+
"devDependencies": {
24+
"@cloudflare/vite-plugin": "1.52.0",
25+
"@cloudflare/workers-types": "^4.20260426.0",
26+
"@flue/cli": "2.0.5",
27+
"@flue/vite": "2.0.5",
28+
"@playwright/test": "~1.63.0",
29+
"@sentry-internal/test-utils": "link:../../../test-utils",
30+
"typescript": "^5.5.2",
31+
"vite": "7.3.5",
32+
"wrangler": "^4.86.0"
33+
},
34+
"volta": {
35+
"node": "24.15.0",
36+
"extends": "../../package.json"
37+
},
38+
"sentryTest": {
39+
"optional": true
40+
}
41+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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 config = getPlaywrightConfig(
10+
{ startCommand: 'pnpm preview', port: 4112 },
11+
// Each test drives a real OpenRouter turn and then waits for the spans to flush, which does not
12+
// fit the default 30s timeout when the provider is slow.
13+
{ timeout: 90_000 },
14+
);
15+
16+
export default config;
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
'use agent';
2+
import { useModel, useTool } from '@flue/runtime';
3+
import * as Sentry from '@sentry/cloudflare';
4+
import * as v from 'valibot';
5+
import { createItemLoader } from '../loaders.ts';
6+
7+
// Flue applies the agent's Durable Object wrapper from this re-export.
8+
export { cloudflare } from '../sentry.ts';
9+
10+
// The `'use agent'` directive is how `@flue/vite` binds an identity to this module at build time.
11+
export function Hello() {
12+
useModel('openrouter/anthropic/claude-haiku-4.5');
13+
14+
useTool({
15+
name: 'get_weather',
16+
description: 'Get the current weather for a city.',
17+
input: v.object({ city: v.string() }),
18+
// Wrapped in a manual span: Flue runs the tool while the SDK's `execute_tool` span is active,
19+
// so this should nest directly under it rather than landing beside it.
20+
run: ({ city }) =>
21+
Sentry.startSpan({ name: 'resolve-weather', attributes: { 'weather.source': 'static-table' } }, () => {
22+
return `It is 21 degrees and sunny in ${city}.`;
23+
}),
24+
});
25+
26+
// Called from inside a tool so the dataloader span lands in the agent's trace beside the AI
27+
// spans. Constructed per execution: a module-level loader caches its keys, so a second call
28+
// would skip the batch function and emit no span.
29+
useTool({
30+
name: 'count_items',
31+
description: 'Count items by loading them. Call this when the user asks to count items.',
32+
input: v.object({}),
33+
run: async () => {
34+
const loader = createItemLoader();
35+
const doubled = await Promise.all([loader.load(1), loader.load(2), loader.load(3)]);
36+
return `Loaded ${doubled.length} items: ${doubled.join(', ')}.`;
37+
},
38+
});
39+
40+
useTool({
41+
name: 'fail_now',
42+
description: 'Always throws an error. Call this when the user asks to trigger a failure.',
43+
input: v.object({}),
44+
run: () => {
45+
throw new Error('Intentional flue tool failure');
46+
},
47+
});
48+
49+
return 'You are a helpful assistant. Use get_weather when asked about weather, count_items when asked to count items, and fail_now when asked to fail.';
50+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { createAgentRouter } from '@flue/runtime/routing';
2+
import { Hono } from 'hono';
3+
import { Hello } from './agents/hello.ts';
4+
5+
const app = new Hono();
6+
7+
app.route('/agents/hello', createAgentRouter(Hello));
8+
9+
export default app;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Worker-level Cloudflare code would live here; the Sentry wrapper is in `src/sentry.ts` and is
2+
// re-exported from the agent module, which is how Flue applies it to the agent's Durable Object.
3+
4+
export {};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
interface Env {
2+
E2E_TEST_DSN: string;
3+
OPENROUTER_API_KEY: string;
4+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import DataLoader from 'dataloader';
2+
3+
/**
4+
* Built per call, not shared: a module-level loader caches its keys, so a second `count_items` would
5+
* skip the batch function and emit no span.
6+
*
7+
* Deliberately free of type annotations and generics. Flue's build scans every source file looking
8+
* for `'use agent'` modules and parses them as plain JavaScript, so a return type or a
9+
* `new DataLoader<number, number>(…)` fails the build with a parse error pointing at this file.
10+
*/
11+
export function createItemLoader() {
12+
return new DataLoader(async keys => keys.map(key => Number(key) * 2));
13+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { extend } from '@flue/runtime/cloudflare';
2+
import * as Sentry from '@sentry/cloudflare';
3+
4+
// Each Flue agent runs in its own Durable Object, so the DO class is what has to be wrapped for
5+
// `Sentry.init` to run and spans to be flushed. The agent module re-exports this as `cloudflare`,
6+
// which is how Flue picks it up — defining it here alone does nothing.
7+
//
8+
// There is deliberately no `instrument()` call in this app: registering the Flue instrumentation is
9+
// what `@sentry/cloudflare/vite` does at build time, and these tests exist to prove it.
10+
export const cloudflare = extend({
11+
wrap: Final =>
12+
Sentry.instrumentDurableObjectWithSentry(
13+
(env: Env) => ({
14+
dsn: env.E2E_TEST_DSN,
15+
environment: 'qa',
16+
tunnel: 'http://localhost:3031/', // proxy server
17+
tracesSampleRate: 1.0,
18+
}),
19+
Final,
20+
),
21+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { startEventProxyServer } from '@sentry-internal/test-utils';
2+
3+
startEventProxyServer({
4+
port: 3031,
5+
proxyServerName: 'cloudflare-flue',
6+
});

0 commit comments

Comments
 (0)