Skip to content

Commit a6571ee

Browse files
RulaKhaledclaude
andauthored
test(e2e): Add a node-flue end-to-end application (#24377)
Stacked on #24266 — review that first, this is the e2e app only. The node-integration-test calls `__flueBindAgentModule` by hand, standing in for what `@flue/vite` does from the `'use agent'` directive, so it can't show whether a scaffolded app actually works. This is `flue init` output, built and served the way a user runs it, against a real provider. Covered, in both dev and prod and in both the base and orchestrion variants: - `invoke_agent` / `chat` / `execute_tool` with usage and cost - a thrown tool error becoming an issue - a manual span inside a tool nesting under `execute_tool` - an orchestrion-instrumented `dataloader` span landing in the agent's trace - the provider's `http.client` call nesting inside `chat` The loader runs inside a tool rather than a route so its span shares the agent's trace. No build externals are needed unlike `node-eve` — a Flue node build leaves dependencies as bare specifiers, so `dataloader` stays a real module for the transform to hook. `@flue/*` is pinned because the internal registry proxy 403s on releases it hasn't scanned. _Found_: a thrown tool error produced an errored span and no issue at all, since Flue catches the throw and hands it back to the model as a tool result. Fixed in #24265. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 8317107 commit a6571ee

14 files changed

Lines changed: 377 additions & 0 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
node_modules
2+
dist
3+
.flue
4+
*.tsbuildinfo
5+
results.junit.xml
6+
test-results
7+
playwright-report
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: 'node',
5+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"name": "node-flue",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "NODE_OPTIONS='--import=@sentry/node/import' vite dev --port 3030",
8+
"build": "vite build",
9+
"start": "NODE_OPTIONS='--import=@sentry/node/import' PORT=3030 node dist/server.mjs",
10+
"clean": "npx rimraf node_modules dist pnpm-lock.yaml",
11+
"test:build": "pnpm install && pnpm build",
12+
"test:build-latest": "pnpm install && pnpm add @flue/runtime@latest @flue/vite@latest @flue/cli@latest && pnpm build",
13+
"test:assert": "pnpm test:prod && pnpm test:dev",
14+
"test:prod": "OPENROUTER_API_KEY=$E2E_OPENROUTER_API_KEY TEST_ENV=production playwright test",
15+
"test:dev": "OPENROUTER_API_KEY=$E2E_OPENROUTER_API_KEY TEST_ENV=development playwright test"
16+
},
17+
"dependencies": {
18+
"@flue/runtime": "2.0.5",
19+
"@sentry/node": "file:../../packed/sentry-node-packed.tgz",
20+
"dataloader": "^2.2.3",
21+
"hono": "^4.7.0",
22+
"valibot": "^1.5.0"
23+
},
24+
"devDependencies": {
25+
"@flue/cli": "2.0.5",
26+
"@flue/vite": "2.0.5",
27+
"@playwright/test": "~1.56.0",
28+
"@sentry-internal/test-utils": "link:../../../test-utils",
29+
"@sentry/core": "file:../../packed/sentry-core-packed.tgz",
30+
"@types/node": "24.x",
31+
"typescript": "~5.9.0",
32+
"vite": "^8.0.14"
33+
},
34+
"engines": {
35+
"node": "24.x"
36+
},
37+
"volta": {
38+
"node": "24.15.0",
39+
"extends": "../../package.json"
40+
},
41+
"sentryTest": {
42+
"optional": true,
43+
"optionalVariants": [
44+
{
45+
"build-command": "pnpm test:build-latest",
46+
"label": "node-flue (latest)"
47+
}
48+
]
49+
}
50+
}
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: testEnv === 'development' ? 'pnpm dev' : 'pnpm start' },
11+
// Each test drives a real OpenRouter tool-calling turn and then waits for the spans to flush,
12+
// which does not fit the default 30s timeout when the provider is slow.
13+
{ timeout: 90_000 },
14+
);
15+
16+
export default config;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { instrument } from '@flue/runtime';
2+
import * as Sentry from '@sentry/node';
3+
4+
// Imported for its side effects as the first line of `src/app.ts`, which is how a Flue app sets
5+
// Sentry up: there is no framework-owned instrumentation hook to auto-discover.
6+
Sentry.init({
7+
environment: 'qa',
8+
dsn: process.env.E2E_TEST_DSN,
9+
tunnel: 'http://localhost:3031/', // proxy server
10+
tracesSampleRate: 1.0,
11+
});
12+
13+
instrument(Sentry.createFlueInstrumentation());
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
'use agent';
2+
import { useModel, useTool } from '@flue/runtime';
3+
import * as Sentry from '@sentry/node';
4+
import * as v from 'valibot';
5+
import DataLoader from 'dataloader';
6+
7+
// The `'use agent'` directive is how `@flue/vite` finds this module and binds an identity to it at
8+
// build time. That binding is the part a hand-written scenario cannot reproduce, so it is the main
9+
// reason this app exists alongside the node-integration-test suite.
10+
export function Hello() {
11+
useModel('openrouter/anthropic/claude-haiku-4.5');
12+
13+
useTool({
14+
name: 'get_weather',
15+
description: 'Get the current weather for a city.',
16+
input: v.object({ city: v.string() }),
17+
// Wrapped in a manual span: Flue runs the tool while the SDK's `execute_tool` span is active,
18+
// so this should nest directly under it rather than landing beside it.
19+
run: ({ city }) =>
20+
Sentry.startSpan(
21+
{ name: 'resolve-weather', attributes: { 'weather.source': 'static-table', 'weather.city': city } },
22+
() => {
23+
return `It is 21 degrees and sunny in ${city}.`;
24+
},
25+
),
26+
});
27+
28+
// Called from inside a tool on purpose: the dataloader span then lands under `execute_tool` in
29+
// the agent's trace, which is what "captured alongside the AI spans" has to mean.
30+
useTool({
31+
name: 'count_items',
32+
description: 'Count items by loading them. Call this when the user asks to count items.',
33+
input: v.object({}),
34+
// Constructed per execution, like node-eve does: a module-level loader caches its keys, so a
35+
// second call would skip the batch function and emit no span.
36+
run: async () => {
37+
const loader = new DataLoader<number, number>(async keys => keys.map(key => key * 2));
38+
const doubled = await Promise.all([loader.load(1), loader.load(2), loader.load(3)]);
39+
return `Loaded ${doubled.length} items: ${doubled.join(', ')}.`;
40+
},
41+
});
42+
43+
useTool({
44+
name: 'fail_now',
45+
description: 'Always throws an error. Call this when the user asks to trigger a failure.',
46+
input: v.object({}),
47+
run: () => {
48+
throw new Error('Intentional flue tool failure');
49+
},
50+
});
51+
52+
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.';
53+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import '../sentry-init.ts';
2+
import { createAgentRouter } from '@flue/runtime/routing';
3+
import { Hono } from 'hono';
4+
import { Hello } from './agents/hello.ts';
5+
6+
const app = new Hono();
7+
8+
app.route('/agents/hello', createAgentRouter(Hello));
9+
10+
export default app;
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: 'node-flue',
6+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { expect, test } from '@playwright/test';
2+
import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils';
3+
import { newConversationId, runAgentTurn } from './utils';
4+
5+
const APP = 'node-flue';
6+
7+
const isDataloaderSpan = (span: { attributes?: Record<string, { value?: unknown }> }): boolean =>
8+
span.attributes?.['sentry.origin']?.value === 'auto.db.dataloader';
9+
10+
/**
11+
* `dataloader` is instrumented through orchestrion, a module transform, so it only produces spans
12+
* with the loader registered at process start. A Flue node build needs no externals config for
13+
* that: dependencies stay bare specifiers, so `dataloader` is still a real module to hook. If Flue
14+
* ever switches to a bundled server output, this is what catches it.
15+
*
16+
* The loader is called from inside a tool so its span lands in the agent's trace, beside the AI
17+
* spans, rather than in a trace of its own.
18+
*/
19+
test('captures orchestrion-instrumented dataloader spans in the same trace as the AI spans', async ({ baseURL }) => {
20+
const spansPromise = collectStreamedSpans(
21+
APP,
22+
spansOfTrace =>
23+
spansOfTrace.some(span => span.attributes?.['gen_ai.tool.name']?.value === 'count_items') &&
24+
spansOfTrace.some(isDataloaderSpan),
25+
);
26+
27+
await runAgentTurn(baseURL!, newConversationId('dataloader'), 'Please call count_items to count the items.');
28+
29+
const spans = await spansPromise;
30+
const dataloaderSpan = spans.find(isDataloaderSpan);
31+
const toolSpan = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'count_items');
32+
33+
// Sharing the trace is the point: the orchestrion span is captured alongside the AI spans rather
34+
// than in a trace of its own. Not asserting the exact parent — the model may call the tool more
35+
// than once, and the span that ran the loader is not reliably the one found here.
36+
expect(getSpanOp(dataloaderSpan!)).toBe('cache.get');
37+
expect(dataloaderSpan?.trace_id).toBe(toolSpan?.trace_id);
38+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { expect, test } from '@playwright/test';
2+
import { collectStreamedSpans, waitForError } from '@sentry-internal/test-utils';
3+
import { newConversationId, runAgentTurn } from './utils';
4+
5+
const APP = 'node-flue';
6+
7+
test('captures an error thrown inside a Flue tool and marks its span errored', async ({ baseURL }) => {
8+
const errorPromise = waitForError(
9+
APP,
10+
event => event.exception?.values?.[0]?.value === 'Intentional flue tool failure',
11+
);
12+
const spansPromise = collectStreamedSpans(APP, spansOfTrace =>
13+
spansOfTrace.some(span => span.attributes?.['gen_ai.tool.name']?.value === 'fail_now'),
14+
);
15+
16+
await runAgentTurn(baseURL!, newConversationId('failure'), 'Please call fail_now to trigger a failure.');
17+
18+
const error = await errorPromise;
19+
expect(error.exception?.values?.[0]?.value).toBe('Intentional flue tool failure');
20+
21+
const spans = await spansPromise;
22+
const executeTool = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'fail_now');
23+
expect(executeTool?.status).toBe('error');
24+
});

0 commit comments

Comments
 (0)