Skip to content

Commit 86a1543

Browse files
committed
test(node): enable additionalDependencies in integration runner
1 parent ccc7d32 commit 86a1543

File tree

3 files changed

+260
-24
lines changed

3 files changed

+260
-24
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import * as Sentry from '@sentry/node';
2+
import { generateText } from 'ai';
3+
import { MockLanguageModelV2 } from 'ai/test';
4+
import { z } from 'zod';
5+
6+
async function run() {
7+
await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
8+
await generateText({
9+
model: new MockLanguageModelV2({
10+
doGenerate: async () => ({
11+
finishReason: 'stop',
12+
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
13+
content: [{ type: 'text', text: 'First span here!' }],
14+
}),
15+
}),
16+
prompt: 'Where is the first span?',
17+
});
18+
19+
// This span should have input and output prompts attached because telemetry is explicitly enabled.
20+
await generateText({
21+
experimental_telemetry: { isEnabled: true },
22+
model: new MockLanguageModelV2({
23+
doGenerate: async () => ({
24+
finishReason: 'stop',
25+
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
26+
content: [{ type: 'text', text: 'Second span here!' }],
27+
}),
28+
}),
29+
prompt: 'Where is the second span?',
30+
});
31+
32+
// This span should include tool calls and tool results
33+
await generateText({
34+
model: new MockLanguageModelV2({
35+
doGenerate: async () => ({
36+
finishReason: 'tool-calls',
37+
usage: { inputTokens: 15, outputTokens: 25, totalTokens: 40 },
38+
content: [{ type: 'text', text: 'Tool call completed!' }],
39+
toolCalls: [
40+
{
41+
toolCallType: 'function',
42+
toolCallId: 'call-1',
43+
toolName: 'getWeather',
44+
args: '{ "location": "San Francisco" }',
45+
},
46+
],
47+
}),
48+
}),
49+
tools: {
50+
getWeather: {
51+
parameters: z.object({ location: z.string() }),
52+
execute: async args => {
53+
return `Weather in ${args.location}: Sunny, 72°F`;
54+
},
55+
},
56+
},
57+
prompt: 'What is the weather in San Francisco?',
58+
});
59+
60+
// This span should not be captured because we've disabled telemetry
61+
await generateText({
62+
experimental_telemetry: { isEnabled: false },
63+
model: new MockLanguageModelV2({
64+
doGenerate: async () => ({
65+
finishReason: 'stop',
66+
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
67+
content: [{ type: 'text', text: 'Third span here!' }],
68+
}),
69+
}),
70+
prompt: 'Where is the third span?',
71+
});
72+
});
73+
}
74+
75+
run();

dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,73 @@ describe('Vercel AI integration', () => {
197197
]),
198198
};
199199

200+
// Todo: Add missing attribute spans for v5
201+
// Right now only second span is recorded as it's manually opted in via explicit telemetry option
202+
const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_V5 = {
203+
transaction: 'main',
204+
spans: expect.arrayContaining([
205+
expect.objectContaining({
206+
data: {
207+
'vercel.ai.model.id': 'mock-model-id',
208+
'vercel.ai.model.provider': 'mock-provider',
209+
'vercel.ai.operationId': 'ai.generateText',
210+
'vercel.ai.pipeline.name': 'generateText',
211+
'vercel.ai.prompt': '{"prompt":"Where is the second span?"}',
212+
'vercel.ai.response.finishReason': 'stop',
213+
'gen_ai.response.text': expect.any(String),
214+
'vercel.ai.settings.maxRetries': 2,
215+
// 'vercel.ai.settings.maxSteps': 1,
216+
'vercel.ai.streaming': false,
217+
'gen_ai.prompt': '{"prompt":"Where is the second span?"}',
218+
'gen_ai.response.model': 'mock-model-id',
219+
'gen_ai.usage.input_tokens': 10,
220+
'gen_ai.usage.output_tokens': 20,
221+
'gen_ai.usage.total_tokens': 30,
222+
'operation.name': 'ai.generateText',
223+
'sentry.op': 'gen_ai.invoke_agent',
224+
'sentry.origin': 'auto.vercelai.otel',
225+
},
226+
description: 'generateText',
227+
op: 'gen_ai.invoke_agent',
228+
origin: 'auto.vercelai.otel',
229+
status: 'ok',
230+
}),
231+
// doGenerate
232+
expect.objectContaining({
233+
data: {
234+
'sentry.origin': 'auto.vercelai.otel',
235+
'sentry.op': 'gen_ai.generate_text',
236+
'operation.name': 'ai.generateText.doGenerate',
237+
'vercel.ai.operationId': 'ai.generateText.doGenerate',
238+
'vercel.ai.model.provider': 'mock-provider',
239+
'vercel.ai.model.id': 'mock-model-id',
240+
'vercel.ai.settings.maxRetries': 2,
241+
'gen_ai.system': 'mock-provider',
242+
'gen_ai.request.model': 'mock-model-id',
243+
'vercel.ai.pipeline.name': 'generateText.doGenerate',
244+
'vercel.ai.streaming': false,
245+
'vercel.ai.response.finishReason': 'stop',
246+
'vercel.ai.response.model': 'mock-model-id',
247+
'vercel.ai.response.id': expect.any(String),
248+
'gen_ai.response.text': 'Second span here!',
249+
'vercel.ai.response.timestamp': expect.any(String),
250+
// 'vercel.ai.prompt.format': expect.any(String),
251+
'gen_ai.request.messages': expect.any(String),
252+
'gen_ai.response.finish_reasons': ['stop'],
253+
'gen_ai.usage.input_tokens': 10,
254+
'gen_ai.usage.output_tokens': 20,
255+
'gen_ai.response.id': expect.any(String),
256+
'gen_ai.response.model': 'mock-model-id',
257+
'gen_ai.usage.total_tokens': 30,
258+
},
259+
description: 'generate_text mock-model-id',
260+
op: 'gen_ai.generate_text',
261+
origin: 'auto.vercelai.otel',
262+
status: 'ok',
263+
}),
264+
]),
265+
};
266+
200267
const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE = {
201268
transaction: 'main',
202269
spans: expect.arrayContaining([
@@ -538,6 +605,23 @@ describe('Vercel AI integration', () => {
538605
});
539606
});
540607

608+
// Test with specific Vercel AI v5 version
609+
createEsmAndCjsTests(
610+
__dirname,
611+
'scenario-v5.mjs',
612+
'instrument.mjs',
613+
(createRunner, test) => {
614+
test('creates ai related spans with v5', async () => {
615+
await createRunner().expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_V5 }).start().completed();
616+
});
617+
},
618+
{
619+
additionalDependencies: {
620+
ai: '^5.0.0',
621+
},
622+
},
623+
);
624+
541625
createEsmAndCjsTests(__dirname, 'scenario-error-in-tool-express.mjs', 'instrument.mjs', (createRunner, test) => {
542626
test('captures error in tool in express server', async () => {
543627
const expectedTransaction = {

dev-packages/node-integration-tests/utils/runner.ts

Lines changed: 101 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ import type {
1414
import { normalize } from '@sentry/core';
1515
import { createBasicSentryServer } from '@sentry-internal/test-utils';
1616
import { execSync, spawn, spawnSync } from 'child_process';
17-
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
18-
import { join } from 'path';
17+
import { existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'fs';
18+
import { basename, join } from 'path';
1919
import { inspect } from 'util';
2020
import { afterAll, beforeAll, describe, test } from 'vitest';
2121
import {
@@ -174,7 +174,7 @@ export function createEsmAndCjsTests(
174174
testFn: typeof test | typeof test.fails,
175175
mode: 'esm' | 'cjs',
176176
) => void,
177-
options?: { failsOnCjs?: boolean; failsOnEsm?: boolean },
177+
options?: { failsOnCjs?: boolean; failsOnEsm?: boolean; additionalDependencies?: Record<string, string> },
178178
): void {
179179
const mjsScenarioPath = join(cwd, scenarioPath);
180180
const mjsInstrumentPath = join(cwd, instrumentPath);
@@ -187,33 +187,110 @@ export function createEsmAndCjsTests(
187187
throw new Error(`Instrument file not found: ${mjsInstrumentPath}`);
188188
}
189189

190-
const cjsScenarioPath = join(cwd, `tmp_${scenarioPath.replace('.mjs', '.cjs')}`);
191-
const cjsInstrumentPath = join(cwd, `tmp_${instrumentPath.replace('.mjs', '.cjs')}`);
190+
// If additionalDependencies are provided, we create a dedicated tmp directory that includes
191+
// copied ESM & CJS scenario/instrument files and a nested package.json with those dependencies installed.
192+
const useTmpDir = Boolean(options?.additionalDependencies && Object.keys(options.additionalDependencies).length > 0);
193+
194+
let tmpDirPath: string | undefined;
195+
let esmScenarioPathForRun = mjsScenarioPath;
196+
let esmInstrumentPathForRun = mjsInstrumentPath;
197+
let cjsScenarioPath = join(cwd, `tmp_${scenarioPath.replace('.mjs', '.cjs')}`);
198+
let cjsInstrumentPath = join(cwd, `tmp_${instrumentPath.replace('.mjs', '.cjs')}`);
199+
200+
if (useTmpDir) {
201+
// Create unique tmp directory within the suite folder
202+
const uniqueId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
203+
tmpDirPath = join(cwd, `tmp_${uniqueId}`);
204+
mkdirSync(tmpDirPath);
205+
206+
// Copy ESM files as-is into tmp dir
207+
const esmScenarioBasename = basename(scenarioPath);
208+
const esmInstrumentBasename = basename(instrumentPath);
209+
esmScenarioPathForRun = join(tmpDirPath, esmScenarioBasename);
210+
esmInstrumentPathForRun = join(tmpDirPath, esmInstrumentBasename);
211+
writeFileSync(esmScenarioPathForRun, readFileSync(mjsScenarioPath, 'utf8'));
212+
writeFileSync(esmInstrumentPathForRun, readFileSync(mjsInstrumentPath, 'utf8'));
213+
214+
// Pre-create CJS converted files inside tmp dir
215+
cjsScenarioPath = join(tmpDirPath, esmScenarioBasename.replace('.mjs', '.cjs'));
216+
cjsInstrumentPath = join(tmpDirPath, esmInstrumentBasename.replace('.mjs', '.cjs'));
217+
convertEsmFileToCjs(esmScenarioPathForRun, cjsScenarioPath);
218+
convertEsmFileToCjs(esmInstrumentPathForRun, cjsInstrumentPath);
219+
220+
// Create a minimal package.json with requested dependencies
221+
const additionalDependencies = options?.additionalDependencies ?? {};
222+
const packageJson = {
223+
name: 'tmp-integration-test',
224+
private: true,
225+
version: '0.0.0',
226+
dependencies: additionalDependencies,
227+
} as const;
228+
229+
writeFileSync(join(tmpDirPath, 'package.json'), JSON.stringify(packageJson, null, 2));
230+
231+
// Install the requested dependencies using yarn
232+
try {
233+
const deps = Object.entries(additionalDependencies).map(([name, range]) => {
234+
if (!range || typeof range !== 'string') {
235+
throw new Error(`Invalid version range for "${name}": ${String(range)}`);
236+
}
237+
return `${name}@${range}`;
238+
});
239+
240+
if (deps.length > 0) {
241+
// Run yarn add non-interactively, keep output visible when DEBUG is set
242+
spawnSync('yarn', ['add', '--non-interactive', ...deps], {
243+
cwd: tmpDirPath,
244+
stdio: process.env.DEBUG ? 'inherit' : 'ignore',
245+
});
246+
}
247+
} catch (e) {
248+
// eslint-disable-next-line no-console
249+
console.error('Failed to install additionalDependencies:', e);
250+
throw e;
251+
}
252+
}
192253

193254
describe('esm', () => {
194255
const testFn = options?.failsOnEsm ? test.fails : test;
195-
callback(() => createRunner(mjsScenarioPath).withFlags('--import', mjsInstrumentPath), testFn, 'esm');
256+
callback(() => createRunner(esmScenarioPathForRun).withFlags('--import', esmInstrumentPathForRun), testFn, 'esm');
196257
});
197258

198259
describe('cjs', () => {
199-
beforeAll(() => {
200-
// For the CJS runner, we create some temporary files...
201-
convertEsmFileToCjs(mjsScenarioPath, cjsScenarioPath);
202-
convertEsmFileToCjs(mjsInstrumentPath, cjsInstrumentPath);
203-
});
204-
205-
afterAll(() => {
206-
try {
207-
unlinkSync(cjsInstrumentPath);
208-
} catch {
209-
// Ignore errors here
210-
}
211-
try {
212-
unlinkSync(cjsScenarioPath);
213-
} catch {
214-
// Ignore errors here
215-
}
216-
});
260+
if (!useTmpDir) {
261+
beforeAll(() => {
262+
// For the CJS runner, we create some temporary files...
263+
convertEsmFileToCjs(mjsScenarioPath, cjsScenarioPath);
264+
convertEsmFileToCjs(mjsInstrumentPath, cjsInstrumentPath);
265+
});
266+
267+
afterAll(() => {
268+
try {
269+
unlinkSync(cjsInstrumentPath);
270+
} catch {
271+
// Ignore errors here
272+
}
273+
try {
274+
unlinkSync(cjsScenarioPath);
275+
} catch {
276+
// Ignore errors here
277+
}
278+
});
279+
} else {
280+
// When using a tmp dir, clean up the entire directory once CJS tests are finished
281+
afterAll(() => {
282+
if (tmpDirPath) {
283+
try {
284+
rmSync(tmpDirPath, { recursive: true, force: true });
285+
} catch {
286+
if (process.env.DEBUG) {
287+
// eslint-disable-next-line no-console
288+
console.error(`Failed to remove tmp dir: ${tmpDirPath}`);
289+
}
290+
}
291+
}
292+
});
293+
}
217294

218295
const testFn = options?.failsOnCjs ? test.fails : test;
219296
callback(() => createRunner(cjsScenarioPath).withFlags('--require', cjsInstrumentPath), testFn, 'cjs');

0 commit comments

Comments
 (0)