Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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,48 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# local env files
.env*.local

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts

!*.d.ts

# Sentry
.sentryclirc

.vscode

test-results
event-dumps

.tmp_dev_server_logs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
@sentry:registry=http://127.0.0.1:4873
@sentry-internal:registry=http://127.0.0.1:4873
public-hoist-pattern[]=*import-in-the-middle*
public-hoist-pattern[]=*require-in-the-middle*
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { generateText, rerank } from 'ai';
import { MockLanguageModelV3, MockRerankingModelV3 } from 'ai/test';
import * as Sentry from '@sentry/nextjs';

export const dynamic = 'force-dynamic';

async function runAITest() {
// Test generateText - uses V3 mock format for AI SDK v6
const textResult = await generateText({
experimental_telemetry: { isEnabled: true },
model: new MockLanguageModelV3({
doGenerate: async () => ({
content: [{ type: 'text', text: 'Generated text from v6!' }],
finishReason: 'stop',
usage: { promptTokens: 10, completionTokens: 20 },
warnings: [],
}),
}),
prompt: 'Test prompt for AI SDK v6',
});

// Test rerank - uses V3 mock format for AI SDK v6
const rerankResult = await rerank({
experimental_telemetry: { isEnabled: true },
model: new MockRerankingModelV3({
doRerank: async () => ({
results: [
{ documentIndex: 1, relevanceScore: 0.95 },
{ documentIndex: 0, relevanceScore: 0.8 },
{ documentIndex: 2, relevanceScore: 0.6 },
],
usage: { tokens: 50 },
warnings: [],
}),
}),
query: 'search query for reranking',
documents: ['Document A', 'Document B', 'Document C'],
});

return {
textResult: textResult.text,
rerankResult: rerankResult.results.map(r => ({ score: r.relevanceScore, index: r.documentIndex })),
};
}

export default async function Page() {
const results = await Sentry.startSpan({ op: 'function', name: 'ai-test' }, async () => {
return await runAITest();
});

return (
<div>
<h1>AI SDK v6 Test Results</h1>
<pre id="ai-results">{JSON.stringify(results, null, 2)}</pre>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <p>Next 15 AI SDK v6 test app</p>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
interface Window {
recordedTransactions?: string[];
capturedExceptionId?: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as Sentry from '@sentry/nextjs';

Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
tunnel: `http://localhost:3031/`, // proxy server
tracesSampleRate: 1.0,
sendDefaultPii: true,
});

export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as Sentry from '@sentry/nextjs';

export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./sentry.server.config');
}

if (process.env.NEXT_RUNTIME === 'edge') {
await import('./sentry.edge.config');
}
}

export const onRequestError = Sentry.captureRequestError;
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const { withSentryConfig } = require('@sentry/nextjs');

/** @type {import('next').NextConfig} */
const nextConfig = {};

module.exports = withSentryConfig(nextConfig, {
silent: true,
release: {
name: 'foobar123',
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "nextjs-15-ai-v6",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)",
"clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs",
"test:prod": "TEST_ENV=production playwright test",
"test:dev": "TEST_ENV=development playwright test",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm test:prod && pnpm test:dev"
},
"dependencies": {
"@sentry/nextjs": "latest || *",
"@types/node": "^18.19.1",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.9",
"ai": "^6.0.0",
"next": "15.5.10",
"react": "latest",
"react-dom": "latest",
"typescript": "~5.0.0"
},
"devDependencies": {
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils"
},
"volta": {
"extends": "../../package.json"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
const testEnv = process.env.TEST_ENV;

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

const getStartCommand = () => {
if (testEnv === 'development') {
return 'pnpm next dev -p 3030 2>&1 | tee .tmp_dev_server_logs';
}

if (testEnv === 'production') {
return 'pnpm next start -p 3030';
}

throw new Error(`Unknown test env: ${testEnv}`);
};

const config = getPlaywrightConfig({
startCommand: getStartCommand(),
port: 3030,
});

export default config;
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as Sentry from '@sentry/nextjs';

Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
tunnel: `http://localhost:3031/`, // proxy server
tracesSampleRate: 1.0,
sendDefaultPii: true,
transportOptions: {
// We are doing a lot of events at once in this test
bufferSize: 1000,
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as Sentry from '@sentry/nextjs';

Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
tunnel: `http://localhost:3031/`, // proxy server
tracesSampleRate: 1.0,
sendDefaultPii: true,
transportOptions: {
// We are doing a lot of events at once in this test
bufferSize: 1000,
},
integrations: [Sentry.vercelAIIntegration()],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as fs from 'fs';
import * as path from 'path';
import { startEventProxyServer } from '@sentry-internal/test-utils';

const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json')));

startEventProxyServer({
port: 3031,
proxyServerName: 'nextjs-15-ai-v6',
envelopeDumpPath: path.join(
process.cwd(),
`event-dumps/next-15-ai-v6-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`,
),
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

test('should create AI spans including rerank with correct attributes', async ({ page }) => {
const aiTransactionPromise = waitForTransaction('nextjs-15-ai-v6', async transactionEvent => {
return transactionEvent.transaction === 'GET /ai-test';
});

await page.goto('/ai-test');

const aiTransaction = await aiTransactionPromise;

expect(aiTransaction).toBeDefined();
expect(aiTransaction.transaction).toBe('GET /ai-test');

const spans = aiTransaction.spans || [];

// Check for generateText spans
const aiPipelineSpans = spans.filter(span => span.op === 'gen_ai.invoke_agent');
const aiGenerateSpans = spans.filter(span => span.op === 'gen_ai.generate_text');

// Check for rerank spans - the main feature being tested
const rerankSpans = spans.filter(span => span.op === 'gen_ai.rerank');

expect(aiPipelineSpans.length).toBeGreaterThanOrEqual(1);
expect(aiGenerateSpans.length).toBeGreaterThanOrEqual(1);
expect(rerankSpans.length).toBeGreaterThanOrEqual(1);

// Verify generateText span has expected attributes
const generatePipelineSpan = aiPipelineSpans.find(span =>
span.data?.['vercel.ai.prompt']?.includes('Test prompt for AI SDK v6'),
);
expect(generatePipelineSpan).toBeDefined();
expect(generatePipelineSpan?.data?.['gen_ai.response.text']).toContain('Generated text from v6!');

// Verify rerank span has expected attributes
const rerankPipelineSpan = aiPipelineSpans.find(span =>
span.data?.['gen_ai.request.rerank.query']?.includes('search query for reranking'),
);
expect(rerankPipelineSpan).toBeDefined();
expect(rerankPipelineSpan?.data?.['gen_ai.request.rerank.documents_count']).toBe(3);
expect(rerankPipelineSpan?.data?.['gen_ai.response.rerank.top_score']).toBe(0.95);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

E2E test expects unmapped rerank attributes

Medium Severity

The E2E test expects rerank-specific attributes under the gen_ai.* namespace (gen_ai.request.rerank.query, gen_ai.request.rerank.documents_count, gen_ai.response.rerank.top_score), but the production code doesn't include any attribute mappings for rerank-specific data. Based on the existing codebase pattern, unmapped ai.* attributes get prefixed with vercel. and become vercel.ai.*. This mismatch means either the test expectations are incorrect (should use vercel.ai.*) or the production code is missing attribute mappings for rerank. Per the review rules, this flags that the test may not properly test the newly added behavior.

Fix in Cursor Fix in Web


// Verify results are displayed on the page
const resultsText = await page.locator('#ai-results').textContent();
expect(resultsText).toContain('Generated text from v6!');
expect(resultsText).toContain('Document B');
expect(resultsText).toContain('0.95');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es2018",
"allowImportingTsExtensions": true,
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
],
"incremental": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next.config.js", ".next/types/**/*.ts"],
"exclude": ["node_modules", "playwright.config.ts"]
}
5 changes: 5 additions & 0 deletions packages/core/src/tracing/ai/gen-ai-attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,11 @@ export const GEN_AI_EMBED_DO_EMBED_OPERATION_ATTRIBUTE = 'gen_ai.embed';
*/
export const GEN_AI_EMBED_MANY_DO_EMBED_OPERATION_ATTRIBUTE = 'gen_ai.embed_many';

/**
* The span operation name for reranking
*/
export const GEN_AI_RERANK_DO_RERANK_OPERATION_ATTRIBUTE = 'gen_ai.rerank';

/**
* The span operation name for executing a tool
*/
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/tracing/vercel-ai/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const INVOKE_AGENT_OPS = new Set([
'ai.streamObject',
'ai.embed',
'ai.embedMany',
'ai.rerank',
]);

export const GENERATE_CONTENT_OPS = new Set([
Expand All @@ -22,3 +23,5 @@ export const GENERATE_CONTENT_OPS = new Set([
]);

export const EMBEDDINGS_OPS = new Set(['ai.embed.doEmbed', 'ai.embedMany.doEmbed']);

export const RERANK_OPS = new Set(['ai.rerank.doRerank']);
Loading
Loading