@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 CHANGED
@@ -1,3 +1,5 @@
1
1
  import neostandard from 'neostandard'
2
2
 
3
- export default neostandard({})
3
+ export default neostandard({
4
+ ignores: ['test/fixtures/**/dist/**']
5
+ })
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) return
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.intervalMicros ??= 33333
119
- profiler.start(options)
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
- state.latestProfile = profiler.profile()
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
- export function startProfiling (options = {}) {
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
- state.latestProfile = profiler.profile()
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.13.1",
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
- "@platformatic/foundation": "3.13.1",
27
- "@platformatic/service": "3.13.1"
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,14 @@
1
+ {
2
+ "$schema": "https://schemas.platformatic.dev/@platformatic/service/2.0.0.json",
3
+ "server": {
4
+ "logger": {
5
+ "level": "warn"
6
+ }
7
+ },
8
+ "plugins": {
9
+ "paths": [{
10
+ "path": "dist/plugin.js",
11
+ "encapsulate": false
12
+ }]
13
+ }
14
+ }
@@ -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
+ })