Skip to content

Commit bdfc5a5

Browse files
authored
[Profiler] Grep for processes (elastic#216770)
Grep for running Node.js processes if specified.
1 parent df60cc9 commit bdfc5a5

File tree

7 files changed

+223
-37
lines changed

7 files changed

+223
-37
lines changed

x-pack/platform/packages/shared/kbn-profiler-cli/README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# @kbn/profiler-cli
22

3-
Profile Kibana while it's running, and open the CPU profile in Speedscope.
3+
Profile Kibana (or any other Node.js processes) while it's running, and open the CPU or Heap profile in Speedscope.
44

55
## Usage
66

@@ -18,6 +18,26 @@ Or with a timeout:
1818

1919
`node scripts/profile.js --timeout=10000`
2020

21+
### Heap profiling
22+
23+
If you want to collect a heap profile, simply add `--heap`:
24+
25+
`node scripts/profile.js --heap --timeout=10000`
26+
27+
### Selecting a process
28+
29+
By default, the profiler will look for a process running on 5603 or 5601 (in that order), where Kibana runs by default. But you can attach the profiler to any process. Add `--pid` to specify a specific process id:
30+
31+
`node scripts/profile.js --pid 14782`
32+
33+
Or, use `--grep` to list Node.js processes you can attach to:
34+
35+
`node scripts/profile.js --grep`
36+
37+
You can also already specify a filter:
38+
39+
`node scripts/profile.js --grep myProcess`
40+
2141
## Examples
2242

2343
### Commands

x-pack/platform/packages/shared/kbn-profiler-cli/index.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,27 @@
66
*/
77
import { run } from '@kbn/dev-cli-runner';
88
import { compact, once, uniq } from 'lodash';
9-
import { getKibanaProcessId } from './src/get_kibana_process_id';
9+
import { getProcessId } from './src/get_process_id';
1010
import { runCommand } from './src/run_command';
1111
import { runUntilSigInt } from './src/run_until_sigint';
1212
import { getProfiler } from './src/get_profiler';
1313
import { untilStdinCompletes } from './src/until_stdin_completes';
1414

15+
const NO_GREP = '__NO_GREP__';
16+
1517
export function cli() {
1618
run(
1719
async ({ flags, log, addCleanupTask }) => {
20+
// flags.grep can only be a string, and defaults to an empty string,
21+
// so we override the default with a const and check for that
22+
// to differentiate between ``, `--grep` and `--grep myString`
23+
const grep = flags.grep === NO_GREP ? false : flags.grep === '' ? true : String(flags.grep);
24+
1825
const pid = flags.pid
1926
? Number(flags.pid)
20-
: await getKibanaProcessId({
27+
: await getProcessId({
2128
ports: uniq(compact([Number(flags.port), 5603, 5601])),
29+
grep,
2230
});
2331

2432
const controller = new AbortController();
@@ -28,9 +36,11 @@ export function cli() {
2836
}, Number(flags.timeout));
2937
}
3038

39+
log.debug(`Sending SIGUSR1 to ${pid}`);
40+
3141
process.kill(pid, 'SIGUSR1');
3242

33-
const stop = once(await getProfiler({ log, type: flags.heap ? 'heap' : 'cpu' }));
43+
const stop = once(await getProfiler({ pid, log, type: flags.heap ? 'heap' : 'cpu' }));
3444

3545
addCleanupTask(() => {
3646
// exit-hook, which is used by addCleanupTask,
@@ -40,9 +50,18 @@ export function cli() {
4050
// process.exit a noop for a bit until the
4151
// profile has been collected and opened
4252
const exit = process.exit.bind(process);
53+
const kill = process.kill.bind(process);
4354

4455
// @ts-expect-error
4556
process.exit = () => {};
57+
process.kill = (pidToKill, signal) => {
58+
// inquirer sends a SIGINT kill signal to the process,
59+
// that we need to handle here
60+
if (pidToKill === process.pid && signal === 'SIGINT') {
61+
return true;
62+
}
63+
return kill(pidToKill, signal);
64+
};
4665

4766
stop()
4867
.then(() => {
@@ -86,7 +105,7 @@ export function cli() {
86105
},
87106
{
88107
flags: {
89-
string: ['port', 'pid', 't', 'timeout', 'c', 'connections', 'a', 'amount'],
108+
string: ['port', 'pid', 't', 'timeout', 'c', 'connections', 'a', 'amount', 'grep'],
90109
boolean: ['heap'],
91110
help: `
92111
Usage: node scripts/profiler.js <args> <command>
@@ -97,7 +116,11 @@ export function cli() {
97116
--c, --connections Number of commands that can be run in parallel.
98117
--a, --amount Amount of times the command should be run
99118
--heap Collect a heap snapshot
119+
--grep Grep through running Node.js processes
100120
`,
121+
default: {
122+
grep: NO_GREP,
123+
},
101124
allowUnexpected: false,
102125
},
103126
}

x-pack/platform/packages/shared/kbn-profiler-cli/src/get_kibana_process_id.ts

Lines changed: 0 additions & 29 deletions
This file was deleted.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import execa from 'execa';
9+
10+
export async function getNodeProcesses(
11+
grep?: string
12+
): Promise<Array<{ pid: number; command: string; ports: number[] }>> {
13+
const candidates = await execa
14+
.command(
15+
`ps -eo pid,command | grep -E '^[[:space:]0-9]+[[:space:]]*node[[:space:]]?' | grep -v grep ${
16+
grep ? `| grep "${grep}" ` : ''
17+
}`,
18+
{ shell: true, reject: false }
19+
)
20+
.then(({ stdout, exitCode }) => {
21+
if (exitCode !== 0) {
22+
return [];
23+
}
24+
25+
// example
26+
// 6915 /Users/dariogieselaar/.nvm/versions/node/v20.18.2/bin/node scripts/es snapshot
27+
const lines = stdout.split('\n');
28+
return lines.map((line) => {
29+
const [pid, ...command] = line.trim().split(' ');
30+
return {
31+
pid: Number(pid.trim()),
32+
command: command.join(' ').trim(),
33+
};
34+
});
35+
});
36+
37+
if (!candidates.length) {
38+
return [];
39+
}
40+
41+
const pids = candidates.map((candidate) => candidate.pid);
42+
const portsByPid: Record<string, number[]> = {};
43+
44+
await execa
45+
.command(`lsof -Pan -i -iTCP -sTCP:LISTEN -p ${pids.join(',')}`, { shell: true, reject: false })
46+
.then(({ stdout, exitCode }) => {
47+
// exitCode 1 is returned when some of the ports don't match
48+
if (exitCode !== 0 && exitCode !== 1) {
49+
return;
50+
}
51+
52+
const lines = stdout.split('\n').slice(1);
53+
54+
lines.forEach((line) => {
55+
const values = line.trim().split(/\s+/);
56+
const pid = values[1];
57+
const name = values.slice(8).join(' ');
58+
59+
if (!name) {
60+
return;
61+
}
62+
63+
const portMatch = name.match(/:(\d+)(?:\s|\()/);
64+
const port = portMatch ? Number(portMatch[1]) : undefined;
65+
if (port) {
66+
(portsByPid[pid] = portsByPid[pid] || []).push(port);
67+
}
68+
});
69+
});
70+
71+
return candidates.map(({ pid, command }) => {
72+
return {
73+
pid,
74+
command,
75+
ports: portsByPid[pid] ?? [],
76+
};
77+
});
78+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
import execa from 'execa';
8+
import inquirer from 'inquirer';
9+
import { getNodeProcesses } from './get_node_processes';
10+
11+
class ProcessNotFoundError extends Error {
12+
constructor() {
13+
super(`No Node.js processes found to attach to`);
14+
}
15+
}
16+
17+
async function getProcessIdAtPort(port: number) {
18+
return await execa
19+
.command(`lsof -ti :${port}`)
20+
.then(({ stdout }) => {
21+
return parseInt(stdout.trim().split('\n')[0], 10);
22+
})
23+
.catch((error) => {
24+
return undefined;
25+
});
26+
}
27+
28+
export async function getProcessId({
29+
ports,
30+
grep,
31+
}: {
32+
ports: number[];
33+
grep: boolean | string;
34+
}): Promise<number> {
35+
if (grep) {
36+
const candidates = await getNodeProcesses(typeof grep === 'boolean' ? '' : grep);
37+
38+
if (!candidates.length) {
39+
throw new ProcessNotFoundError();
40+
}
41+
42+
const { pid } = await inquirer.prompt({
43+
type: 'list',
44+
name: 'pid',
45+
message: `Select a Node.js process to attach to`,
46+
choices: candidates.map((candidate) => {
47+
return {
48+
name: `${candidate.command}${
49+
candidate.ports.length ? ` (Listening on ${candidate.ports.join(', ')})` : ``
50+
}`,
51+
value: candidate.pid,
52+
};
53+
}),
54+
});
55+
56+
return pid as number;
57+
}
58+
59+
for (const port of ports) {
60+
const pid = await getProcessIdAtPort(port);
61+
if (pid) {
62+
return pid;
63+
}
64+
}
65+
66+
throw new Error(`Kibana process id not found at ports ${ports.join(', ')}`);
67+
}

x-pack/platform/packages/shared/kbn-profiler-cli/src/get_profiler.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ import Os from 'os';
1010
import Path from 'path';
1111
import { ToolingLog } from '@kbn/tooling-log';
1212
import execa from 'execa';
13+
import getPort from 'get-port';
14+
import { getNodeProcesses } from './get_node_processes';
15+
16+
class InspectorSessionConflictError extends Error {
17+
constructor() {
18+
super(`An inspector session is already running in another process. Close the process first.`);
19+
}
20+
}
1321

1422
async function getHeapProfiler({ client, log }: { client: CDP.Client; log: ToolingLog }) {
1523
await client.HeapProfiler.enable();
@@ -52,11 +60,28 @@ async function getCpuProfiler({ client, log }: { client: CDP.Client; log: Toolin
5260
export async function getProfiler({
5361
log,
5462
type,
63+
pid,
5564
}: {
5665
log: ToolingLog;
5766
type: 'cpu' | 'heap';
67+
pid: number;
5868
}): Promise<() => Promise<void>> {
59-
log.debug(`Attaching to remote debugger at 9229`);
69+
const port = await getPort({
70+
host: '127.0.0.1',
71+
port: 9229,
72+
});
73+
74+
if (port !== 9229) {
75+
// Inspector is already running, see if it's attached to the selected process
76+
await getNodeProcesses()
77+
.then((processes) => processes.find((process) => process.pid === pid))
78+
.then((candidate) => {
79+
if (!candidate?.ports.includes(9229)) {
80+
throw new InspectorSessionConflictError();
81+
}
82+
});
83+
}
84+
6085
const client = await CDP({ port: 9229 });
6186

6287
log.info(`Attached to remote debugger at 9229`);

x-pack/platform/packages/shared/kbn-profiler-cli/src/run_command.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,10 @@ export async function runCommand({
5858
const limiter = pLimit(connections);
5959

6060
await Promise.allSettled(
61-
range(0, amount).map(async () => {
62-
await limiter(() => Promise.race([abortPromise, executeCommand()]));
61+
range(0, amount).map(async (index) => {
62+
await limiter(() => {
63+
return Promise.race([abortPromise, executeCommand()]);
64+
});
6365
})
6466
).then((results) => {
6567
const errors = results.flatMap((result) =>

0 commit comments

Comments
 (0)