@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 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.24.0-alpha0",
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/foundation": "3.24.0-alpha0",
30
- "@platformatic/service": "3.24.0-alpha0"
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
+ })