Skip to content

Commit f8dc497

Browse files
chargomeclaude
andauthored
test(nextjs): Add AI provider orchestrion instrumentations to e2e app (#22550)
Adds e2e coverage for `openai`, `anthropic-ai` and `google-genai` orchestrion instrumentations in the `nextjs-16-orchestrion` app. A shared `node:http` mock server stands in for the three provider APIs so the real SDK clients emit gen_ai spans without live credentials. These libraries are bundled (internalized) and instrumented by the orchestrion build-time loader. Closes #22506 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 34eb8bc commit f8dc497

8 files changed

Lines changed: 229 additions & 0 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { createServer } from 'node:http';
2+
3+
// A single mock server standing in for the OpenAI, Anthropic and Google GenAI HTTP APIs, so the real
4+
// SDK clients emit gen_ai spans without any live credentials. Response bodies mirror the mock servers
5+
// in the node-integration tests (suites/tracing/{openai,anthropic,google-genai}). Uses raw `node:http`
6+
// (not express) so the mock doesn't itself get instrumented.
7+
8+
function readJson(req) {
9+
return new Promise(resolve => {
10+
let body = '';
11+
req.on('data', chunk => (body += chunk));
12+
req.on('end', () => {
13+
try {
14+
resolve(JSON.parse(body || '{}'));
15+
} catch {
16+
resolve({});
17+
}
18+
});
19+
});
20+
}
21+
22+
function sendJson(res, status, obj) {
23+
res.writeHead(status, { 'Content-Type': 'application/json' });
24+
res.end(JSON.stringify(obj));
25+
}
26+
27+
let serverPromise;
28+
29+
/** Lazily starts the shared mock server and resolves to its port. */
30+
export function getMockAiPort() {
31+
serverPromise ??= new Promise(resolve => {
32+
const server = createServer(async (req, res) => {
33+
const url = req.url || '';
34+
35+
// OpenAI: chat completions
36+
if (req.method === 'POST' && url.endsWith('/openai/chat/completions')) {
37+
const { model } = await readJson(req);
38+
sendJson(res, 200, {
39+
id: 'chatcmpl-mock123',
40+
object: 'chat.completion',
41+
created: 1677652288,
42+
model,
43+
choices: [
44+
{ index: 0, message: { role: 'assistant', content: 'Hello from OpenAI mock!' }, finish_reason: 'stop' },
45+
],
46+
usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 },
47+
});
48+
return;
49+
}
50+
51+
// Anthropic: messages
52+
if (req.method === 'POST' && url.endsWith('/anthropic/v1/messages')) {
53+
const { model } = await readJson(req);
54+
sendJson(res, 200, {
55+
id: 'msg_mock123',
56+
type: 'message',
57+
model,
58+
role: 'assistant',
59+
content: [{ type: 'text', text: 'Hello from Anthropic mock!' }],
60+
stop_reason: 'end_turn',
61+
stop_sequence: null,
62+
usage: { input_tokens: 10, output_tokens: 15 },
63+
});
64+
return;
65+
}
66+
67+
// Google GenAI: generateContent (the model name is embedded in the path before `:generateContent`).
68+
// Plain string checks avoid the polynomial-backtracking risk of a `.+` regex on the URL.
69+
if (req.method === 'POST' && url.startsWith('/v1beta/models/') && url.endsWith(':generateContent')) {
70+
await readJson(req);
71+
sendJson(res, 200, {
72+
candidates: [
73+
{
74+
content: { parts: [{ text: 'Mock response from Google GenAI!' }], role: 'model' },
75+
finishReason: 'stop',
76+
index: 0,
77+
},
78+
],
79+
usageMetadata: { promptTokenCount: 8, candidatesTokenCount: 12, totalTokenCount: 20 },
80+
});
81+
return;
82+
}
83+
84+
res.writeHead(404).end();
85+
});
86+
87+
server.listen(0, () => {
88+
resolve(server.address().port);
89+
});
90+
});
91+
92+
return serverPromise;
93+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import Anthropic from '@anthropic-ai/sdk';
2+
import { NextResponse } from 'next/server';
3+
import { getMockAiPort } from '../../../ai-mock-server.mjs';
4+
5+
export const dynamic = 'force-dynamic';
6+
7+
export async function GET() {
8+
const port = await getMockAiPort();
9+
const client = new Anthropic({
10+
apiKey: 'mock-api-key',
11+
baseURL: `http://localhost:${port}/anthropic`,
12+
});
13+
14+
await client.messages.create({
15+
model: 'claude-3-haiku-20240307',
16+
max_tokens: 100,
17+
messages: [{ role: 'user', content: 'What is the capital of France?' }],
18+
});
19+
20+
return NextResponse.json({ status: 'ok' });
21+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { GoogleGenAI } from '@google/genai';
2+
import { NextResponse } from 'next/server';
3+
import { getMockAiPort } from '../../../ai-mock-server.mjs';
4+
5+
export const dynamic = 'force-dynamic';
6+
7+
export async function GET() {
8+
const port = await getMockAiPort();
9+
const client = new GoogleGenAI({
10+
apiKey: 'mock-api-key',
11+
httpOptions: { baseUrl: `http://localhost:${port}` },
12+
});
13+
14+
await client.models.generateContent({
15+
model: 'gemini-1.5-flash',
16+
config: { temperature: 0.7, topP: 0.9, maxOutputTokens: 100 },
17+
contents: [{ role: 'user', parts: [{ text: 'What is the capital of France?' }] }],
18+
});
19+
20+
return NextResponse.json({ status: 'ok' });
21+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { NextResponse } from 'next/server';
2+
import OpenAI from 'openai';
3+
import { getMockAiPort } from '../../../ai-mock-server.mjs';
4+
5+
export const dynamic = 'force-dynamic';
6+
7+
export async function GET() {
8+
const port = await getMockAiPort();
9+
const client = new OpenAI({
10+
baseURL: `http://localhost:${port}/openai`,
11+
apiKey: 'mock-api-key',
12+
});
13+
14+
await client.chat.completions.create({
15+
model: 'gpt-3.5-turbo',
16+
messages: [{ role: 'user', content: 'What is the capital of France?' }],
17+
});
18+
19+
return NextResponse.json({ status: 'ok' });
20+
}

dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
},
1717
"//": "Pin `ioredis` to 5.10.1 and `mysql2` to 3.19.1: both are the last versions before the driver publishes its own native diagnostics channels; orchestrion's configs cover `ioredis <5.11.0` and `mysql2 <3.20.0`.",
1818
"dependencies": {
19+
"@anthropic-ai/sdk": "0.63.0",
20+
"@google/genai": "^1.20.0",
1921
"@sentry/core": "file:../../packed/sentry-core-packed.tgz",
2022
"@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz",
2123
"dataloader": "2.2.2",
@@ -28,6 +30,7 @@
2830
"mysql": "^2.18.1",
2931
"mysql2": "3.19.1",
3032
"next": "16.2.10",
33+
"openai": "5.18.1",
3134
"pg": "^8.13.1",
3235
"postgres": "^3.4.7",
3336
"react": "19.1.0",
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForStreamedSpans } from '@sentry-internal/test-utils';
3+
4+
// gen_ai spans are emitted as a separate span-v2 envelope item (not inline on the transaction), so we
5+
// assert on the streamed spans. Attribute values are wrapped as `{ value, type }` in the v2 format.
6+
test('Instruments anthropic-ai automatically via orchestrion', async ({ baseURL }) => {
7+
const spansPromise = waitForStreamedSpans('nextjs-16-orchestrion', spans =>
8+
spans.some(span => span.attributes['sentry.origin']?.value === 'auto.ai.orchestrion.anthropic'),
9+
);
10+
11+
await fetch(`${baseURL}/api/anthropic`);
12+
13+
const spans = await spansPromise;
14+
15+
const chatSpan = spans.find(span => span.name === 'chat claude-3-haiku-20240307');
16+
expect(chatSpan).toBeDefined();
17+
expect(chatSpan?.attributes['sentry.op']?.value).toBe('gen_ai.chat');
18+
expect(chatSpan?.attributes['sentry.origin']?.value).toBe('auto.ai.orchestrion.anthropic');
19+
expect(chatSpan?.attributes['gen_ai.system']?.value).toBe('anthropic');
20+
expect(chatSpan?.attributes['gen_ai.request.model']?.value).toBe('claude-3-haiku-20240307');
21+
expect(chatSpan?.attributes['gen_ai.usage.input_tokens']?.value).toBe(10);
22+
expect(chatSpan?.attributes['gen_ai.usage.output_tokens']?.value).toBe(15);
23+
});
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 { waitForStreamedSpans } from '@sentry-internal/test-utils';
3+
4+
// gen_ai spans are emitted as a separate span-v2 envelope item (not inline on the transaction), so we
5+
// assert on the streamed spans. Attribute values are wrapped as `{ value, type }` in the v2 format.
6+
test('Instruments google-genai automatically via orchestrion', async ({ baseURL }) => {
7+
const spansPromise = waitForStreamedSpans('nextjs-16-orchestrion', spans =>
8+
spans.some(span => span.attributes['sentry.origin']?.value === 'auto.ai.orchestrion.google_genai'),
9+
);
10+
11+
await fetch(`${baseURL}/api/google-genai`);
12+
13+
const spans = await spansPromise;
14+
15+
const generateSpan = spans.find(span => span.name === 'generate_content gemini-1.5-flash');
16+
expect(generateSpan).toBeDefined();
17+
expect(generateSpan?.attributes['sentry.op']?.value).toBe('gen_ai.generate_content');
18+
expect(generateSpan?.attributes['sentry.origin']?.value).toBe('auto.ai.orchestrion.google_genai');
19+
expect(generateSpan?.attributes['gen_ai.system']?.value).toBe('google_genai');
20+
expect(generateSpan?.attributes['gen_ai.request.model']?.value).toBe('gemini-1.5-flash');
21+
expect(generateSpan?.attributes['gen_ai.usage.input_tokens']?.value).toBe(8);
22+
expect(generateSpan?.attributes['gen_ai.usage.output_tokens']?.value).toBe(12);
23+
expect(generateSpan?.attributes['gen_ai.usage.total_tokens']?.value).toBe(20);
24+
});
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 { waitForStreamedSpans } from '@sentry-internal/test-utils';
3+
4+
// gen_ai spans are emitted as a separate span-v2 envelope item (not inline on the transaction), so we
5+
// assert on the streamed spans. Attribute values are wrapped as `{ value, type }` in the v2 format.
6+
test('Instruments openai automatically via orchestrion', async ({ baseURL }) => {
7+
const spansPromise = waitForStreamedSpans('nextjs-16-orchestrion', spans =>
8+
spans.some(span => span.attributes['sentry.origin']?.value === 'auto.ai.orchestrion.openai'),
9+
);
10+
11+
await fetch(`${baseURL}/api/openai`);
12+
13+
const spans = await spansPromise;
14+
15+
const chatSpan = spans.find(span => span.name === 'chat gpt-3.5-turbo');
16+
expect(chatSpan).toBeDefined();
17+
expect(chatSpan?.attributes['sentry.op']?.value).toBe('gen_ai.chat');
18+
expect(chatSpan?.attributes['sentry.origin']?.value).toBe('auto.ai.orchestrion.openai');
19+
expect(chatSpan?.attributes['gen_ai.system']?.value).toBe('openai');
20+
expect(chatSpan?.attributes['gen_ai.request.model']?.value).toBe('gpt-3.5-turbo');
21+
expect(chatSpan?.attributes['gen_ai.usage.input_tokens']?.value).toBe(10);
22+
expect(chatSpan?.attributes['gen_ai.usage.output_tokens']?.value).toBe(15);
23+
expect(chatSpan?.attributes['gen_ai.usage.total_tokens']?.value).toBe(25);
24+
});

0 commit comments

Comments
 (0)