diff --git a/bin/flame.js b/bin/flame.js index 80e06b0..a74d754 100755 --- a/bin/flame.js +++ b/bin/flame.js @@ -38,6 +38,10 @@ const { values: args, positionals } = parseArgs({ 'sourcemap-dirs': { type: 'string', short: 's' + }, + 'node-modules-source-maps': { + type: 'string', + short: 'n' } }, allowPositionals: true @@ -63,6 +67,7 @@ Options: -m, --manual Manual profiling mode (require SIGUSR2 to start) -d, --delay Delay before starting profiler (ms, 'none', or 'until-started', default: 'until-started') -s, --sourcemap-dirs Directories to search for sourcemaps (colon/semicolon-separated) + -n, --node-modules-source-maps Node modules to load sourcemaps from (comma-separated, e.g., "next,@next/next-server") --node-options Node.js CLI options to pass to the profiled process -h, --help Show this help message -v, --version Show version number @@ -74,6 +79,7 @@ Examples: flame run --delay=none server.js # Start profiling immediately flame run --delay=until-started server.js # Start profiling after next event loop tick (default) flame run --sourcemap-dirs=dist:build server.js # Enable sourcemap support + flame run -n next,@next/next-server server.js # Load Next.js sourcemaps from node_modules flame run --node-options="--require ts-node/register" server.ts # With Node.js options flame run --node-options="--import ./loader.js --max-old-space-size=4096" server.js flame generate profile.pb.gz @@ -125,11 +131,17 @@ async function main () { ? args['sourcemap-dirs'].split(/[:;]/).filter(d => d.trim()) : undefined + // Parse node modules sourcemap options + const nodeModulesSourceMaps = args['node-modules-source-maps'] + ? args['node-modules-source-maps'].split(',').map(s => s.trim()) + : undefined + const { pid, process: childProcess } = startProfiling(script, scriptArgs, { autoStart, nodeOptions, delay, - sourcemapDirs + sourcemapDirs, + nodeModulesSourceMaps }) console.log(`🔥 Started profiling process ${pid}`) diff --git a/lib/index.js b/lib/index.js index 7c14fdd..3b8f397 100644 --- a/lib/index.js +++ b/lib/index.js @@ -14,6 +14,7 @@ const { spawn } = require('child_process') * @param {string[]} options.nodeOptions - Node.js CLI options to pass (default: []) * @param {string} options.delay - Delay before starting profiler ('none', 'until-started', or numeric string for ms, default: 'until-started') * @param {string|string[]} options.sourcemapDirs - Directory or directories to search for sourcemaps (optional) + * @param {string|string[]} options.nodeModulesSourceMaps - Node modules to load sourcemaps from (optional) * @returns {Promise} Process information */ function startProfiling (script, args = [], options = {}) { @@ -34,6 +35,14 @@ function startProfiling (script, args = [], options = {}) { env.FLAME_SOURCEMAP_DIRS = dirs.join(path.delimiter) } + // Add node_modules sourcemap configuration if provided + if (options.nodeModulesSourceMaps) { + const mods = Array.isArray(options.nodeModulesSourceMaps) + ? options.nodeModulesSourceMaps + : [options.nodeModulesSourceMaps] + env.FLAME_NODE_MODULES_SOURCE_MAPS = mods.join(',') + } + // Construct the node command with options const nodeArgs = [ ...(options.nodeOptions || []), diff --git a/package-lock.json b/package-lock.json index a8dc47a..dbacf22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1648,7 +1648,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3290,7 +3289,6 @@ "integrity": "sha512-vPZZsiOKaBAIATpFE2uMI4w5IRwdv/FpQ+qZZMR4E+PeOcM4OeoEbqxRMnywdxP19TyB/3h6QBB0EWon7letSQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/types": "^8.35.0", "comment-parser": "^1.4.1", @@ -6543,7 +6541,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7901,6 +7898,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/preload.js b/preload.js index ee4dc77..9c5f3fa 100644 --- a/preload.js +++ b/preload.js @@ -14,29 +14,174 @@ const autoStart = process.env.FLAME_AUTO_START === 'true' // Initialize sourcemap support if enabled const sourcemapDirs = process.env.FLAME_SOURCEMAP_DIRS +const nodeModulesSourceMaps = process.env.FLAME_NODE_MODULES_SOURCE_MAPS let sourceMapperPromise = null +let nodeModulesMapperPromise = null -if (sourcemapDirs) { - const dirs = sourcemapDirs.split(path.delimiter).filter(d => d.trim()) +// Helper: resolve module path from node_modules +function resolveModulePath (appPath, moduleName) { + try { + const resolved = require.resolve(moduleName, { paths: [appPath] }) + const nodeModulesIndex = resolved.lastIndexOf('node_modules') + if (nodeModulesIndex === -1) return null - if (dirs.length > 0) { - console.log(`🗺️ Initializing sourcemap support for directories: ${dirs.join(', ')}`) + const afterNodeModules = resolved.substring(nodeModulesIndex + 'node_modules'.length + 1) + const parts = afterNodeModules.split(path.sep) + + if (moduleName.startsWith('@')) { + return path.join(resolved.substring(0, nodeModulesIndex), 'node_modules', parts[0], parts[1]) + } else { + return path.join(resolved.substring(0, nodeModulesIndex), 'node_modules', parts[0]) + } + } catch { + return null + } +} + +// Helper: walk directory for .map files +async function * walkForMapFiles (dir) { + const fsPromises = require('fs').promises + async function * walkRecursive (currentDir) { + try { + const dirHandle = await fsPromises.opendir(currentDir) + for await (const entry of dirHandle) { + const entryPath = path.join(currentDir, entry.name) + if (entry.isDirectory() && entry.name !== '.git') { + yield * walkRecursive(entryPath) + } else if (entry.isFile() && /\.[cm]?js\.map$/.test(entry.name)) { + yield entryPath + } + } + } catch { + // Silently ignore permission errors + } + } + yield * walkRecursive(dir) +} + +// Helper: process sourcemap file +async function processSourceMapFile (mapPath) { + try { + const fsPromises = require('fs').promises + const sourceMap = require('source-map') + + const contents = await fsPromises.readFile(mapPath, 'utf8') + const consumer = await new sourceMap.SourceMapConsumer(contents) + + const dir = path.dirname(mapPath) + const generatedPathCandidates = [] + + if (consumer.file) { + generatedPathCandidates.push(path.resolve(dir, consumer.file)) + } + generatedPathCandidates.push(path.resolve(dir, path.basename(mapPath, '.map'))) + + for (const generatedPath of generatedPathCandidates) { + try { + await fsPromises.access(generatedPath) + return { + generatedPath, + info: { mapFileDir: dir, mapConsumer: consumer } + } + } catch {} + } + return null + } catch { + return null + } +} - const { SourceMapper } = require('@datadog/pprof/out/src/sourcemapper/sourcemapper') +// Load sourcemaps from node_modules packages +async function loadNodeModulesSourceMaps (moduleNames, debug = false) { + const entries = new Map() - sourceMapperPromise = SourceMapper.create(dirs) - .then(mapper => { - sourceMapper = mapper - console.log('🗺️ Sourcemap initialization complete') - return mapper + for (const moduleName of moduleNames) { + const modulePath = resolveModulePath(process.cwd(), moduleName) + if (!modulePath) { + if (debug) { + console.warn(`⚠️ Could not resolve module: ${moduleName}`) + } + continue + } + + if (debug) { + console.log(`🗺️ Scanning ${moduleName} for sourcemaps...`) + } + + let mapCount = 0 + for await (const mapFile of walkForMapFiles(modulePath)) { + const entry = await processSourceMapFile(mapFile) + if (entry) { + entries.set(entry.generatedPath, entry.info) + mapCount++ + } + } + + if (debug) { + console.log(`🗺️ Loaded ${mapCount} sourcemaps from ${moduleName}`) + } + } + + return entries +} + +// Start loading node_modules sourcemaps if configured +if (nodeModulesSourceMaps) { + const mods = nodeModulesSourceMaps.split(',').filter(m => m.trim()) + + if (mods.length > 0) { + console.log(`🗺️ Loading sourcemaps from node_modules: ${mods.join(', ')}`) + + nodeModulesMapperPromise = loadNodeModulesSourceMaps(mods, false) + .then(entries => { + console.log(`🗺️ Loaded ${entries.size} sourcemaps from node_modules`) + return entries }) .catch(error => { - console.error('⚠️ Warning: Failed to initialize sourcemaps:', error.message) - return null + console.error('⚠️ Warning: Failed to load node_modules sourcemaps:', error.message) + return new Map() }) } } +// Parse sourcemap directories +const dirs = sourcemapDirs + ? sourcemapDirs.split(path.delimiter).filter(d => d.trim()) + : [] + +// Initialize sourcemaps if we have either dirs or node_modules sourcemaps +if (dirs.length > 0 || nodeModulesMapperPromise) { + const { SourceMapper } = require('@datadog/pprof/out/src/sourcemapper/sourcemapper') + + if (dirs.length > 0) { + console.log(`🗺️ Initializing sourcemap support for directories: ${dirs.join(', ')}`) + } + + sourceMapperPromise = (async () => { + try { + // Create SourceMapper from dirs if provided, otherwise create empty one + const mapper = dirs.length > 0 + ? await SourceMapper.create(dirs) + : new SourceMapper(false) + + // Merge node_modules sourcemaps if available + if (nodeModulesMapperPromise) { + const nodeModulesEntries = await nodeModulesMapperPromise + for (const [generatedPath, info] of nodeModulesEntries) { + mapper.infoMap.set(generatedPath, info) + } + } + + sourceMapper = mapper + console.log('🗺️ Sourcemap initialization complete') + return mapper + } catch (error) { + console.error('⚠️ Warning: Failed to initialize sourcemaps:', error.message) + return null + } + })() +} + function generateFlamegraph (pprofPath, outputPath) { return new Promise((resolve, reject) => { // Find the flame CLI diff --git a/test/cli.test.js b/test/cli.test.js index 96131ce..0a1ae64 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -102,6 +102,147 @@ test('CLI help should include node-options flag', async (t) => { assert.ok(result.stdout.includes('Node.js CLI options to pass'), 'Should show node-options description') }) +test('CLI help should include node-modules-source-maps flag', async (t) => { + const result = await runCli(['--help']) + + assert.strictEqual(result.code, 0, 'Should exit successfully') + assert.ok(result.stdout.includes('--node-modules-source-maps'), 'Should show --node-modules-source-maps option') + assert.ok(result.stdout.includes('-n'), 'Should show -n short option') + assert.ok(result.stdout.includes('Node modules to load sourcemaps from'), 'Should show node-modules-source-maps description') +}) + +test('CLI should accept --node-modules-source-maps flag', async (t) => { + // Create a simple test script that checks for the env var + const testScript = path.join(__dirname, 'temp-node-modules-sourcemaps-test.js') + fs.writeFileSync(testScript, ` + console.log('FLAME_NODE_MODULES_SOURCE_MAPS:', process.env.FLAME_NODE_MODULES_SOURCE_MAPS); + console.log('Test completed'); + process.exit(0); + `) + + const child = spawn('node', [cliPath, 'run', '--node-modules-source-maps=next,@next/next-server', testScript], { + stdio: 'pipe' + }) + + let stdout = '' + + child.stdout.on('data', (data) => { + stdout += data.toString() + }) + + child.stderr.on('data', (data) => { + // Consume stderr to prevent blocking + }) + + // Wait for the process to complete or timeout after 5 seconds + const [exitCode] = await Promise.race([ + once(child, 'close'), + new Promise((resolve) => { + setTimeout(() => { + child.kill('SIGKILL') + resolve([-1]) + }, 5000) + }) + ]) + + // Clean up + fs.unlinkSync(testScript) + + // Verify the env var was passed correctly + assert.notStrictEqual(exitCode, -1, 'Process should complete before timeout') + assert.ok(stdout.includes('FLAME_NODE_MODULES_SOURCE_MAPS: next,@next/next-server'), 'Should pass node-modules-source-maps to the profiled process') +}) + +test('CLI should accept -n shorthand for node-modules-source-maps', async (t) => { + // Create a simple test script that checks for the env var + const testScript = path.join(__dirname, 'temp-node-modules-sourcemaps-short-test.js') + fs.writeFileSync(testScript, ` + console.log('FLAME_NODE_MODULES_SOURCE_MAPS:', process.env.FLAME_NODE_MODULES_SOURCE_MAPS); + console.log('Test completed'); + process.exit(0); + `) + + const child = spawn('node', [cliPath, 'run', '-n', 'next', testScript], { + stdio: 'pipe' + }) + + let stdout = '' + + child.stdout.on('data', (data) => { + stdout += data.toString() + }) + + child.stderr.on('data', (data) => { + // Consume stderr to prevent blocking + }) + + // Wait for the process to complete or timeout after 5 seconds + const [exitCode] = await Promise.race([ + once(child, 'close'), + new Promise((resolve) => { + setTimeout(() => { + child.kill('SIGKILL') + resolve([-1]) + }, 5000) + }) + ]) + + // Clean up + fs.unlinkSync(testScript) + + // Verify the env var was passed correctly + assert.notStrictEqual(exitCode, -1, 'Process should complete before timeout') + assert.ok(stdout.includes('FLAME_NODE_MODULES_SOURCE_MAPS: next'), 'Should pass node-modules-source-maps via -n shorthand') +}) + +test('CLI should accept both --sourcemap-dirs and --node-modules-source-maps together', async (t) => { + // Create a simple test script that checks for both env vars + const testScript = path.join(__dirname, 'temp-both-sourcemaps-test.js') + fs.writeFileSync(testScript, ` + console.log('FLAME_SOURCEMAP_DIRS:', process.env.FLAME_SOURCEMAP_DIRS); + console.log('FLAME_NODE_MODULES_SOURCE_MAPS:', process.env.FLAME_NODE_MODULES_SOURCE_MAPS); + console.log('Test completed'); + process.exit(0); + `) + + // Use platform-appropriate separator for sourcemap-dirs input + const sourcemapDirsArg = `--sourcemap-dirs=dist${path.delimiter}build` + const child = spawn('node', [cliPath, 'run', sourcemapDirsArg, '--node-modules-source-maps=next,@next/next-server', testScript], { + stdio: 'pipe' + }) + + let stdout = '' + + child.stdout.on('data', (data) => { + stdout += data.toString() + }) + + child.stderr.on('data', (data) => { + // Consume stderr to prevent blocking + }) + + // Wait for the process to complete or timeout after 5 seconds + const [exitCode] = await Promise.race([ + once(child, 'close'), + new Promise((resolve) => { + setTimeout(() => { + child.kill('SIGKILL') + resolve([-1]) + }, 5000) + }) + ]) + + // Clean up + fs.unlinkSync(testScript) + + // Verify both env vars were passed correctly + // The env var uses path.delimiter which is ';' on Windows and ':' on Unix + const expectedSourcemapDirs = `dist${path.delimiter}build` + assert.notStrictEqual(exitCode, -1, 'Process should complete before timeout') + assert.ok(stdout.includes(`FLAME_SOURCEMAP_DIRS: ${expectedSourcemapDirs}`), 'Should pass sourcemap-dirs to the profiled process') + assert.ok(stdout.includes('FLAME_NODE_MODULES_SOURCE_MAPS: next,@next/next-server'), 'Should pass node-modules-source-maps to the profiled process') +}) + test('CLI should accept --node-options flag', async (t) => { // Create a simple test script that uses process.execArgv to verify node options were passed const testScript = path.join(__dirname, 'temp-node-options-test.js')