Skip to content
Open
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
7 changes: 7 additions & 0 deletions dev-packages/e2e-tests/test-applications/node-flue/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules
dist
.flue
*.tsbuildinfo
results.junit.xml
test-results
playwright-report
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { defineConfig } from '@flue/runtime/config';

export default defineConfig({
target: 'node',
});
59 changes: 59 additions & 0 deletions dev-packages/e2e-tests/test-applications/node-flue/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"name": "node-flue",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 3030",
"build": "vite build",
"start": "PORT=3030 node dist/server.mjs",
"dev:orchestrion": "NODE_OPTIONS='--import=@sentry/node/import' pnpm dev",
"start:orchestrion": "NODE_OPTIONS='--import=@sentry/node/import' pnpm start",
"clean": "npx rimraf node_modules dist pnpm-lock.yaml",
"test:build": "pnpm install && pnpm build",
"test:build-orchestrion": "USE_ORCHESTRION=1 pnpm test:build",
"test:build-latest": "pnpm install && pnpm add @flue/runtime@latest @flue/vite@latest @flue/cli@latest && pnpm build",
"test:assert": "pnpm test:prod && pnpm test:dev",
"test:assert-orchestrion": "USE_ORCHESTRION=1 pnpm test:assert",
"test:prod": "OPENROUTER_API_KEY=$E2E_OPENROUTER_API_KEY TEST_ENV=production playwright test",
"test:dev": "OPENROUTER_API_KEY=$E2E_OPENROUTER_API_KEY TEST_ENV=development playwright test"
},
"dependencies": {
"@flue/runtime": "2.0.5",
"@sentry/node": "file:../../packed/sentry-node-packed.tgz",
"dataloader": "^2.2.3",
"hono": "^4.7.0",
"valibot": "^1.5.0"
},
"devDependencies": {
"@flue/cli": "2.0.5",
"@flue/vite": "2.0.5",
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@sentry/core": "file:../../packed/sentry-core-packed.tgz",
"@types/node": "24.x",
"typescript": "~5.9.0",
"vite": "^8.0.14"
},
"engines": {
"node": "24.x"
},
"volta": {
"node": "24.15.0",
"extends": "../../package.json"
},
"sentryTest": {
"optional": true,
"optionalVariants": [
{
"build-command": "pnpm test:build-latest",
"label": "node-flue (latest)"
},
{
"build-command": "pnpm test:build-orchestrion",
"assert-command": "pnpm test:assert-orchestrion",
"label": "node-flue (orchestrion)"
}
]
}
}
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;
const useOrchestrion = process.env.USE_ORCHESTRION === '1';

if (!testEnv) {
throw new Error('No test env defined');
}

let startCommand = testEnv === 'development' ? 'pnpm dev' : 'pnpm start';

if (useOrchestrion) {
startCommand = `${startCommand}:orchestrion`;
}

const config = getPlaywrightConfig(
{ startCommand },
// Each agent turn is a real OpenRouter tool-calling round trip (two model calls) followed by a
// span flush, which does not fit the default 30s timeout when the provider is slow.
{ timeout: 90_000 },
);

export default config;
17 changes: 17 additions & 0 deletions dev-packages/e2e-tests/test-applications/node-flue/sentry-init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { instrument } from '@flue/runtime';
import * as Sentry from '@sentry/node';

// Imported for its side effects as the first line of `src/app.ts`, which is how a Flue app is
// expected to set Sentry up: there is no framework-owned instrumentation hook to auto-discover.
Sentry.init({
environment: 'qa',
dsn: process.env.E2E_TEST_DSN,
tunnel: 'http://localhost:3031/', // proxy server
tracesSampleRate: 1.0,
// Not a default integration. It only produces spans in the "orchestrion" test variant, where the
// server starts with `NODE_OPTIONS=--import=@sentry/node/import` so the module transform is
// registered before `dataloader` is loaded.
integrations: [Sentry.dataloaderIntegration()],
});

instrument(Sentry.createFlueInstrumentation());
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use agent';
import { useModel, useTool } from '@flue/runtime';
import * as Sentry from '@sentry/node';
import * as v from 'valibot';
import { itemLoader } from '../loaders.ts';

// The `'use agent'` directive is how `@flue/vite` finds this module and binds an identity to it at
// build time. That binding is the part a hand-written scenario cannot reproduce, so it is the main
// reason this app exists alongside the node-integration-test suite.
export function Hello() {
useModel('openrouter/anthropic/claude-haiku-4.5');

useTool({
name: 'get_weather',
description: 'Get the current weather for a city.',
input: v.object({ city: v.string() }),
// Wrapped in a manual span: Flue runs the tool while the SDK's `execute_tool` span is active,
// so this should nest directly under it rather than landing beside it.
run: ({ city }) =>
Sentry.startSpan(
{ name: 'resolve-weather', attributes: { 'weather.source': 'static-table', 'weather.city': city } },
() => {
return `It is 21 degrees and sunny in ${city}.`;
},
),
});

// Called from inside a tool on purpose: the dataloader span then lands under `execute_tool` in
// the agent's trace, which is what "captured alongside the AI spans" has to mean.
useTool({
name: 'count_items',
description: 'Count items by loading them. Call this when the user asks to count items.',
input: v.object({}),
run: async () => {
const doubled = await Promise.all([itemLoader.load(1), itemLoader.load(2), itemLoader.load(3)]);
return `Loaded ${doubled.length} items: ${doubled.join(', ')}.`;
},
});

useTool({
name: 'fail_now',
description: 'Always throws an error. Call this when the user asks to trigger a failure.',
input: v.object({}),
run: () => {
throw new Error('Intentional flue tool failure');
},
});

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.';
}
10 changes: 10 additions & 0 deletions dev-packages/e2e-tests/test-applications/node-flue/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import '../sentry-init.ts';
import { createAgentRouter } from '@flue/runtime/routing';
import { Hono } from 'hono';
import { Hello } from './agents/hello.ts';

const app = new Hono();

app.route('/agents/hello', createAgentRouter(Hello));

export default app;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import DataLoader from 'dataloader';

export const itemLoader = new DataLoader<number, number>(async keys => keys.map(key => key * 2));
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startEventProxyServer } from '@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'node-flue',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { expect, test } from '@playwright/test';
import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils';
import { runAgentTurn } from './utils';

const APP = 'node-flue';
const useOrchestrion = process.env.USE_ORCHESTRION === '1';

const isDataloaderSpan = (span: { attributes?: Record<string, { value?: unknown }> }): boolean =>
span.attributes?.['sentry.origin']?.value === 'auto.db.dataloader';

/**
* `dataloaderIntegration` relies on orchestrion, a module transform, so it only emits spans when the
* server starts with `NODE_OPTIONS=--import=@sentry/node/import` (the `node-flue (orchestrion)`
* variant). The integration installs and subscribes either way, so the absence of a span is the only
* thing that distinguishes the two — hence `test.fail(!useOrchestrion)`.
*
* Flue needs no build configuration for this: a Flue node build leaves dependencies as bare
* specifiers, so `dataloader` stays a real module the transform can hook. If Flue ever switches to
* a bundled server output, this test is what catches it.
*
* The loader is called from inside a tool so its span lands in the agent's trace, beside the AI
* spans, rather than in a trace of its own.
*/
test('captures orchestrion-instrumented dataloader spans in the same trace as the AI spans', async ({ baseURL }) => {
test.fail(!useOrchestrion, 'orchestrion module instrumentation needs NODE_OPTIONS=--import=@sentry/node/import');

// With orchestrion, wait for the dataloader span itself. Without it that span never arrives, so
// anchor on the always-present tool span and let the assertion below fail fast rather than time
// the test out.
const spansPromise = collectStreamedSpans(APP, spansOfTrace =>
useOrchestrion
? spansOfTrace.some(isDataloaderSpan)
: spansOfTrace.some(span => getSpanOp(span) === 'gen_ai.execute_tool'),
);

await runAgentTurn(baseURL!, 'dataloader-conversation', 'Please call count_items to count the items.');

const spans = await spansPromise;
const executeTool = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'count_items');
const dataloaderSpan = spans.find(isDataloaderSpan);

expect(dataloaderSpan?.attributes?.['sentry.origin']?.value).toBe('auto.db.dataloader');
// Same trace as the AI spans, and underneath the tool that triggered it.
expect(dataloaderSpan?.trace_id).toBe(executeTool?.trace_id);
expect(dataloaderSpan?.parent_span_id).toBe(executeTool?.span_id);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { expect, test } from '@playwright/test';
import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/test-utils';
import { runAgentTurn } from './utils';

const APP = 'node-flue';

test('captures an error thrown inside a Flue tool and marks its span errored', async ({ baseURL }) => {
const errorPromise = waitForError(
APP,
event => event.exception?.values?.[0]?.value === 'Intentional flue tool failure',
);
const spansPromise = collectStreamedSpans(APP, spansOfTrace =>
spansOfTrace.some(span => getSpanOp(span) === 'gen_ai.execute_tool'),
);

await runAgentTurn(baseURL!, 'failure-conversation', 'Please call fail_now to trigger a failure.');

const error = await errorPromise;
expect(error.exception?.values?.[0]?.value).toBe('Intentional flue tool failure');

const spans = await spansPromise;
const executeTool = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'fail_now');
expect(executeTool?.status).toBe('error');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { expect, test } from '@playwright/test';
import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils';
import { runAgentTurn } from './utils';

const APP = 'node-flue';

const hasOps = (ops: string[]) => (spansOfTrace: { attributes?: Record<string, { value?: unknown }> }[]) =>
ops.every(op => spansOfTrace.some(span => getSpanOp(span) === op));

test('captures the invoke_agent / chat / execute_tool hierarchy for a Flue turn', async ({ baseURL }) => {
// The trace flushes across several envelopes, so accumulate it rather than asserting on one.
const spansPromise = collectStreamedSpans(APP, hasOps(['gen_ai.invoke_agent', 'gen_ai.chat', 'gen_ai.execute_tool']));

await runAgentTurn(baseURL!, 'weather-conversation', 'What is the weather in Paris?');

const spans = await spansPromise;
const invokeAgent = spans.find(span => getSpanOp(span) === 'gen_ai.invoke_agent');
const chat = spans.find(span => getSpanOp(span) === 'gen_ai.chat');
const executeTool = spans.find(span => getSpanOp(span) === 'gen_ai.execute_tool');

expect(invokeAgent?.attributes?.['sentry.origin']?.value).toBe('auto.ai.flue');
expect(invokeAgent?.attributes?.['gen_ai.operation.name']?.value).toBe('invoke_agent');
expect(invokeAgent?.attributes?.['gen_ai.agent.name']?.value).toBe('Hello');

expect(chat?.attributes?.['sentry.origin']?.value).toBe('auto.ai.flue');
expect(chat?.attributes?.['gen_ai.provider.name']?.value).toBe('openrouter');
expect(typeof chat?.attributes?.['gen_ai.usage.input_tokens']?.value).toBe('number');
expect(typeof chat?.attributes?.['gen_ai.usage.output_tokens']?.value).toBe('number');
// Flue computes cost itself; no provider SDK reports it.
expect(typeof chat?.attributes?.['gen_ai.cost.total_tokens']?.value).toBe('number');

expect(executeTool?.attributes?.['gen_ai.tool.name']?.value).toBe('get_weather');

// Tool and chat spans are siblings under the agent invocation, matching how Flue's own
// OpenTelemetry adapter projects them.
expect(chat?.parent_span_id).toBe(invokeAgent?.span_id);
expect(executeTool?.parent_span_id).toBe(invokeAgent?.span_id);
});

test('nests a manual span raised inside a tool under that tool span', async ({ baseURL }) => {
const spansPromise = collectStreamedSpans(
APP,
spansOfTrace =>
spansOfTrace.some(span => getSpanOp(span) === 'gen_ai.execute_tool') &&
spansOfTrace.some(span => span.name === 'resolve-weather'),
);

await runAgentTurn(baseURL!, 'manual-span-conversation', 'What is the weather in Berlin?');

const spans = await spansPromise;
const executeTool = spans.find(span => getSpanOp(span) === 'gen_ai.execute_tool');
const manualSpan = spans.find(span => span.name === 'resolve-weather');

expect(manualSpan?.attributes?.['weather.source']?.value).toBe('static-table');
expect(manualSpan?.trace_id).toBe(executeTool?.trace_id);
expect(manualSpan?.parent_span_id).toBe(executeTool?.span_id);
});

// Flue's `model` operation is wrapped so the turn span is active for it, which is what puts the
// provider's HTTP call inside `chat` rather than beside it.
test('nests the provider HTTP call inside the chat span', async ({ baseURL }) => {
const spansPromise = collectStreamedSpans(
APP,
spansOfTrace =>
spansOfTrace.some(span => getSpanOp(span) === 'gen_ai.chat') &&
spansOfTrace.some(span => getSpanOp(span) === 'http.client'),
);

await runAgentTurn(baseURL!, 'provider-http-conversation', 'Say hello.');

const spans = await spansPromise;
const chat = spans.find(span => getSpanOp(span) === 'gen_ai.chat');
const providerCall = spans.find(span => getSpanOp(span) === 'http.client');

expect(providerCall?.trace_id).toBe(chat?.trace_id);
expect(providerCall?.parent_span_id).toBe(chat?.span_id);
});
35 changes: 35 additions & 0 deletions dev-packages/e2e-tests/test-applications/node-flue/tests/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { expect } from '@playwright/test';

/**
* Run one agent turn over Flue's agent router and wait for it to settle.
*
* `POST /:id` only admits the work — it returns `202` with a `streamUrl` and the turn runs after.
* Returning there would let one test's turn still be emitting spans while the next one waits for
* spans of its own, so a leftover trace could satisfy the wrong assertion. Reading the conversation
* back until it reports a settlement keeps each test to its own turn.
*
* The conversation id is ours to choose: it is the `:id` path segment. It is not the
* `gen_ai.conversation.id` attribute, which Flue generates.
*/
export async function runAgentTurn(baseURL: string, conversationId: string, message: string): Promise<void> {
const url = `${baseURL}/agents/hello/${conversationId}`;

const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: 'user', body: message }),
});
expect(res.status).toBe(202);
await res.text();

const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const conversation = (await (await fetch(url)).json()) as { settlements?: unknown[] };
if (conversation.settlements?.length) {
return;
}
await new Promise(resolve => setTimeout(resolve, 250));
}

throw new Error(`Flue turn for "${conversationId}" did not settle within 60s`);
}
Comment thread
RulaKhaled marked this conversation as resolved.
14 changes: 14 additions & 0 deletions dev-packages/e2e-tests/test-applications/node-flue/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "esnext",
"moduleResolution": "bundler",
"types": ["node"],
"strict": true,
"allowImportingTsExtensions": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src/**/*.ts", "sentry-init.ts", "flue.config.ts", "vite.config.ts"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { flue } from '@flue/vite';
import { defineConfig } from 'vite';

// Unmodified from what `flue init` scaffolds. In particular there is no externals config: a Flue
// node build leaves dependencies as bare specifiers, so orchestrion's module transform still sees
// them as real modules. (eve needs `externalDependencies` because it emits a bundled server.)
export default defineConfig({
plugins: [flue()],
});
Loading