@platformatic/wattpm-pprof-capture 3.13.1 → 3.14.0
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/eslint.config.js +3 -1
- package/index.js +130 -9
- package/package.json +6 -3
- package/test/fixtures/minimal-sourcemap-worker.js +38 -0
- package/test/fixtures/sourcemap-test/platformatic.json +15 -0
- package/test/fixtures/sourcemap-test/service/platformatic.json +14 -0
- package/test/fixtures/sourcemap-test/service/plugin.ts +73 -0
- package/test/fixtures/sourcemap-test/service/tsconfig.json +17 -0
- package/test/minimal-sourcemap.test.js +49 -0
- package/test/sourcemap.test.js +206 -0
package/eslint.config.js
CHANGED
package/index.js
CHANGED
|
@@ -1,9 +1,63 @@
|
|
|
1
|
-
import { time, heap } from '@datadog/pprof'
|
|
1
|
+
import { time, heap, SourceMapper } from '@datadog/pprof'
|
|
2
2
|
import { performance } from 'node:perf_hooks'
|
|
3
|
+
import { workerData } from 'node:worker_threads'
|
|
3
4
|
import { NoProfileAvailableError, NotEnoughELUError, ProfilingAlreadyStartedError, ProfilingNotStartedError } from './lib/errors.js'
|
|
4
5
|
|
|
5
6
|
const kITC = Symbol.for('plt.runtime.itc')
|
|
6
7
|
|
|
8
|
+
// SourceMapper for resolving transpiled code locations back to original source
|
|
9
|
+
let sourceMapper = null
|
|
10
|
+
let sourceMapperInitialized = false
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Wrapper around SourceMapper that fixes Windows path normalization issues.
|
|
14
|
+
*
|
|
15
|
+
* On Windows, V8 profiler returns paths like `file:///D:/path/to/file.js`.
|
|
16
|
+
* The @datadog/pprof library removes `file://` leaving `/D:/path/to/file.js`,
|
|
17
|
+
* but SourceMapper stores paths as `D:\path\to\file.js`.
|
|
18
|
+
*
|
|
19
|
+
* This wrapper normalizes Windows paths to match the internal format.
|
|
20
|
+
*/
|
|
21
|
+
class SourceMapperWrapper {
|
|
22
|
+
constructor (innerMapper) {
|
|
23
|
+
this.innerMapper = innerMapper
|
|
24
|
+
this.debug = innerMapper.debug
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Normalize Windows-style paths from V8 profiler to match SourceMapper format.
|
|
29
|
+
* Handles paths like `/D:/path/to/file.js` -> `D:\path\to\file.js`
|
|
30
|
+
*/
|
|
31
|
+
normalizePath (filePath) {
|
|
32
|
+
if (process.platform !== 'win32') {
|
|
33
|
+
return filePath
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Handle paths like /D:/path/to/file -> D:\path\to\file
|
|
37
|
+
// This happens because pprof removes 'file://' from 'file:///D:/path/to/file'
|
|
38
|
+
if (filePath.startsWith('/') && filePath.length > 2 && filePath[2] === ':') {
|
|
39
|
+
// Remove leading slash and convert forward slashes to backslashes
|
|
40
|
+
return filePath.slice(1).replace(/\//g, '\\')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Also convert any forward slashes to backslashes on Windows
|
|
44
|
+
return filePath.replace(/\//g, '\\')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
hasMappingInfo (inputPath) {
|
|
48
|
+
const normalized = this.normalizePath(inputPath)
|
|
49
|
+
return this.innerMapper.hasMappingInfo(normalized)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
mappingInfo (location) {
|
|
53
|
+
const normalized = {
|
|
54
|
+
...location,
|
|
55
|
+
file: this.normalizePath(location.file)
|
|
56
|
+
}
|
|
57
|
+
return this.innerMapper.mappingInfo(normalized)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
7
61
|
// Track ELU globally (shared across all profiler types)
|
|
8
62
|
let lastELU = null
|
|
9
63
|
let previousELU = performance.eventLoopUtilization()
|
|
@@ -30,7 +84,8 @@ const profilingState = {
|
|
|
30
84
|
eluThreshold: null,
|
|
31
85
|
options: null,
|
|
32
86
|
profilerStarted: false,
|
|
33
|
-
clearProfileTimeout: null
|
|
87
|
+
clearProfileTimeout: null,
|
|
88
|
+
sourceMapsEnabled: false
|
|
34
89
|
},
|
|
35
90
|
heap: {
|
|
36
91
|
isCapturing: false,
|
|
@@ -40,7 +95,8 @@ const profilingState = {
|
|
|
40
95
|
eluThreshold: null,
|
|
41
96
|
options: null,
|
|
42
97
|
profilerStarted: false,
|
|
43
|
-
clearProfileTimeout: null
|
|
98
|
+
clearProfileTimeout: null,
|
|
99
|
+
sourceMapsEnabled: false
|
|
44
100
|
}
|
|
45
101
|
}
|
|
46
102
|
|
|
@@ -99,7 +155,9 @@ function unscheduleProfileRotation (state) {
|
|
|
99
155
|
}
|
|
100
156
|
|
|
101
157
|
function startProfiler (type, state, options) {
|
|
102
|
-
if (state.profilerStarted)
|
|
158
|
+
if (state.profilerStarted) {
|
|
159
|
+
return
|
|
160
|
+
}
|
|
103
161
|
|
|
104
162
|
const profiler = getProfiler(type)
|
|
105
163
|
|
|
@@ -115,8 +173,18 @@ function startProfiler (type, state, options) {
|
|
|
115
173
|
profiler.start(intervalBytes, stackDepth)
|
|
116
174
|
} else {
|
|
117
175
|
// CPU time profiler takes options object
|
|
118
|
-
options
|
|
119
|
-
|
|
176
|
+
const profilerOptions = { ...options }
|
|
177
|
+
profilerOptions.intervalMicros ??= 33333
|
|
178
|
+
|
|
179
|
+
// Enable line numbers to get file information in the profile
|
|
180
|
+
profilerOptions.lineNumbers = true
|
|
181
|
+
|
|
182
|
+
// Add sourceMapper if enabled and available for transpiled source resolution
|
|
183
|
+
if (state.sourceMapsEnabled && sourceMapper) {
|
|
184
|
+
profilerOptions.sourceMapper = sourceMapper
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
profiler.start(profilerOptions)
|
|
120
188
|
}
|
|
121
189
|
}
|
|
122
190
|
|
|
@@ -131,10 +199,12 @@ function stopProfiler (type, state) {
|
|
|
131
199
|
|
|
132
200
|
if (type === 'heap') {
|
|
133
201
|
// Get the profile before stopping
|
|
134
|
-
|
|
202
|
+
// Pass sourceMapper if enabled and available for transpiled source resolution
|
|
203
|
+
state.latestProfile = (state.sourceMapsEnabled && sourceMapper) ? profiler.profile(undefined, sourceMapper) : profiler.profile()
|
|
135
204
|
profiler.stop()
|
|
136
205
|
} else {
|
|
137
206
|
// CPU time profiler returns the profile when stopping
|
|
207
|
+
// sourceMapper was already passed to start(), so it's applied automatically
|
|
138
208
|
state.latestProfile = profiler.stop()
|
|
139
209
|
}
|
|
140
210
|
}
|
|
@@ -203,13 +273,62 @@ function startIfOverThreshold (type, state, wasRunning = state.profilerStarted)
|
|
|
203
273
|
}
|
|
204
274
|
}
|
|
205
275
|
|
|
206
|
-
|
|
276
|
+
async function initializeSourceMapper () {
|
|
277
|
+
if (sourceMapperInitialized) {
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
sourceMapperInitialized = true
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
// Get the application directory from workerData
|
|
285
|
+
const appPath = workerData?.applicationConfig?.path
|
|
286
|
+
if (!appPath) {
|
|
287
|
+
if (globalThis.platformatic?.logger) {
|
|
288
|
+
globalThis.platformatic.logger.debug('No application path available for sourcemap resolution')
|
|
289
|
+
}
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Create SourceMapper to search for .map files in the app directory
|
|
294
|
+
// Note: SourceMapper searches recursively for files matching /\.[cm]?js\.map$/
|
|
295
|
+
const debug = process.env.PLT_PPROF_SOURCEMAP_DEBUG === 'true'
|
|
296
|
+
const innerMapper = await SourceMapper.create([appPath], debug)
|
|
297
|
+
|
|
298
|
+
// Wrap the SourceMapper to fix Windows path normalization
|
|
299
|
+
sourceMapper = new SourceMapperWrapper(innerMapper)
|
|
300
|
+
|
|
301
|
+
if (globalThis.platformatic?.logger) {
|
|
302
|
+
const hasMappings = sourceMapper && typeof sourceMapper.hasMappingInfo === 'function'
|
|
303
|
+
globalThis.platformatic.logger.info(
|
|
304
|
+
{ appPath, hasSourceMapper: !!sourceMapper, hasMappingInfo: hasMappings },
|
|
305
|
+
'SourceMapper initialized for profiling'
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
} catch (err) {
|
|
309
|
+
if (globalThis.platformatic?.logger) {
|
|
310
|
+
globalThis.platformatic.logger.warn(
|
|
311
|
+
{ err: err.message, stack: err.stack },
|
|
312
|
+
'Failed to initialize SourceMapper'
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function startProfiling (options = {}) {
|
|
207
319
|
const type = options.type || 'cpu'
|
|
208
320
|
const state = profilingState[type]
|
|
209
321
|
|
|
210
322
|
if (state.isCapturing) {
|
|
211
323
|
throw new ProfilingAlreadyStartedError()
|
|
212
324
|
}
|
|
325
|
+
|
|
326
|
+
// Initialize source mapper if source maps are requested
|
|
327
|
+
state.sourceMapsEnabled = options.sourceMaps === true
|
|
328
|
+
if (state.sourceMapsEnabled) {
|
|
329
|
+
await initializeSourceMapper()
|
|
330
|
+
}
|
|
331
|
+
|
|
213
332
|
state.isCapturing = true
|
|
214
333
|
state.options = options
|
|
215
334
|
state.eluThreshold = options.eluThreshold
|
|
@@ -233,6 +352,7 @@ export function stopProfiling (options = {}) {
|
|
|
233
352
|
state.eluThreshold = null
|
|
234
353
|
state.durationMillis = null
|
|
235
354
|
state.options = null
|
|
355
|
+
state.sourceMapsEnabled = false
|
|
236
356
|
|
|
237
357
|
// Return the latest profile if available, otherwise return an empty profile
|
|
238
358
|
// (e.g., when profiler never started due to ELU threshold not being exceeded)
|
|
@@ -256,7 +376,8 @@ export function getLastProfile (options = {}) {
|
|
|
256
376
|
// For CPU profiler, use the cached profile if available
|
|
257
377
|
if (type === 'heap' && state.profilerStarted) {
|
|
258
378
|
const profiler = getProfiler(type)
|
|
259
|
-
|
|
379
|
+
// Get heap profile with sourceMapper if enabled and available
|
|
380
|
+
state.latestProfile = (state.sourceMapsEnabled && sourceMapper) ? profiler.profile(undefined, sourceMapper) : profiler.profile()
|
|
260
381
|
}
|
|
261
382
|
|
|
262
383
|
// Check if we have a profile
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@platformatic/wattpm-pprof-capture",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.14.0",
|
|
4
4
|
"description": "pprof profiling capture for wattpm",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -20,11 +20,14 @@
|
|
|
20
20
|
"undici": "^6.0.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
+
"@types/node": "^22.0.0",
|
|
23
24
|
"atomic-sleep": "^1.0.0",
|
|
24
25
|
"eslint": "9",
|
|
25
26
|
"neostandard": "^0.12.0",
|
|
26
|
-
"
|
|
27
|
-
"
|
|
27
|
+
"pprof-format": "^2.1.0",
|
|
28
|
+
"typescript": "^5.0.0",
|
|
29
|
+
"@platformatic/foundation": "3.14.0",
|
|
30
|
+
"@platformatic/service": "3.14.0"
|
|
28
31
|
},
|
|
29
32
|
"engines": {
|
|
30
33
|
"node": ">=22.19.0"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { parentPort } from 'node:worker_threads'
|
|
2
|
+
import { time, SourceMapper } from '@datadog/pprof'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
process.on('uncaughtException', (err) => {
|
|
6
|
+
parentPort.postMessage({ success: false, error: err.message })
|
|
7
|
+
process.exit(1)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
process.on('unhandledRejection', (reason) => {
|
|
11
|
+
parentPort.postMessage({ success: false, error: String(reason) })
|
|
12
|
+
process.exit(1)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
async function test () {
|
|
16
|
+
try {
|
|
17
|
+
const serviceDir = resolve(import.meta.dirname, 'sourcemap-test/service')
|
|
18
|
+
const sourceMapper = await SourceMapper.create([serviceDir], false)
|
|
19
|
+
|
|
20
|
+
time.start({
|
|
21
|
+
intervalMicros: 33333,
|
|
22
|
+
lineNumbers: true,
|
|
23
|
+
sourceMapper
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
27
|
+
|
|
28
|
+
time.stop()
|
|
29
|
+
|
|
30
|
+
parentPort.postMessage({ success: true })
|
|
31
|
+
process.exit(0)
|
|
32
|
+
} catch (err) {
|
|
33
|
+
parentPort.postMessage({ success: false, error: err.message })
|
|
34
|
+
process.exit(1)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test()
|
|
@@ -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,73 @@
|
|
|
1
|
+
import { 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
|
+
async function myTypeScriptFunction(value: number): Promise<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
|
+
for (let i = 0; i < 10; i++) {
|
|
12
|
+
// Allocate a large array (~800KB of numbers)
|
|
13
|
+
const largeArray = new Array(100000)
|
|
14
|
+
for (let j = 0; j < largeArray.length; j++) {
|
|
15
|
+
largeArray[j] = Math.sqrt(j * value)
|
|
16
|
+
result += largeArray[j]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Allocate objects with metadata
|
|
20
|
+
const metadata = {
|
|
21
|
+
index: i,
|
|
22
|
+
timestamp: Date.now(),
|
|
23
|
+
value,
|
|
24
|
+
description: 'Memory allocation for heap profiling test'.repeat(10)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
allocations.push({ data: largeArray, metadata })
|
|
28
|
+
|
|
29
|
+
// Small delay to allow heap profiler to sample
|
|
30
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Keep allocations alive until we're done computing
|
|
34
|
+
return result + allocations.length
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default async function (app: FastifyInstance) {
|
|
38
|
+
app.get('/', async () => {
|
|
39
|
+
return { message: 'Hello from TypeScript' }
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
app.get('/compute', async () => {
|
|
43
|
+
const result = await myTypeScriptFunction(42)
|
|
44
|
+
return { result }
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
app.get('/diagnostic', async () => {
|
|
48
|
+
const fs = await import('node:fs/promises')
|
|
49
|
+
const path = await import('node:path')
|
|
50
|
+
const url = await import('node:url')
|
|
51
|
+
const serviceDir = path.dirname(url.fileURLToPath(import.meta.url))
|
|
52
|
+
const pluginJsPath = path.join(serviceDir, 'plugin.js')
|
|
53
|
+
const pluginMapPath = path.join(serviceDir, 'plugin.js.map')
|
|
54
|
+
|
|
55
|
+
const diagnostics = {
|
|
56
|
+
serviceDir,
|
|
57
|
+
pluginJsExists: false,
|
|
58
|
+
pluginMapExists: false
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
await fs.access(pluginJsPath)
|
|
63
|
+
diagnostics.pluginJsExists = true
|
|
64
|
+
} catch {}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
await fs.access(pluginMapPath)
|
|
68
|
+
diagnostics.pluginMapExists = true
|
|
69
|
+
} catch {}
|
|
70
|
+
|
|
71
|
+
return diagnostics
|
|
72
|
+
})
|
|
73
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import { Worker } from 'node:worker_threads'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { exec } from 'node:child_process'
|
|
5
|
+
import { promisify } from 'node:util'
|
|
6
|
+
|
|
7
|
+
const execAsync = promisify(exec)
|
|
8
|
+
|
|
9
|
+
test.before(async () => {
|
|
10
|
+
const serviceDir = resolve(import.meta.dirname, 'fixtures/sourcemap-test/service')
|
|
11
|
+
try {
|
|
12
|
+
await execAsync('npx tsc', { cwd: serviceDir, timeout: 30000 })
|
|
13
|
+
} catch (err) {
|
|
14
|
+
throw new Error(`Failed to build TypeScript service: ${err.message}`)
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
// Test if SourceMapper works in a plain worker thread on Windows
|
|
19
|
+
test('minimal SourceMapper test in worker thread', async (t) => {
|
|
20
|
+
await new Promise((resolve, reject) => {
|
|
21
|
+
const worker = new Worker(new URL('./fixtures/minimal-sourcemap-worker.js', import.meta.url))
|
|
22
|
+
|
|
23
|
+
const timeout = setTimeout(() => {
|
|
24
|
+
worker.terminate()
|
|
25
|
+
reject(new Error('Worker timed out after 10 seconds'))
|
|
26
|
+
}, 10000)
|
|
27
|
+
|
|
28
|
+
worker.on('message', (msg) => {
|
|
29
|
+
clearTimeout(timeout)
|
|
30
|
+
if (msg.success) {
|
|
31
|
+
resolve()
|
|
32
|
+
} else {
|
|
33
|
+
reject(new Error(`Worker reported error: ${msg.error}`))
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
worker.on('error', (err) => {
|
|
38
|
+
clearTimeout(timeout)
|
|
39
|
+
reject(new Error(`Worker error: ${err.message}`))
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
worker.on('exit', (code) => {
|
|
43
|
+
clearTimeout(timeout)
|
|
44
|
+
if (code !== 0) {
|
|
45
|
+
reject(new Error(`Worker exited with code ${code}`))
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { exec } from 'node:child_process'
|
|
4
|
+
import { promisify } from 'node:util'
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import { request } from 'undici'
|
|
7
|
+
import { Profile } from 'pprof-format'
|
|
8
|
+
import { createRuntime } from '../../runtime/test/helpers.js'
|
|
9
|
+
|
|
10
|
+
const execAsync = promisify(exec)
|
|
11
|
+
|
|
12
|
+
// Helper to wait for a condition to be true
|
|
13
|
+
async function waitForCondition (checkFn, timeoutMs = 5000, pollMs = 100) {
|
|
14
|
+
const startTime = Date.now()
|
|
15
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
16
|
+
if (await checkFn()) {
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
19
|
+
await new Promise(resolve => setTimeout(resolve, pollMs))
|
|
20
|
+
}
|
|
21
|
+
throw new Error('Timeout waiting for condition')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Helper to verify TypeScript files are present in profile
|
|
25
|
+
function verifyTypeScriptFilesInProfile (profile) {
|
|
26
|
+
let foundTypeScriptFile = false
|
|
27
|
+
const allFilenames = new Set()
|
|
28
|
+
|
|
29
|
+
for (const func of profile.function) {
|
|
30
|
+
const id = Number(func.filename)
|
|
31
|
+
const filename = profile.stringTable.strings[id] || ''
|
|
32
|
+
if (filename) {
|
|
33
|
+
allFilenames.add(filename)
|
|
34
|
+
if (filename.endsWith('.ts')) {
|
|
35
|
+
foundTypeScriptFile = true
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const filenameList = Array.from(allFilenames).sort()
|
|
41
|
+
assert.ok(
|
|
42
|
+
foundTypeScriptFile,
|
|
43
|
+
`should contain .ts filenames. Found ${allFilenames.size} unique filenames:\n${filenameList.join('\n')}`
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Helper to decode and validate a profile
|
|
48
|
+
function decodeAndValidateProfile (encodedProfile, checkLocations) {
|
|
49
|
+
assert.ok(encodedProfile instanceof Uint8Array, 'should be Uint8Array')
|
|
50
|
+
assert.ok(encodedProfile.length > 0, 'should have content')
|
|
51
|
+
|
|
52
|
+
const profile = Profile.decode(encodedProfile)
|
|
53
|
+
assert.ok(profile.function, 'should have functions')
|
|
54
|
+
assert.ok(profile.function.length > 0, 'should have at least one function')
|
|
55
|
+
|
|
56
|
+
if (checkLocations) {
|
|
57
|
+
assert.ok(profile.location, 'should have locations')
|
|
58
|
+
assert.ok(profile.location.length > 0, 'should have at least one location')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return profile
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Build TypeScript service once before all tests
|
|
65
|
+
test.before(async () => {
|
|
66
|
+
const serviceDir = resolve(import.meta.dirname, 'fixtures/sourcemap-test/service')
|
|
67
|
+
|
|
68
|
+
// Build the TypeScript (dependencies are in parent package devDependencies)
|
|
69
|
+
try {
|
|
70
|
+
await execAsync('npx tsc', { cwd: serviceDir, timeout: 30000 })
|
|
71
|
+
} catch (err) {
|
|
72
|
+
throw new Error(`Failed to build TypeScript service: ${err.message}\nStdout: ${err.stdout}\nStderr: ${err.stderr}`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Verify build artifacts exist
|
|
76
|
+
const { access } = await import('node:fs/promises')
|
|
77
|
+
const pluginPath = resolve(serviceDir, 'dist/plugin.js')
|
|
78
|
+
const mapPath = resolve(serviceDir, 'dist/plugin.js.map')
|
|
79
|
+
try {
|
|
80
|
+
await access(pluginPath)
|
|
81
|
+
await access(mapPath)
|
|
82
|
+
} catch (err) {
|
|
83
|
+
throw new Error(`Build artifacts not found: ${err.message}. Plugin: ${pluginPath}, Map: ${mapPath}`)
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
async function createApp (t) {
|
|
88
|
+
const configFile = resolve(import.meta.dirname, 'fixtures/sourcemap-test/platformatic.json')
|
|
89
|
+
|
|
90
|
+
// Ensure tmp directory exists
|
|
91
|
+
const { mkdir } = await import('node:fs/promises')
|
|
92
|
+
const tmpDir = resolve(import.meta.dirname, '../../tmp')
|
|
93
|
+
await mkdir(tmpDir, { recursive: true })
|
|
94
|
+
|
|
95
|
+
const logsPath = resolve(tmpDir, `sourcemap-test-${Date.now()}.log`)
|
|
96
|
+
|
|
97
|
+
const app = await createRuntime(configFile, null, { logsPath })
|
|
98
|
+
|
|
99
|
+
t.after(async () => {
|
|
100
|
+
await app.close()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const url = await app.start()
|
|
104
|
+
|
|
105
|
+
// Wait for services and handlers to register
|
|
106
|
+
await new Promise(resolve => setTimeout(resolve, 200))
|
|
107
|
+
|
|
108
|
+
return { app, url }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
test('sourcemaps should be initialized and profiling should work with TypeScript', async t => {
|
|
112
|
+
const { app, url } = await createApp(t)
|
|
113
|
+
|
|
114
|
+
// Verify service is running
|
|
115
|
+
const res = await request(`${url}/`)
|
|
116
|
+
const json = await res.body.json()
|
|
117
|
+
assert.strictEqual(res.statusCode, 200)
|
|
118
|
+
assert.strictEqual(json.message, 'Hello from TypeScript')
|
|
119
|
+
|
|
120
|
+
// Verify sourcemap files exist in service directory
|
|
121
|
+
const diagRes = await request(`${url}/diagnostic`, { headersTimeout: 10000, bodyTimeout: 10000 })
|
|
122
|
+
const diag = await diagRes.body.json()
|
|
123
|
+
assert.strictEqual(diag.pluginMapExists, true, `Sourcemap file should exist at ${diag.serviceDir}/plugin.js.map`)
|
|
124
|
+
|
|
125
|
+
// Start profiling with sufficient duration to ensure we capture samples
|
|
126
|
+
// Enable source maps to resolve TypeScript locations
|
|
127
|
+
await app.sendCommandToApplication('service', 'startProfiling', { durationMillis: 5000, sourceMaps: true })
|
|
128
|
+
|
|
129
|
+
// Wait for profiler to actually start
|
|
130
|
+
await waitForCondition(async () => {
|
|
131
|
+
const state = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
132
|
+
return state.isProfilerRunning
|
|
133
|
+
}, 2000)
|
|
134
|
+
|
|
135
|
+
// Make requests spread over time to ensure continuous CPU activity during profiling
|
|
136
|
+
// Use shorter interval and add timeout to prevent hanging
|
|
137
|
+
let consecutiveFailures = 0
|
|
138
|
+
let intervalStopped = false
|
|
139
|
+
const requestInterval = setInterval(() => {
|
|
140
|
+
if (intervalStopped) return
|
|
141
|
+
|
|
142
|
+
request(`${url}/compute`, { headersTimeout: 30000, bodyTimeout: 30000 })
|
|
143
|
+
.then(() => {
|
|
144
|
+
consecutiveFailures = 0
|
|
145
|
+
})
|
|
146
|
+
.catch((err) => {
|
|
147
|
+
consecutiveFailures++
|
|
148
|
+
|
|
149
|
+
// If we get 3 consecutive connection failures, the service likely crashed
|
|
150
|
+
if (consecutiveFailures >= 3 && (err.message.includes('ECONNREFUSED') || err.message.includes('ECONNRESET'))) {
|
|
151
|
+
intervalStopped = true
|
|
152
|
+
clearInterval(requestInterval)
|
|
153
|
+
throw new Error(`Service crashed - ${consecutiveFailures} consecutive connection failures: ${err.message}`)
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
}, 300)
|
|
157
|
+
|
|
158
|
+
// Wait for profile to be captured
|
|
159
|
+
await waitForCondition(async () => {
|
|
160
|
+
const state = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
161
|
+
return state.hasProfile
|
|
162
|
+
}, 10000)
|
|
163
|
+
|
|
164
|
+
clearInterval(requestInterval)
|
|
165
|
+
|
|
166
|
+
// Get the profile - this should succeed with SourceMapper initialized
|
|
167
|
+
const encodedProfile = await app.sendCommandToApplication('service', 'getLastProfile')
|
|
168
|
+
const profile = decodeAndValidateProfile(encodedProfile, true)
|
|
169
|
+
|
|
170
|
+
// Verify sourcemaps are working by checking for .ts file extensions
|
|
171
|
+
verifyTypeScriptFilesInProfile(profile)
|
|
172
|
+
|
|
173
|
+
await app.sendCommandToApplication('service', 'stopProfiling')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('sourcemaps should work with heap profiling', async t => {
|
|
177
|
+
const { app, url } = await createApp(t)
|
|
178
|
+
|
|
179
|
+
// Start heap profiling with source maps enabled
|
|
180
|
+
await app.sendCommandToApplication('service', 'startProfiling', { type: 'heap', sourceMaps: true })
|
|
181
|
+
|
|
182
|
+
// Wait for heap profiler to actually start
|
|
183
|
+
await waitForCondition(async () => {
|
|
184
|
+
const state = await app.sendCommandToApplication('service', 'getProfilingState', { type: 'heap' })
|
|
185
|
+
return state.isProfilerRunning
|
|
186
|
+
}, 2000)
|
|
187
|
+
|
|
188
|
+
// Make many requests to ensure enough allocations for heap profiler to capture
|
|
189
|
+
// Heap profiler samples at 512KB intervals by default
|
|
190
|
+
// Each request allocates ~10MB of memory which should trigger multiple samples
|
|
191
|
+
for (let i = 0; i < 50; i++) {
|
|
192
|
+
await request(`${url}/compute`, { headersTimeout: 30000, bodyTimeout: 30000 })
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Wait a bit to ensure heap samples are taken
|
|
196
|
+
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
197
|
+
|
|
198
|
+
// Get the heap profile - this should succeed with SourceMapper initialized
|
|
199
|
+
const encodedProfile = await app.sendCommandToApplication('service', 'getLastProfile', { type: 'heap' })
|
|
200
|
+
const profile = decodeAndValidateProfile(encodedProfile)
|
|
201
|
+
|
|
202
|
+
// Verify sourcemaps work for heap profiling
|
|
203
|
+
verifyTypeScriptFilesInProfile(profile)
|
|
204
|
+
|
|
205
|
+
await app.sendCommandToApplication('service', 'stopProfiling', { type: 'heap' })
|
|
206
|
+
})
|