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
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules
pnpm-lock.yaml
dist
.wrangler
test-results
playwright-report
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{
"name": "gen-ai-libraries",
"description": "Real gen_ai spans for every instrumented AI library (OpenAI, Anthropic, Mistral, Together, Vercel AI), each driven through OpenRouter with a chat query and a tool call, on Node and on Cloudflare",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev:node": "node --import tsx/esm --import ./src/instrument.node.ts src/entry.node.ts",
"dev:cloudflare": "wrangler dev --config ./dist/gen_ai_libraries/wrangler.json --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --var \"E2E_OPENROUTER_API_KEY:$E2E_OPENROUTER_API_KEY\" --port 38787",
"preview": "vite preview --port 38787",
"test": "playwright test",
"clean": "npx rimraf node_modules dist pnpm-lock.yaml",
"test:build": "pnpm install",
"test:build:cloudflare": "pnpm install && vite build",
"test:assert": "pnpm test",
"test:assert:cloudflare": "RUNTIME=cloudflare pnpm test"
},
"dependencies": {
"@anthropic-ai/sdk": "0.63.0",
"@mistralai/mistralai": "^2.6.4",
"@openrouter/ai-sdk-provider": "~3.0.0",
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
"@sentry/node": "file:../../packed/sentry-node-packed.tgz",
"ai": "~7.0.97",
"express": "^4.21.2",
"openai": "5.18.1",
"together-ai": "0.54.0",
"zod": "4.5.4"
},
"devDependencies": {
"@cloudflare/vite-plugin": "1.52.0",
"@cloudflare/workers-types": "^4.20260426.0",
"@playwright/test": "~1.63.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@types/express": "^4.17.21",
"@types/node": "^18.19.1",
"tsx": "4.21.0",
"typescript": "^5.5.2",
"vite": "8.3.0",
"wrangler": "^4.86.0"
},
"sentryTest": {
"optional": true,
"optionalVariants": [
{
"build-command": "pnpm test:build:cloudflare",
"assert-command": "pnpm test:assert:cloudflare",
"label": "gen-ai-libraries (cloudflare)"
}
]
},
"volta": {
"node": "24.15.0",
"extends": "../../package.json"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
import { RUNTIME } from './tests/constants';

// The same suite runs against both runtimes, selected by the `RUNTIME` env var (see the `sentryTest`
// variants in package.json): the Node entry (runtime channel injection) or the Cloudflare entry (Vite
// build + `@sentry/cloudflare/vite` plugin at build time, the prebuilt bundle served by `wrangler dev`).
const CF_PORT = 38787;
const NODE_PORT = 3030;

const config = getPlaywrightConfig(
{
startCommand: RUNTIME === 'cloudflare' ? 'pnpm dev:cloudflare' : 'pnpm dev:node',
port: RUNTIME === 'cloudflare' ? CF_PORT : NODE_PORT,
},
// Every test drives a real OpenRouter model call (a tool-calling turn does two) and then waits for
// the gen_ai spans to flush, which does not fit the default 30s test timeout when the provider is
// slow.
{ timeout: 90_000, retries: 0 },
);

export default config;
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// The Cloudflare variant: the same libraries and routes as the Node entry, but instrumented by the
// `@sentry/cloudflare/vite` bundler plugin (build-time channel injection) and run on workerd.
import * as Sentry from '@sentry/cloudflare';
import { libraries } from './libraries';

const byId = new Map(libraries.map(library => [library.id, library]));

export default Sentry.withSentry(
(env: Env) => ({
dsn: env.E2E_TEST_DSN,
environment: 'qa',
tunnel: 'http://localhost:3031/',
tracesSampleRate: 1.0,
}),
{
async fetch(request, env, _ctx) {
const url = new URL(request.url);
const [, id, action] = url.pathname.split('/');
const library = id ? byId.get(id) : undefined;

if (!library || (action !== 'chat' && action !== 'tools')) {
return new Response('Not found', { status: 404 });
}

const apiKey = env.E2E_OPENROUTER_API_KEY;
if (!apiKey) {
return new Response('E2E_OPENROUTER_API_KEY is not set', { status: 500 });
}

try {
const spanName = action === 'tools' ? 'ai-tool-workflow' : 'ai-workflow';
const result = await Sentry.startSpan({ name: spanName, op: 'function' }, () => library[action](apiKey));
return Response.json({ result });
} catch (error) {
return Response.json({ message: (error as Error).message }, { status: 500 });
}
},
} satisfies ExportedHandler<Env>,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// `instrument.node.ts` is preloaded via `node --import`, so Sentry is already initialised here.
import * as Sentry from '@sentry/node';
import express from 'express';
import { libraries } from './libraries';

const apiKey = process.env.E2E_OPENROUTER_API_KEY;
if (!apiKey) {
throw new Error('E2E_OPENROUTER_API_KEY is not set');
}

const app = express();

// One `/:lib/chat` and `/:lib/tools` per instrumented library. Each SDK call is wrapped in a manual
// `ai-workflow` span, so the gen_ai span nests inside it, and it inside the auto-instrumented request
// span.
for (const library of libraries) {
app.get(`/${library.id}/chat`, async (_req, res, next) => {
try {
const answer = await Sentry.startSpan({ name: 'ai-workflow', op: 'function' }, () => library.chat(apiKey));
res.send({ answer });
} catch (error) {
next(error);
}
});

app.get(`/${library.id}/tools`, async (_req, res, next) => {
try {
const toolCalls = await Sentry.startSpan({ name: 'ai-tool-workflow', op: 'function' }, () =>
library.tools(apiKey),
);
res.send({ toolCalls });
} catch (error) {
next(error);
}
});
}

Sentry.setupExpressErrorHandler(app);

app.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(500).send({ message: error.message });
});

const port = Number(process.env.PORT ?? 3030);
app.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`gen-ai-libraries (Node) listening on port ${port}`);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
interface Env {
E2E_TEST_DSN: '';
E2E_OPENROUTER_API_KEY: '';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as Sentry from '@sentry/node';

// Loaded through `node --import`, so the runtime channel-injection hook transforms the AI SDKs and
// express as they load. (The Cloudflare variant covers the build-time bundler-plugin injection path.)
Sentry.init({
environment: 'qa',
dsn: process.env.E2E_TEST_DSN,
debug: !!process.env.DEBUG,
tunnel: 'http://localhost:3031/',
tracesSampleRate: 1,
enableRuntimeChannelInjection: true,
});
Loading
Loading