@platformatic/wattpm-pprof-capture 3.24.0-alpha0 → 3.25.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/index.js +8 -50
- package/lib/source-mapper-wrapper.js +65 -0
- package/package.json +3 -3
- package/test/source-mapper-wrapper.test.js +43 -0
- package/test/watt-pprof-capture.test.js +121 -0
package/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { time, heap, SourceMapper } from '@datadog/pprof'
|
|
|
2
2
|
import { performance } from 'node:perf_hooks'
|
|
3
3
|
import { workerData } from 'node:worker_threads'
|
|
4
4
|
import { NoProfileAvailableError, NotEnoughELUError, ProfilingAlreadyStartedError, ProfilingNotStartedError } from './lib/errors.js'
|
|
5
|
+
import { SourceMapperWrapper } from './lib/source-mapper-wrapper.js'
|
|
5
6
|
|
|
6
7
|
const kITC = Symbol.for('plt.runtime.itc')
|
|
7
8
|
|
|
@@ -9,55 +10,6 @@ const kITC = Symbol.for('plt.runtime.itc')
|
|
|
9
10
|
let sourceMapper = null
|
|
10
11
|
let sourceMapperInitialized = false
|
|
11
12
|
|
|
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
|
-
|
|
61
13
|
// Track ELU globally (shared across all profiler types)
|
|
62
14
|
let lastELU = null
|
|
63
15
|
let previousELU = performance.eventLoopUtilization()
|
|
@@ -79,6 +31,7 @@ const profilingState = {
|
|
|
79
31
|
cpu: {
|
|
80
32
|
isCapturing: false,
|
|
81
33
|
latestProfile: null,
|
|
34
|
+
latestProfileTimestamp: null,
|
|
82
35
|
captureInterval: null,
|
|
83
36
|
durationMillis: null,
|
|
84
37
|
eluThreshold: null,
|
|
@@ -90,6 +43,7 @@ const profilingState = {
|
|
|
90
43
|
heap: {
|
|
91
44
|
isCapturing: false,
|
|
92
45
|
latestProfile: null,
|
|
46
|
+
latestProfileTimestamp: null,
|
|
93
47
|
captureInterval: null,
|
|
94
48
|
durationMillis: null,
|
|
95
49
|
eluThreshold: null,
|
|
@@ -124,6 +78,7 @@ function scheduleLastProfileCleanup (state) {
|
|
|
124
78
|
if (state.options?.durationMillis) {
|
|
125
79
|
state.clearProfileTimeout = setTimeout(() => {
|
|
126
80
|
state.latestProfile = undefined
|
|
81
|
+
state.latestProfileTimestamp = null
|
|
127
82
|
state.clearProfileTimeout = null
|
|
128
83
|
}, state.options.durationMillis)
|
|
129
84
|
state.clearProfileTimeout.unref()
|
|
@@ -196,6 +151,7 @@ function stopProfiler (type, state) {
|
|
|
196
151
|
scheduleLastProfileCleanup(state)
|
|
197
152
|
unscheduleProfileRotation(state)
|
|
198
153
|
state.profilerStarted = false
|
|
154
|
+
state.latestProfileTimestamp = Date.now()
|
|
199
155
|
|
|
200
156
|
if (type === 'heap') {
|
|
201
157
|
// Get the profile before stopping
|
|
@@ -378,6 +334,7 @@ export function getLastProfile (options = {}) {
|
|
|
378
334
|
const profiler = getProfiler(type)
|
|
379
335
|
// Get heap profile with sourceMapper if enabled and available
|
|
380
336
|
state.latestProfile = (state.sourceMapsEnabled && sourceMapper) ? profiler.profile(undefined, sourceMapper) : profiler.profile()
|
|
337
|
+
state.latestProfileTimestamp = Date.now()
|
|
381
338
|
}
|
|
382
339
|
|
|
383
340
|
// Check if we have a profile
|
|
@@ -405,7 +362,8 @@ export function getProfilingState (options = {}) {
|
|
|
405
362
|
isProfilerRunning: state.profilerStarted,
|
|
406
363
|
isPausedBelowThreshold: state.eluThreshold != null && !state.profilerStarted,
|
|
407
364
|
lastELU: lastELU?.utilization,
|
|
408
|
-
eluThreshold: state.eluThreshold
|
|
365
|
+
eluThreshold: state.eluThreshold,
|
|
366
|
+
latestProfileTimestamp: state.latestProfileTimestamp
|
|
409
367
|
}
|
|
410
368
|
}
|
|
411
369
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrapper around SourceMapper that fixes Windows path normalization issues.
|
|
3
|
+
*
|
|
4
|
+
* On Windows, V8 profiler returns paths like `file:///D:/path/to/file.js`.
|
|
5
|
+
* The @datadog/pprof library removes `file://` leaving `/D:/path/to/file.js`,
|
|
6
|
+
* but SourceMapper stores paths as `D:\path\to\file.js`.
|
|
7
|
+
*
|
|
8
|
+
* This wrapper normalizes Windows paths to match the internal format.
|
|
9
|
+
*/
|
|
10
|
+
export class SourceMapperWrapper {
|
|
11
|
+
constructor (innerMapper) {
|
|
12
|
+
this.innerMapper = innerMapper
|
|
13
|
+
this.debug = innerMapper.debug
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Normalize Windows-style paths from V8 profiler to match SourceMapper format.
|
|
18
|
+
* Handles paths like `/D:/path/to/file.js` -> `D:\path\to\file.js`
|
|
19
|
+
*/
|
|
20
|
+
normalizePath (filePath) {
|
|
21
|
+
if (process.platform !== 'win32') {
|
|
22
|
+
return filePath
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Handle paths like /D:/path/to/file -> D:\path\to\file
|
|
26
|
+
// This happens because pprof removes 'file://' from 'file:///D:/path/to/file'
|
|
27
|
+
if (filePath.startsWith('/') && filePath.length > 2 && filePath[2] === ':') {
|
|
28
|
+
// Remove leading slash and convert forward slashes to backslashes
|
|
29
|
+
return filePath.slice(1).replace(/\//g, '\\')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Also convert any forward slashes to backslashes on Windows
|
|
33
|
+
return filePath.replace(/\//g, '\\')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
hasMappingInfo (inputPath) {
|
|
37
|
+
const normalized = this.normalizePath(inputPath)
|
|
38
|
+
return this.innerMapper.hasMappingInfo(normalized)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
mappingInfo (location) {
|
|
42
|
+
const normalized = {
|
|
43
|
+
...location,
|
|
44
|
+
file: this.normalizePath(location.file)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const protocols = ['webpack:']
|
|
48
|
+
const mappedInfo = this.innerMapper.mappingInfo(normalized)
|
|
49
|
+
// The @datadog/pprof SourceMapper uses path.resolve() which treats webpack: URLs
|
|
50
|
+
// as relative paths, creating malformed paths like:
|
|
51
|
+
// /path/to/.next/server/app/api/heavy/webpack:/next/src/app/api/heavy/route.js
|
|
52
|
+
// We need to extract just the webpack: URL part
|
|
53
|
+
|
|
54
|
+
for (const protocol of protocols) {
|
|
55
|
+
if (!mappedInfo.file) continue
|
|
56
|
+
|
|
57
|
+
const webpackIndex = mappedInfo.file.indexOf(protocol)
|
|
58
|
+
if (webpackIndex > 0) {
|
|
59
|
+
// Extract just the webpack: URL
|
|
60
|
+
mappedInfo.file = mappedInfo.file.substring(webpackIndex)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return mappedInfo
|
|
64
|
+
}
|
|
65
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@platformatic/wattpm-pprof-capture",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.25.0",
|
|
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/
|
|
30
|
-
"@platformatic/
|
|
29
|
+
"@platformatic/service": "3.25.0",
|
|
30
|
+
"@platformatic/foundation": "3.25.0"
|
|
31
31
|
},
|
|
32
32
|
"engines": {
|
|
33
33
|
"node": ">=22.19.0"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import { deepStrictEqual } from 'node:assert'
|
|
3
|
+
import { SourceMapperWrapper } from '../lib/source-mapper-wrapper.js'
|
|
4
|
+
|
|
5
|
+
class MockSourceMapper {
|
|
6
|
+
constructor (mapper) {
|
|
7
|
+
this.mapper = mapper
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
mappingInfo (location) {
|
|
11
|
+
return this.mapper(location)
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test('should correctly map webpack paths', async () => {
|
|
16
|
+
const appPath = '/Users/ivan-tymoshenko/projects/platformatic/leads-demo/web/next'
|
|
17
|
+
const info = {
|
|
18
|
+
file: `${appPath}/.next/server/app/api/heavy/route.js`,
|
|
19
|
+
line: 1,
|
|
20
|
+
column: 42,
|
|
21
|
+
name: 'a'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const mapper = () => {
|
|
25
|
+
return {
|
|
26
|
+
file: `${appPath}/.next/server/app/api/heavy/webpack:/next/src/app/api/heavy/route.js`,
|
|
27
|
+
name: 'fibonacci',
|
|
28
|
+
line: 6,
|
|
29
|
+
column: 12
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const innerMapper = new MockSourceMapper(mapper)
|
|
34
|
+
const sourceMapper = new SourceMapperWrapper(innerMapper)
|
|
35
|
+
|
|
36
|
+
const mappedInfo = sourceMapper.mappingInfo(info)
|
|
37
|
+
deepStrictEqual(mappedInfo, {
|
|
38
|
+
file: 'webpack:/next/src/app/api/heavy/route.js',
|
|
39
|
+
name: 'fibonacci',
|
|
40
|
+
line: 6,
|
|
41
|
+
column: 12
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -647,3 +647,124 @@ test('profiling with eluThreshold should continue rotating while above threshold
|
|
|
647
647
|
await request(`${url}/cpu-intensive/stop`, { method: 'POST' })
|
|
648
648
|
await app.sendCommandToApplication('service', 'stopProfiling')
|
|
649
649
|
})
|
|
650
|
+
|
|
651
|
+
test('latestProfileTimestamp should be set after profile rotation', async t => {
|
|
652
|
+
const { app } = await createApp(t)
|
|
653
|
+
|
|
654
|
+
// Check initial state - timestamp should be null
|
|
655
|
+
const initialState = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
656
|
+
assert.strictEqual(initialState.latestProfileTimestamp, null, 'Timestamp should be null before profiling')
|
|
657
|
+
|
|
658
|
+
// Start profiling with rotation
|
|
659
|
+
await app.sendCommandToApplication('service', 'startProfiling', { durationMillis: 200 })
|
|
660
|
+
|
|
661
|
+
// Wait for first rotation
|
|
662
|
+
await new Promise(resolve => setTimeout(resolve, 250))
|
|
663
|
+
|
|
664
|
+
// Get state and verify timestamp is set
|
|
665
|
+
const stateAfterRotation = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
666
|
+
assert.ok(stateAfterRotation.latestProfileTimestamp != null, 'Timestamp should be set after rotation')
|
|
667
|
+
assert.ok(typeof stateAfterRotation.latestProfileTimestamp === 'number', 'Timestamp should be a number')
|
|
668
|
+
assert.ok(stateAfterRotation.latestProfileTimestamp <= Date.now(), 'Timestamp should not be in the future')
|
|
669
|
+
assert.ok(stateAfterRotation.latestProfileTimestamp > Date.now() - 5000, 'Timestamp should be recent')
|
|
670
|
+
|
|
671
|
+
await app.sendCommandToApplication('service', 'stopProfiling')
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
test('latestProfileTimestamp should be set after stopProfiling', async t => {
|
|
675
|
+
const { app } = await createApp(t)
|
|
676
|
+
|
|
677
|
+
// Start profiling without rotation
|
|
678
|
+
await app.sendCommandToApplication('service', 'startProfiling', {})
|
|
679
|
+
|
|
680
|
+
// Get state before stop - timestamp should be null (no rotation occurred)
|
|
681
|
+
const stateBeforeStop = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
682
|
+
assert.strictEqual(stateBeforeStop.latestProfileTimestamp, null, 'Timestamp should be null before stop')
|
|
683
|
+
|
|
684
|
+
// Stop profiling
|
|
685
|
+
const beforeStopTime = Date.now()
|
|
686
|
+
await app.sendCommandToApplication('service', 'stopProfiling')
|
|
687
|
+
const afterStopTime = Date.now()
|
|
688
|
+
|
|
689
|
+
// Get state after stop - timestamp should be set
|
|
690
|
+
const stateAfterStop = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
691
|
+
assert.ok(stateAfterStop.latestProfileTimestamp != null, 'Timestamp should be set after stopProfiling')
|
|
692
|
+
assert.ok(stateAfterStop.latestProfileTimestamp >= beforeStopTime, 'Timestamp should be >= time before stop')
|
|
693
|
+
assert.ok(stateAfterStop.latestProfileTimestamp <= afterStopTime, 'Timestamp should be <= time after stop')
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
test('latestProfileTimestamp should be cleared after profile cleanup timeout', async t => {
|
|
697
|
+
const { app } = await createApp(t)
|
|
698
|
+
|
|
699
|
+
// Start profiling with short rotation interval
|
|
700
|
+
await app.sendCommandToApplication('service', 'startProfiling', { durationMillis: 200 })
|
|
701
|
+
|
|
702
|
+
// Wait for first rotation
|
|
703
|
+
await new Promise(resolve => setTimeout(resolve, 250))
|
|
704
|
+
|
|
705
|
+
// Verify timestamp is set
|
|
706
|
+
const stateWithProfile = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
707
|
+
assert.ok(stateWithProfile.latestProfileTimestamp != null, 'Timestamp should be set after rotation')
|
|
708
|
+
assert.ok(stateWithProfile.hasProfile, 'Should have profile')
|
|
709
|
+
|
|
710
|
+
// Stop profiling - this schedules cleanup after durationMillis (200ms)
|
|
711
|
+
await app.sendCommandToApplication('service', 'stopProfiling')
|
|
712
|
+
|
|
713
|
+
// Wait for cleanup timeout (durationMillis after stop)
|
|
714
|
+
await new Promise(resolve => setTimeout(resolve, 300))
|
|
715
|
+
|
|
716
|
+
// Verify timestamp is cleared after cleanup
|
|
717
|
+
const stateAfterCleanup = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
718
|
+
assert.strictEqual(stateAfterCleanup.latestProfileTimestamp, null, 'Timestamp should be cleared after cleanup')
|
|
719
|
+
assert.ok(!stateAfterCleanup.hasProfile, 'Profile should be cleared')
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
test('latestProfileTimestamp should be set for heap profiling', async t => {
|
|
723
|
+
const { app } = await createApp(t)
|
|
724
|
+
|
|
725
|
+
// Check initial state for heap - timestamp should be null
|
|
726
|
+
const initialState = await app.sendCommandToApplication('service', 'getProfilingState', { type: 'heap' })
|
|
727
|
+
assert.strictEqual(initialState.latestProfileTimestamp, null, 'Heap timestamp should be null before profiling')
|
|
728
|
+
|
|
729
|
+
// Start heap profiling
|
|
730
|
+
await app.sendCommandToApplication('service', 'startProfiling', { type: 'heap' })
|
|
731
|
+
|
|
732
|
+
// Wait a bit
|
|
733
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
734
|
+
|
|
735
|
+
// Get last profile (for heap, this captures a snapshot)
|
|
736
|
+
const beforeGetProfile = Date.now()
|
|
737
|
+
await app.sendCommandToApplication('service', 'getLastProfile', { type: 'heap' })
|
|
738
|
+
const afterGetProfile = Date.now()
|
|
739
|
+
|
|
740
|
+
// Verify timestamp is set after getLastProfile for heap
|
|
741
|
+
const stateAfterGet = await app.sendCommandToApplication('service', 'getProfilingState', { type: 'heap' })
|
|
742
|
+
assert.ok(stateAfterGet.latestProfileTimestamp != null, 'Heap timestamp should be set after getLastProfile')
|
|
743
|
+
assert.ok(stateAfterGet.latestProfileTimestamp >= beforeGetProfile, 'Timestamp should be >= time before get')
|
|
744
|
+
assert.ok(stateAfterGet.latestProfileTimestamp <= afterGetProfile, 'Timestamp should be <= time after get')
|
|
745
|
+
|
|
746
|
+
await app.sendCommandToApplication('service', 'stopProfiling', { type: 'heap' })
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
test('latestProfileTimestamp should update with each rotation', async t => {
|
|
750
|
+
const { app } = await createApp(t)
|
|
751
|
+
|
|
752
|
+
// Start profiling with short rotation interval
|
|
753
|
+
await app.sendCommandToApplication('service', 'startProfiling', { durationMillis: 200 })
|
|
754
|
+
|
|
755
|
+
// Wait for first rotation
|
|
756
|
+
await new Promise(resolve => setTimeout(resolve, 250))
|
|
757
|
+
const stateAfterFirst = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
758
|
+
const firstTimestamp = stateAfterFirst.latestProfileTimestamp
|
|
759
|
+
assert.ok(firstTimestamp != null, 'Timestamp should be set after first rotation')
|
|
760
|
+
|
|
761
|
+
// Wait for second rotation
|
|
762
|
+
await new Promise(resolve => setTimeout(resolve, 250))
|
|
763
|
+
const stateAfterSecond = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
764
|
+
const secondTimestamp = stateAfterSecond.latestProfileTimestamp
|
|
765
|
+
|
|
766
|
+
// Second timestamp should be greater than first
|
|
767
|
+
assert.ok(secondTimestamp > firstTimestamp, 'Timestamp should update with each rotation')
|
|
768
|
+
|
|
769
|
+
await app.sendCommandToApplication('service', 'stopProfiling')
|
|
770
|
+
})
|