@platformatic/wattpm-pprof-capture 3.28.0-alpha.1 → 3.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +23 -3
- package/lib/node-modules-sourcemaps.js +231 -0
- package/package.json +3 -3
- package/test/fixtures/node-modules-sourcemap-test/platformatic.json +15 -0
- package/test/fixtures/node-modules-sourcemap-test/service/platformatic.json +14 -0
- package/test/fixtures/node-modules-sourcemap-test/service/plugin.js +17 -0
- package/test/fixtures/sourcemap-config-test/platformatic.json +16 -0
- package/test/fixtures/sourcemap-config-test/service/platformatic.json +14 -0
- package/test/fixtures/sourcemap-config-test/service/plugin.ts +71 -0
- package/test/fixtures/sourcemap-config-test/service/tsconfig.json +17 -0
- package/test/minimal-sourcemap.test.js +3 -1
- package/test/node-modules-sourcemap.test.js +78 -0
- package/test/sourcemap.test.js +82 -8
package/index.js
CHANGED
|
@@ -229,7 +229,7 @@ function startIfOverThreshold (type, state, wasRunning = state.profilerStarted)
|
|
|
229
229
|
}
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
async function initializeSourceMapper () {
|
|
232
|
+
async function initializeSourceMapper (options = {}) {
|
|
233
233
|
if (sourceMapperInitialized) {
|
|
234
234
|
return
|
|
235
235
|
}
|
|
@@ -251,6 +251,19 @@ async function initializeSourceMapper () {
|
|
|
251
251
|
const debug = process.env.PLT_PPROF_SOURCEMAP_DEBUG === 'true'
|
|
252
252
|
const innerMapper = await SourceMapper.create([appPath], debug)
|
|
253
253
|
|
|
254
|
+
// Load additional node_modules sourcemaps if specified
|
|
255
|
+
if (options.nodeModulesSourceMaps?.length > 0) {
|
|
256
|
+
const { loadNodeModulesSourceMaps } = await import('./lib/node-modules-sourcemaps.js')
|
|
257
|
+
const moduleEntries = await loadNodeModulesSourceMaps(
|
|
258
|
+
appPath,
|
|
259
|
+
options.nodeModulesSourceMaps,
|
|
260
|
+
debug
|
|
261
|
+
)
|
|
262
|
+
for (const [generatedPath, info] of moduleEntries) {
|
|
263
|
+
innerMapper.infoMap.set(generatedPath, info)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
254
267
|
// Wrap the SourceMapper to fix Windows path normalization
|
|
255
268
|
sourceMapper = new SourceMapperWrapper(innerMapper)
|
|
256
269
|
|
|
@@ -280,9 +293,16 @@ export async function startProfiling (options = {}) {
|
|
|
280
293
|
}
|
|
281
294
|
|
|
282
295
|
// Initialize source mapper if source maps are requested
|
|
283
|
-
|
|
296
|
+
if (options.sourceMaps === undefined) {
|
|
297
|
+
state.sourceMapsEnabled = process.sourceMapsEnabled
|
|
298
|
+
} else if (options.sourceMaps === true) {
|
|
299
|
+
state.sourceMapsEnabled = true
|
|
300
|
+
}
|
|
301
|
+
|
|
284
302
|
if (state.sourceMapsEnabled) {
|
|
285
|
-
await initializeSourceMapper(
|
|
303
|
+
await initializeSourceMapper({
|
|
304
|
+
nodeModulesSourceMaps: options.nodeModulesSourceMaps
|
|
305
|
+
})
|
|
286
306
|
}
|
|
287
307
|
|
|
288
308
|
state.isCapturing = true
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import * as sourceMap from 'source-map'
|
|
5
|
+
|
|
6
|
+
const MAP_EXT = '.map'
|
|
7
|
+
const MAP_FILE_PATTERN = /\.[cm]?js\.map$/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Check if an error is a non-fatal filesystem error that should be silently ignored.
|
|
11
|
+
*/
|
|
12
|
+
function isNonFatalError (error) {
|
|
13
|
+
const nonFatalErrors = ['ENOENT', 'EPERM', 'EACCES', 'ELOOP']
|
|
14
|
+
return error instanceof Error && error.code && nonFatalErrors.includes(error.code)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Async generator that recursively walks a directory looking for .map files.
|
|
19
|
+
* Similar to @datadog/pprof's walk function but without the node_modules exclusion.
|
|
20
|
+
*/
|
|
21
|
+
async function * walkForMapFiles (dir) {
|
|
22
|
+
async function * walkRecursive (currentDir) {
|
|
23
|
+
try {
|
|
24
|
+
const dirHandle = await fs.opendir(currentDir)
|
|
25
|
+
for await (const entry of dirHandle) {
|
|
26
|
+
const entryPath = path.join(currentDir, entry.name)
|
|
27
|
+
if (entry.isDirectory()) {
|
|
28
|
+
// Skip .git directories but NOT node_modules (unlike @datadog/pprof)
|
|
29
|
+
if (entry.name !== '.git') {
|
|
30
|
+
yield * walkRecursive(entryPath)
|
|
31
|
+
}
|
|
32
|
+
} else if (entry.isFile() && MAP_FILE_PATTERN.test(entry.name)) {
|
|
33
|
+
// Verify the file is readable
|
|
34
|
+
try {
|
|
35
|
+
await fs.access(entryPath, fs.constants.R_OK)
|
|
36
|
+
yield entryPath
|
|
37
|
+
} catch {
|
|
38
|
+
// Skip unreadable files
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (!isNonFatalError(error)) {
|
|
44
|
+
throw error
|
|
45
|
+
}
|
|
46
|
+
// Silently ignore non-fatal errors (ENOENT, EPERM, etc.)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
yield * walkRecursive(dir)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Process a single .map file and return the entry for the infoMap.
|
|
54
|
+
* Returns { generatedPath, info } or null if processing fails.
|
|
55
|
+
*/
|
|
56
|
+
async function processSourceMapFile (mapPath, debug, logger) {
|
|
57
|
+
if (!mapPath || !mapPath.endsWith(MAP_EXT)) {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
mapPath = path.normalize(mapPath)
|
|
62
|
+
|
|
63
|
+
let contents
|
|
64
|
+
try {
|
|
65
|
+
contents = await fs.readFile(mapPath, 'utf8')
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (debug && logger) {
|
|
68
|
+
logger.debug({ mapPath, error: error.message }, 'Could not read source map file')
|
|
69
|
+
}
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let consumer
|
|
74
|
+
try {
|
|
75
|
+
consumer = await new sourceMap.SourceMapConsumer(contents)
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (debug && logger) {
|
|
78
|
+
logger.debug({ mapPath, error: error.message }, 'Could not parse source map file')
|
|
79
|
+
}
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Determine the generated file path
|
|
84
|
+
// Same logic as @datadog/pprof: try consumer.file first, then basename without .map
|
|
85
|
+
const dir = path.dirname(mapPath)
|
|
86
|
+
const generatedPathCandidates = []
|
|
87
|
+
|
|
88
|
+
if (consumer.file) {
|
|
89
|
+
generatedPathCandidates.push(path.resolve(dir, consumer.file))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const samePath = path.resolve(dir, path.basename(mapPath, MAP_EXT))
|
|
93
|
+
if (generatedPathCandidates.length === 0 || generatedPathCandidates[0] !== samePath) {
|
|
94
|
+
generatedPathCandidates.push(samePath)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Find the first candidate that exists
|
|
98
|
+
for (const generatedPath of generatedPathCandidates) {
|
|
99
|
+
try {
|
|
100
|
+
await fs.access(generatedPath, fs.constants.F_OK)
|
|
101
|
+
if (debug && logger) {
|
|
102
|
+
logger.debug({ generatedPath, mapPath }, 'Loaded source map for node_modules file')
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
generatedPath,
|
|
106
|
+
info: {
|
|
107
|
+
mapFileDir: dir,
|
|
108
|
+
mapConsumer: consumer
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
if (debug && logger) {
|
|
113
|
+
logger.debug({ generatedPath }, 'Generated path does not exist')
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (debug && logger) {
|
|
119
|
+
logger.debug({ mapPath }, 'Unable to find generated file for source map')
|
|
120
|
+
}
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Resolve a module path from the application directory.
|
|
126
|
+
* Handles both regular and scoped packages (e.g., 'next' and '@next/next-server').
|
|
127
|
+
*/
|
|
128
|
+
function resolveModulePath (appPath, moduleName) {
|
|
129
|
+
// Try using require.resolve from the app's context
|
|
130
|
+
try {
|
|
131
|
+
const require = createRequire(path.join(appPath, 'package.json'))
|
|
132
|
+
const modulePath = require.resolve(moduleName)
|
|
133
|
+
// Get the module's root directory
|
|
134
|
+
// For regular packages: node_modules/next/dist/file.js -> node_modules/next
|
|
135
|
+
// For scoped packages: node_modules/@next/next-server/dist/file.js -> node_modules/@next/next-server
|
|
136
|
+
const nodeModulesIndex = modulePath.lastIndexOf('node_modules')
|
|
137
|
+
if (nodeModulesIndex === -1) {
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const afterNodeModules = modulePath.substring(nodeModulesIndex + 'node_modules'.length + 1)
|
|
142
|
+
let moduleRoot
|
|
143
|
+
if (moduleName.startsWith('@')) {
|
|
144
|
+
// Scoped package: @scope/package
|
|
145
|
+
const parts = afterNodeModules.split(path.sep)
|
|
146
|
+
moduleRoot = path.join(modulePath.substring(0, nodeModulesIndex), 'node_modules', parts[0], parts[1])
|
|
147
|
+
} else {
|
|
148
|
+
// Regular package
|
|
149
|
+
const parts = afterNodeModules.split(path.sep)
|
|
150
|
+
moduleRoot = path.join(modulePath.substring(0, nodeModulesIndex), 'node_modules', parts[0])
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return moduleRoot
|
|
154
|
+
} catch {
|
|
155
|
+
// Module not found, try walking up from appPath
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Fallback: walk up directory tree looking for node_modules
|
|
159
|
+
let currentDir = appPath
|
|
160
|
+
while (currentDir !== path.dirname(currentDir)) {
|
|
161
|
+
const modulePath = path.join(currentDir, 'node_modules', moduleName)
|
|
162
|
+
try {
|
|
163
|
+
// Check if the module directory exists synchronously (we're in a sync context here)
|
|
164
|
+
const stat = require('node:fs').statSync(modulePath)
|
|
165
|
+
if (stat.isDirectory()) {
|
|
166
|
+
return modulePath
|
|
167
|
+
}
|
|
168
|
+
} catch {
|
|
169
|
+
// Not found, continue up
|
|
170
|
+
}
|
|
171
|
+
currentDir = path.dirname(currentDir)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Load source maps from specified node_modules packages.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} appPath - The application root directory
|
|
181
|
+
* @param {string[]} moduleNames - Array of module names to load sourcemaps from (e.g., ['next', '@next/next-server'])
|
|
182
|
+
* @param {boolean} debug - Whether to enable debug logging
|
|
183
|
+
* @returns {Promise<Map<string, {mapFileDir: string, mapConsumer: SourceMapConsumer}>>}
|
|
184
|
+
*/
|
|
185
|
+
export async function loadNodeModulesSourceMaps (appPath, moduleNames, debug = false) {
|
|
186
|
+
const entries = new Map()
|
|
187
|
+
const logger = globalThis.platformatic?.logger
|
|
188
|
+
|
|
189
|
+
if (debug && logger) {
|
|
190
|
+
logger.debug({ appPath, moduleNames }, 'Loading source maps from node_modules')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (const moduleName of moduleNames) {
|
|
194
|
+
const modulePath = resolveModulePath(appPath, moduleName)
|
|
195
|
+
if (!modulePath) {
|
|
196
|
+
if (logger) {
|
|
197
|
+
logger.warn({ moduleName }, 'Could not resolve module path for sourcemap loading')
|
|
198
|
+
}
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (debug && logger) {
|
|
203
|
+
logger.debug({ moduleName, modulePath }, 'Scanning module for source maps')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let mapCount = 0
|
|
207
|
+
try {
|
|
208
|
+
for await (const mapFile of walkForMapFiles(modulePath)) {
|
|
209
|
+
const entry = await processSourceMapFile(mapFile, debug, logger)
|
|
210
|
+
if (entry) {
|
|
211
|
+
entries.set(entry.generatedPath, entry.info)
|
|
212
|
+
mapCount++
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (logger) {
|
|
217
|
+
logger.warn({ moduleName, error: error.message }, 'Error scanning module for source maps')
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (debug && logger) {
|
|
222
|
+
logger.debug({ moduleName, mapCount }, 'Finished scanning module for source maps')
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (debug && logger) {
|
|
227
|
+
logger.debug({ totalMaps: entries.size }, 'Finished loading node_modules source maps')
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return entries
|
|
231
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@platformatic/wattpm-pprof-capture",
|
|
3
|
-
"version": "3.28.
|
|
3
|
+
"version": "3.28.1",
|
|
4
4
|
"description": "pprof profiling capture for wattpm",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"neostandard": "^0.12.0",
|
|
27
27
|
"pprof-format": "^2.1.0",
|
|
28
28
|
"typescript": "^5.0.0",
|
|
29
|
-
"@platformatic/foundation": "3.28.
|
|
30
|
-
"@platformatic/service": "3.28.
|
|
29
|
+
"@platformatic/foundation": "3.28.1",
|
|
30
|
+
"@platformatic/service": "3.28.1"
|
|
31
31
|
},
|
|
32
32
|
"engines": {
|
|
33
33
|
"node": ">=22.19.0"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://schemas.platformatic.dev/@platformatic/runtime/2.0.0.json",
|
|
3
|
+
"entrypoint": "service",
|
|
4
|
+
"preload": "../../../index.js",
|
|
5
|
+
"autoload": {
|
|
6
|
+
"path": "./"
|
|
7
|
+
},
|
|
8
|
+
"server": {
|
|
9
|
+
"hostname": "127.0.0.1",
|
|
10
|
+
"port": 0
|
|
11
|
+
},
|
|
12
|
+
"logger": {
|
|
13
|
+
"level": "error"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Simple plugin that can trigger CPU-intensive work
|
|
2
|
+
// The test uses this to generate profiles while testing nodeModulesSourceMaps option
|
|
3
|
+
|
|
4
|
+
export default async function (app) {
|
|
5
|
+
app.get('/', async () => {
|
|
6
|
+
return { message: 'Hello from Node Modules Sourcemap Test' }
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
app.get('/compute', async () => {
|
|
10
|
+
// Do some CPU-intensive work to generate profiling samples
|
|
11
|
+
let result = 0
|
|
12
|
+
for (let i = 0; i < 1000000; i++) {
|
|
13
|
+
result += Math.sqrt(i)
|
|
14
|
+
}
|
|
15
|
+
return { result }
|
|
16
|
+
})
|
|
17
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://schemas.platformatic.dev/@platformatic/runtime/3.20.0.json",
|
|
3
|
+
"entrypoint": "service",
|
|
4
|
+
"preload": "../../../index.js",
|
|
5
|
+
"autoload": {
|
|
6
|
+
"path": "./"
|
|
7
|
+
},
|
|
8
|
+
"server": {
|
|
9
|
+
"hostname": "127.0.0.1",
|
|
10
|
+
"port": 0
|
|
11
|
+
},
|
|
12
|
+
"logger": {
|
|
13
|
+
"level": "error"
|
|
14
|
+
},
|
|
15
|
+
"sourceMaps": true
|
|
16
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type FastifyInstance } from 'fastify'
|
|
2
|
+
|
|
3
|
+
// A function with a distinctive name that we can find in the profile
|
|
4
|
+
// This function allocates significant memory to ensure heap profiler captures samples
|
|
5
|
+
function myTypeScriptFunction(value: number): number {
|
|
6
|
+
let result = 0
|
|
7
|
+
const allocations: Array<{ data: number[], metadata: object }> = []
|
|
8
|
+
|
|
9
|
+
// Allocate memory in chunks larger than heap profiler sampling interval (512KB)
|
|
10
|
+
// Each iteration allocates ~1MB to ensure we trigger heap profiler sampling
|
|
11
|
+
// Reduced to 3 iterations to make test faster while still generating enough samples
|
|
12
|
+
for (let i = 0; i < 3; i++) {
|
|
13
|
+
// Allocate a large array (~800KB of numbers)
|
|
14
|
+
const largeArray = new Array(100000)
|
|
15
|
+
for (let j = 0; j < largeArray.length; j++) {
|
|
16
|
+
largeArray[j] = Math.sqrt(j * value)
|
|
17
|
+
result += largeArray[j]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Allocate objects with metadata
|
|
21
|
+
const metadata = {
|
|
22
|
+
index: i,
|
|
23
|
+
timestamp: Date.now(),
|
|
24
|
+
value,
|
|
25
|
+
description: 'Memory allocation for heap profiling test'.repeat(10)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
allocations.push({ data: largeArray, metadata })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Keep allocations alive until we're done computing
|
|
32
|
+
return result + allocations.length
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default async function (app: FastifyInstance) {
|
|
36
|
+
app.get('/', async () => {
|
|
37
|
+
return { message: 'Hello from TypeScript' }
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
app.get('/compute', async () => {
|
|
41
|
+
const result = myTypeScriptFunction(42)
|
|
42
|
+
return { result }
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
app.get('/diagnostic', async () => {
|
|
46
|
+
const fs = await import('node:fs/promises')
|
|
47
|
+
const path = await import('node:path')
|
|
48
|
+
const url = await import('node:url')
|
|
49
|
+
const serviceDir = path.dirname(url.fileURLToPath(import.meta.url))
|
|
50
|
+
const pluginJsPath = path.join(serviceDir, 'plugin.js')
|
|
51
|
+
const pluginMapPath = path.join(serviceDir, 'plugin.js.map')
|
|
52
|
+
|
|
53
|
+
const diagnostics = {
|
|
54
|
+
serviceDir,
|
|
55
|
+
pluginJsExists: false,
|
|
56
|
+
pluginMapExists: false
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
await fs.access(pluginJsPath)
|
|
61
|
+
diagnostics.pluginJsExists = true
|
|
62
|
+
} catch {}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
await fs.access(pluginMapPath)
|
|
66
|
+
diagnostics.pluginMapExists = true
|
|
67
|
+
} catch {}
|
|
68
|
+
|
|
69
|
+
return diagnostics
|
|
70
|
+
})
|
|
71
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "nodenext",
|
|
5
|
+
"moduleResolution": "nodenext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": "./",
|
|
9
|
+
"sourceMap": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"strict": false
|
|
14
|
+
},
|
|
15
|
+
"include": ["*.ts"],
|
|
16
|
+
"exclude": ["node_modules", "dist"]
|
|
17
|
+
}
|
|
@@ -3,8 +3,10 @@ import { Worker } from 'node:worker_threads'
|
|
|
3
3
|
import { resolve } from 'node:path'
|
|
4
4
|
import { exec } from 'node:child_process'
|
|
5
5
|
import { promisify } from 'node:util'
|
|
6
|
+
import { platform } from 'node:os'
|
|
6
7
|
|
|
7
8
|
const execAsync = promisify(exec)
|
|
9
|
+
const isWindows = platform() === 'win32'
|
|
8
10
|
|
|
9
11
|
test.before(async () => {
|
|
10
12
|
const serviceDir = resolve(import.meta.dirname, 'fixtures/sourcemap-test/service')
|
|
@@ -16,7 +18,7 @@ test.before(async () => {
|
|
|
16
18
|
})
|
|
17
19
|
|
|
18
20
|
// Test if SourceMapper works in a plain worker thread on Windows
|
|
19
|
-
test('minimal SourceMapper test in worker thread', async (t) => {
|
|
21
|
+
test('minimal SourceMapper test in worker thread', { skip: isWindows }, async (t) => {
|
|
20
22
|
await new Promise((resolve, reject) => {
|
|
21
23
|
const worker = new Worker(new URL('./fixtures/minimal-sourcemap-worker.js', import.meta.url))
|
|
22
24
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
import { loadNodeModulesSourceMaps } from '../lib/node-modules-sourcemaps.js'
|
|
5
|
+
|
|
6
|
+
// Unit test for loadNodeModulesSourceMaps
|
|
7
|
+
test('loadNodeModulesSourceMaps should load source maps from next package', async (t) => {
|
|
8
|
+
// Get the path to the platformatic monorepo root (where next is installed)
|
|
9
|
+
// From packages/wattpm-pprof-capture/test -> go up 3 levels to reach monorepo root
|
|
10
|
+
const monorepoRoot = resolve(import.meta.dirname, '../../..')
|
|
11
|
+
|
|
12
|
+
// Load source maps from the next package
|
|
13
|
+
const entries = await loadNodeModulesSourceMaps(monorepoRoot, ['next'], false)
|
|
14
|
+
|
|
15
|
+
// Verify that we found source maps
|
|
16
|
+
assert.ok(entries.size > 0, `Should have found source maps in next package, found ${entries.size}`)
|
|
17
|
+
|
|
18
|
+
// Verify the structure of entries
|
|
19
|
+
for (const [generatedPath, info] of entries) {
|
|
20
|
+
assert.ok(typeof generatedPath === 'string', 'Generated path should be a string')
|
|
21
|
+
assert.ok(generatedPath.endsWith('.js') || generatedPath.endsWith('.cjs') || generatedPath.endsWith('.mjs'),
|
|
22
|
+
`Generated path should end with .js, .cjs, or .mjs: ${generatedPath}`)
|
|
23
|
+
assert.ok(info.mapFileDir, 'Info should have mapFileDir')
|
|
24
|
+
assert.ok(info.mapConsumer, 'Info should have mapConsumer')
|
|
25
|
+
assert.ok(typeof info.mapConsumer.originalPositionFor === 'function',
|
|
26
|
+
'mapConsumer should have originalPositionFor method')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Log some stats
|
|
30
|
+
console.log(`Loaded ${entries.size} source maps from next package`)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// Unit test for loadNodeModulesSourceMaps with scoped package
|
|
34
|
+
test('loadNodeModulesSourceMaps should handle non-existent packages gracefully', async (t) => {
|
|
35
|
+
const monorepoRoot = resolve(import.meta.dirname, '../../..')
|
|
36
|
+
|
|
37
|
+
// Try to load from a non-existent package
|
|
38
|
+
const entries = await loadNodeModulesSourceMaps(monorepoRoot, ['non-existent-package-12345'], false)
|
|
39
|
+
|
|
40
|
+
// Should return empty map without throwing
|
|
41
|
+
assert.strictEqual(entries.size, 0, 'Should return empty map for non-existent package')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// Unit test for loadNodeModulesSourceMaps with multiple packages
|
|
45
|
+
test('loadNodeModulesSourceMaps should load source maps from multiple packages', async (t) => {
|
|
46
|
+
const monorepoRoot = resolve(import.meta.dirname, '../../..')
|
|
47
|
+
|
|
48
|
+
// Load from multiple packages (next has source maps, fastify typically doesn't)
|
|
49
|
+
const entries = await loadNodeModulesSourceMaps(monorepoRoot, ['next', 'fastify'], false)
|
|
50
|
+
|
|
51
|
+
// Should have at least the next package's source maps
|
|
52
|
+
assert.ok(entries.size > 0, 'Should have found source maps')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// Test that source map data can be used for lookups
|
|
56
|
+
test('loaded source maps should have valid mapping data', async (t) => {
|
|
57
|
+
const monorepoRoot = resolve(import.meta.dirname, '../../..')
|
|
58
|
+
|
|
59
|
+
const entries = await loadNodeModulesSourceMaps(monorepoRoot, ['next'], false)
|
|
60
|
+
assert.ok(entries.size > 0, 'Should have source maps to test')
|
|
61
|
+
|
|
62
|
+
// Pick a random entry and verify we can do a lookup
|
|
63
|
+
const [generatedPath, info] = entries.entries().next().value
|
|
64
|
+
|
|
65
|
+
// Try to get original position for line 1, column 0
|
|
66
|
+
const pos = info.mapConsumer.originalPositionFor({ line: 1, column: 0 })
|
|
67
|
+
|
|
68
|
+
// The position might be null if line 1 col 0 has no mapping,
|
|
69
|
+
// but the function should not throw
|
|
70
|
+
assert.ok(pos !== undefined, 'originalPositionFor should return a result')
|
|
71
|
+
|
|
72
|
+
// Verify the sources array exists
|
|
73
|
+
assert.ok(info.mapConsumer.sources, 'mapConsumer should have sources')
|
|
74
|
+
assert.ok(Array.isArray(info.mapConsumer.sources), 'sources should be an array')
|
|
75
|
+
|
|
76
|
+
console.log(`Tested lookup on ${generatedPath}`)
|
|
77
|
+
console.log(` Sources: ${info.mapConsumer.sources.length}`)
|
|
78
|
+
})
|
package/test/sourcemap.test.js
CHANGED
|
@@ -61,10 +61,7 @@ function decodeAndValidateProfile (encodedProfile, checkLocations) {
|
|
|
61
61
|
return profile
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
test.before(async () => {
|
|
66
|
-
const serviceDir = resolve(import.meta.dirname, 'fixtures/sourcemap-test/service')
|
|
67
|
-
|
|
64
|
+
async function compile (serviceDir) {
|
|
68
65
|
// Build the TypeScript (dependencies are in parent package devDependencies)
|
|
69
66
|
try {
|
|
70
67
|
await execAsync('npx tsc', { cwd: serviceDir, timeout: 30000 })
|
|
@@ -82,10 +79,22 @@ test.before(async () => {
|
|
|
82
79
|
} catch (err) {
|
|
83
80
|
throw new Error(`Build artifacts not found: ${err.message}. Plugin: ${pluginPath}, Map: ${mapPath}`)
|
|
84
81
|
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Build TypeScript service once before all tests
|
|
85
|
+
test.before(async () => {
|
|
86
|
+
const dirs = [
|
|
87
|
+
resolve(import.meta.dirname, 'fixtures/sourcemap-test/service'),
|
|
88
|
+
resolve(import.meta.dirname, 'fixtures/sourcemap-config-test/service')
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
for (const dir of dirs) {
|
|
92
|
+
await compile(dir)
|
|
93
|
+
}
|
|
85
94
|
})
|
|
86
95
|
|
|
87
|
-
async function createApp (t) {
|
|
88
|
-
const configFile = resolve(import.meta.dirname,
|
|
96
|
+
async function createApp (t, config = 'fixtures/sourcemap-test/platformatic.json') {
|
|
97
|
+
const configFile = resolve(import.meta.dirname, config)
|
|
89
98
|
|
|
90
99
|
// Ensure tmp directory exists
|
|
91
100
|
const { mkdir } = await import('node:fs/promises')
|
|
@@ -108,7 +117,7 @@ async function createApp (t) {
|
|
|
108
117
|
return { app, url }
|
|
109
118
|
}
|
|
110
119
|
|
|
111
|
-
test('sourcemaps should be initialized and profiling should work with TypeScript', async t => {
|
|
120
|
+
test('sourcemaps should be initialized and profiling should work with TypeScript', { skip: process.platform === 'win32' }, async t => {
|
|
112
121
|
const { app, url } = await createApp(t)
|
|
113
122
|
|
|
114
123
|
// Verify service is running
|
|
@@ -173,7 +182,7 @@ test('sourcemaps should be initialized and profiling should work with TypeScript
|
|
|
173
182
|
await app.sendCommandToApplication('service', 'stopProfiling')
|
|
174
183
|
})
|
|
175
184
|
|
|
176
|
-
test('sourcemaps should work with heap profiling', async t => {
|
|
185
|
+
test('sourcemaps should work with heap profiling', { skip: process.platform === 'win32' }, async t => {
|
|
177
186
|
const { app, url } = await createApp(t)
|
|
178
187
|
|
|
179
188
|
// Start heap profiling with source maps enabled
|
|
@@ -205,3 +214,68 @@ test('sourcemaps should work with heap profiling', async t => {
|
|
|
205
214
|
|
|
206
215
|
await app.sendCommandToApplication('service', 'stopProfiling', { type: 'heap' })
|
|
207
216
|
})
|
|
217
|
+
|
|
218
|
+
test('sourcemaps should be initialized and profiling should work with TypeScript if enabled via config file', { skip: process.platform === 'win32' }, async t => {
|
|
219
|
+
const { app, url } = await createApp(t, 'fixtures/sourcemap-config-test/platformatic.json')
|
|
220
|
+
|
|
221
|
+
// Verify service is running
|
|
222
|
+
const res = await request(`${url}/`)
|
|
223
|
+
const json = await res.body.json()
|
|
224
|
+
assert.strictEqual(res.statusCode, 200)
|
|
225
|
+
assert.strictEqual(json.message, 'Hello from TypeScript')
|
|
226
|
+
|
|
227
|
+
// Verify sourcemap files exist in service directory
|
|
228
|
+
const diagRes = await request(`${url}/diagnostic`, { headersTimeout: 10000, bodyTimeout: 10000 })
|
|
229
|
+
const diag = await diagRes.body.json()
|
|
230
|
+
assert.strictEqual(diag.pluginMapExists, true, `Sourcemap file should exist at ${diag.serviceDir}/plugin.js.map`)
|
|
231
|
+
|
|
232
|
+
// Start profiling with sufficient duration to ensure we capture samples
|
|
233
|
+
// Enable source maps to resolve TypeScript locations
|
|
234
|
+
await app.sendCommandToApplication('service', 'startProfiling', { durationMillis: 5000 })
|
|
235
|
+
|
|
236
|
+
// Wait for profiler to actually start
|
|
237
|
+
await waitForCondition(async () => {
|
|
238
|
+
const state = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
239
|
+
return state.isProfilerRunning
|
|
240
|
+
}, 2000)
|
|
241
|
+
|
|
242
|
+
// Make requests spread over time to ensure continuous CPU activity during profiling
|
|
243
|
+
// Use shorter interval and add timeout to prevent hanging
|
|
244
|
+
let consecutiveFailures = 0
|
|
245
|
+
let intervalStopped = false
|
|
246
|
+
const requestInterval = setInterval(() => {
|
|
247
|
+
if (intervalStopped) return
|
|
248
|
+
|
|
249
|
+
request(`${url}/compute`, { headersTimeout: 30000, bodyTimeout: 30000 })
|
|
250
|
+
.then(() => {
|
|
251
|
+
consecutiveFailures = 0
|
|
252
|
+
})
|
|
253
|
+
.catch((err) => {
|
|
254
|
+
consecutiveFailures++
|
|
255
|
+
|
|
256
|
+
// If we get 3 consecutive connection failures, the service likely crashed
|
|
257
|
+
if (consecutiveFailures >= 3 && (err.message.includes('ECONNREFUSED') || err.message.includes('ECONNRESET'))) {
|
|
258
|
+
intervalStopped = true
|
|
259
|
+
clearInterval(requestInterval)
|
|
260
|
+
throw new Error(`Service crashed - ${consecutiveFailures} consecutive connection failures: ${err.message}`)
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
}, 300)
|
|
264
|
+
|
|
265
|
+
// Wait for profile to be captured
|
|
266
|
+
await waitForCondition(async () => {
|
|
267
|
+
const state = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
268
|
+
return state.hasProfile
|
|
269
|
+
}, 10000)
|
|
270
|
+
|
|
271
|
+
clearInterval(requestInterval)
|
|
272
|
+
|
|
273
|
+
// Get the profile - this should succeed with SourceMapper initialized
|
|
274
|
+
const encodedProfile = await app.sendCommandToApplication('service', 'getLastProfile')
|
|
275
|
+
const profile = decodeAndValidateProfile(encodedProfile, true)
|
|
276
|
+
|
|
277
|
+
// Verify sourcemaps are working by checking for .ts file extensions
|
|
278
|
+
verifyTypeScriptFilesInProfile(profile)
|
|
279
|
+
|
|
280
|
+
await app.sendCommandToApplication('service', 'stopProfiling')
|
|
281
|
+
})
|