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
96 changes: 96 additions & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ const dirs = sourcemapDirs
// 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')
const sourceMap = require('source-map')

if (dirs.length > 0) {
console.log(`🗺️ Initializing sourcemap support for directories: ${dirs.join(', ')}`)
Expand All @@ -173,6 +174,101 @@ if (dirs.length > 0 || nodeModulesMapperPromise) {
}
}

// Helper to extract function name from source line
// Matches: function name(, async function name(, name = function(, name: function(, etc.
function extractFunctionName (sourceLine) {
if (!sourceLine) return null
// Match function declarations: function name( or async function name(
const funcMatch = sourceLine.match(/(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/)
if (funcMatch) return funcMatch[1]
// Match arrow functions: name = ( or name = async (
const arrowMatch = sourceLine.match(/([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(?:async\s*)?\(/)
if (arrowMatch) return arrowMatch[1]
// Match object property functions: name: function( or name: async function(
const propFuncMatch = sourceLine.match(/([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:\s*(?:async\s+)?function\s*\(/)
if (propFuncMatch) return propFuncMatch[1]
// Match method shorthand in class/object: name( but not control flow like if(
const methodMatch = sourceLine.match(/^\s*(?:async\s+)?([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/)
if (methodMatch) {
const name = methodMatch[1]
// Exclude control flow keywords
if (!['if', 'else', 'for', 'while', 'switch', 'catch', 'with'].includes(name)) {
return name
}
}
return null
}

// Cache for source content lines to avoid repeated parsing
const sourceContentCache = new Map()

// Override the mappingInfo method to use LEAST_UPPER_BOUND bias for better
// compatibility with Turbopack and other bundlers that generate minified
// files where mappings don't start at column 0
mapper.mappingInfo = function (location) {
const inputPath = path.normalize(location.file)
const entry = this.getMappingInfo(inputPath)
if (entry === null) {
return location
}

const generatedPos = {
line: location.line,
column: location.column > 0 ? location.column - 1 : 0
}

const consumer = entry.mapConsumer

// First try default lookup
let pos = consumer.originalPositionFor(generatedPos)

// If no mapping found, try with LEAST_UPPER_BOUND bias to find
// the nearest mapping to the right (useful for Turbopack's loader code
// that occupies the beginning of lines without mappings)
if (pos.source === null) {
pos = consumer.originalPositionFor({
...generatedPos,
bias: sourceMap.SourceMapConsumer.LEAST_UPPER_BOUND
})
}

if (pos.source === null) {
return location
}

let resolvedName = pos.name || location.name

// If no name from sourcemap, try to extract from sourcesContent
if (!pos.name && pos.source && pos.line) {
try {
// Get or cache the source content lines
let lines = sourceContentCache.get(pos.source)
if (!lines) {
const content = consumer.sourceContentFor(pos.source, true)
if (content) {
lines = content.split('\n')
sourceContentCache.set(pos.source, lines)
}
}
if (lines && lines[pos.line - 1]) {
const extractedName = extractFunctionName(lines[pos.line - 1])
if (extractedName) {
resolvedName = extractedName
}
}
} catch (e) {
// Ignore errors in name extraction
}
}

return {
file: path.resolve(entry.mapFileDir, pos.source),
line: pos.line || undefined,
name: resolvedName,
column: pos.column === null ? undefined : pos.column + 1
}
}

sourceMapper = mapper
console.log('🗺️ Sourcemap initialization complete')
return mapper
Expand Down
205 changes: 205 additions & 0 deletions test/sourcemap-enhancement.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
'use strict'

const test = require('node:test')
const assert = require('node:assert')
const fs = require('fs')
const path = require('path')

// Test the sourcemap enhancement logic directly
// This tests the LEAST_UPPER_BOUND bias and function name extraction from sourcesContent

test('sourcemap enhancement: extractFunctionName helper', async (t) => {
// Import the helper by evaluating it in isolation
// (since it's defined inside the preload closure, we recreate it here for testing)
function extractFunctionName (sourceLine) {
if (!sourceLine) return null
// Match function declarations: function name( or async function name(
const funcMatch = sourceLine.match(/(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/)
if (funcMatch) return funcMatch[1]
// Match arrow functions: name = ( or name = async (
const arrowMatch = sourceLine.match(/([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(?:async\s*)?\(/)
if (arrowMatch) return arrowMatch[1]
// Match object property functions: name: function( or name: async function(
const propFuncMatch = sourceLine.match(/([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:\s*(?:async\s+)?function\s*\(/)
if (propFuncMatch) return propFuncMatch[1]
// Match method shorthand in class/object: name( but not control flow like if(
const methodMatch = sourceLine.match(/^\s*(?:async\s+)?([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/)
if (methodMatch) {
const name = methodMatch[1]
// Exclude control flow keywords
if (!['if', 'else', 'for', 'while', 'switch', 'catch', 'with'].includes(name)) {
return name
}
}
return null
}

await t.test('should extract function declaration names', () => {
assert.strictEqual(extractFunctionName('function myFunction() {'), 'myFunction')
assert.strictEqual(extractFunctionName('function getCustomFormFields(resumableState, formAction) {'), 'getCustomFormFields')
assert.strictEqual(extractFunctionName(' function innerFunc() {'), 'innerFunc')
})

await t.test('should extract async function names', () => {
assert.strictEqual(extractFunctionName('async function fetchData() {'), 'fetchData')
assert.strictEqual(extractFunctionName('async function renderNodeDestructive(task) {'), 'renderNodeDestructive')
})

await t.test('should extract arrow function names', () => {
assert.strictEqual(extractFunctionName('const handler = () => {'), 'handler')
assert.strictEqual(extractFunctionName('const process = async () => {'), 'process')
assert.strictEqual(extractFunctionName('let callback = (x) => x * 2'), 'callback')
})

await t.test('should extract method definition names', () => {
assert.strictEqual(extractFunctionName(' render() {'), 'render')
assert.strictEqual(extractFunctionName(' async componentDidMount() {'), 'componentDidMount')
})

await t.test('should extract object property function names', () => {
assert.strictEqual(extractFunctionName(' performWork: function() {'), 'performWork')
assert.strictEqual(extractFunctionName(' retryNode: async function(node) {'), 'retryNode')
})

await t.test('should return null for non-function lines', () => {
assert.strictEqual(extractFunctionName('const x = 5;'), null)
assert.strictEqual(extractFunctionName('if (true) {'), null)
assert.strictEqual(extractFunctionName('return result;'), null)
assert.strictEqual(extractFunctionName(''), null)
assert.strictEqual(extractFunctionName(null), null)
})
})

test('sourcemap enhancement: LEAST_UPPER_BOUND bias for Turbopack-style maps', async (t) => {
const sourceMap = require('source-map')

// Create a synthetic sourcemap using SourceMapGenerator (more reliable than hand-crafting VLQ)
const generator = new sourceMap.SourceMapGenerator({ file: 'generated.js' })

generator.setSourceContent('original.ts', 'function myFunction(arg1, arg2) {\n return arg1 + arg2;\n}\n')

// Add a mapping starting at column 350 (simulating Turbopack's loader code taking up early columns)
generator.addMapping({
generated: { line: 1, column: 350 },
original: { line: 1, column: 0 },
source: 'original.ts',
name: 'myFunction'
})

const syntheticMap = JSON.parse(generator.toString())
const consumer = await new sourceMap.SourceMapConsumer(syntheticMap)

await t.test('exact position lookup should fail for early columns', () => {
// Column 0 has no mapping (the mapping starts at column 350)
const pos = consumer.originalPositionFor({ line: 1, column: 0 })
assert.strictEqual(pos.source, null, 'Should not find mapping at column 0')
})

await t.test('LEAST_UPPER_BOUND should find nearest mapping to the right', () => {
// Using LEAST_UPPER_BOUND should find the mapping at column 350
const pos = consumer.originalPositionFor({
line: 1,
column: 0,
bias: sourceMap.SourceMapConsumer.LEAST_UPPER_BOUND
})
assert.strictEqual(pos.source, 'original.ts', 'Should find source with LEAST_UPPER_BOUND')
assert.strictEqual(pos.line, 1, 'Should map to line 1')
assert.strictEqual(pos.name, 'myFunction', 'Should have the function name')
})

consumer.destroy()
})

test('sourcemap enhancement: function name extraction from sourcesContent', async (t) => {
const sourceMap = require('source-map')

// Create a sourcemap WITH sourcesContent but WITHOUT name mappings
// This mimics how Next.js bundles React - file/line mappings exist but names don't
const mapWithSourcesContent = {
version: 3,
sources: ['react-internal.js'],
names: [], // Empty names - no name mappings!
mappings: 'AAAA;AACA;AACA', // Maps lines 1-3 to source lines 1-3, but no names
sourcesContent: [
'function renderElement(type, props) {\n' +
' const element = createElement(type, props);\n' +
' return element;\n' +
'}\n' +
'\n' +
'async function finishFunctionComponent(Component) {\n' +
' const result = await Component();\n' +
' return result;\n' +
'}'
]
}

const consumer = await new sourceMap.SourceMapConsumer(mapWithSourcesContent)

await t.test('sourcesContent should be accessible', () => {
const content = consumer.sourceContentFor('react-internal.js')
assert.ok(content, 'Should have source content')
assert.ok(content.includes('function renderElement'), 'Should contain the function')
})

await t.test('position lookup should work but without name', () => {
const pos = consumer.originalPositionFor({ line: 1, column: 0 })
assert.strictEqual(pos.source, 'react-internal.js')
assert.strictEqual(pos.line, 1)
assert.strictEqual(pos.name, null, 'Name should be null since names array is empty')
})

await t.test('function name can be extracted from source line', () => {
const content = consumer.sourceContentFor('react-internal.js')
const lines = content.split('\n')

// Line 1 should contain renderElement
assert.ok(lines[0].includes('function renderElement'), 'Line 1 should have renderElement')

// Line 6 should contain finishFunctionComponent
assert.ok(lines[5].includes('async function finishFunctionComponent'), 'Line 6 should have finishFunctionComponent')
})

consumer.destroy()
})

test('sourcemap enhancement: integration with SourceMapper', async (t) => {
const { SourceMapper } = require('@datadog/pprof/out/src/sourcemapper/sourcemapper')

// Create a temporary directory with test fixtures
const fixtureDir = path.join(__dirname, 'temp-sourcemap-fixtures')
fs.mkdirSync(fixtureDir, { recursive: true })

// Create a generated JS file
const generatedJs = '// loader code padding '.repeat(20) + 'function eY(){return 1}'
fs.writeFileSync(path.join(fixtureDir, 'bundle.js'), generatedJs + '\n//# sourceMappingURL=bundle.js.map')

// Create corresponding sourcemap with sourcesContent but no name mappings
const bundleMap = {
version: 3,
file: 'bundle.js',
sources: ['../src/utils.ts'],
names: [],
mappings: 'gNAAA', // Maps to column 400+ in generated, line 1 col 0 in source
sourcesContent: ['function getCustomFormFields(state) {\n return state.fields;\n}']
}
fs.writeFileSync(path.join(fixtureDir, 'bundle.js.map'), JSON.stringify(bundleMap))

try {
const mapper = await SourceMapper.create([fixtureDir])

await t.test('should load the sourcemap', () => {
const bundlePath = path.join(fixtureDir, 'bundle.js')
assert.ok(mapper.infoMap.has(bundlePath), 'Should have mapping for bundle.js')
})

await t.test('should have access to map consumer', () => {
const bundlePath = path.join(fixtureDir, 'bundle.js')
const entry = mapper.infoMap.get(bundlePath)
assert.ok(entry, 'Should have entry')
assert.ok(entry.mapConsumer, 'Should have mapConsumer')
})
} finally {
// Cleanup
fs.rmSync(fixtureDir, { recursive: true, force: true })
}
})
Loading