ci_build_metrics.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. #
  2. # Copyright (c) Contributors to the Open 3D Engine Project.
  3. # For complete copyright and license terms please see the LICENSE at the root of this distribution.
  4. #
  5. # SPDX-License-Identifier: Apache-2.0 OR MIT
  6. #
  7. #
  8. import argparse
  9. import datetime
  10. import json
  11. import math
  12. import os
  13. import platform
  14. import psutil
  15. import shutil
  16. import stat
  17. import subprocess
  18. import sys
  19. import time
  20. import ci_build
  21. from pathlib import Path
  22. import submit_metrics
  23. if platform.system() == 'Windows':
  24. EXE_EXTENSION = '.exe'
  25. else:
  26. EXE_EXTENSION = ''
  27. DATE_FORMAT = "%Y-%m-%dT%H:%M:%S"
  28. METRICS_TEST_MODE = os.environ.get('METRICS_TEST_MODE')
  29. def parse_args():
  30. cur_dir = os.path.dirname(os.path.abspath(__file__))
  31. parser = argparse.ArgumentParser()
  32. parser.add_argument('-p', '--platform', dest="platform", help="Platform to gather metrics for")
  33. parser.add_argument('-r', '--repository', dest="repository", help="Repository to gather metrics for")
  34. parser.add_argument('-a', '--jobname', dest="jobname", default="unknown", help="Name/tag of the job in the CI system (used to track where the report comes from, constant through multiple runs)")
  35. parser.add_argument('-u', '--jobnumber', dest="jobnumber", default=-1, help="Number of run in the CI system (used to track where the report comes from, variable through runs)")
  36. parser.add_argument('-o', '--jobnode', dest="jobnode", default="unknown", help="Build node name (used to track where the build happened in CI systems where the same jobs run in different hosts)")
  37. parser.add_argument('-l', '--changelist', dest="changelist", default=-1, help="Last changelist in this workspace")
  38. parser.add_argument('-c', '--config', dest="build_config_filename", default="build_config.json",
  39. help="JSON filename in Platform/<platform> that defines build configurations for the platform")
  40. args = parser.parse_args()
  41. # Input validation
  42. if args.platform is None:
  43. print('[ci_build_metrics] No platform specified')
  44. sys.exit(-1)
  45. return args
  46. def shutdown_processes():
  47. process_list = {f'AssetBuilder{EXE_EXTENSION}', f'AssetProcessor{EXE_EXTENSION}', f'RC{EXE_EXTENSION}', f'Editor{EXE_EXTENSION}'}
  48. for process in psutil.process_iter():
  49. try:
  50. if process.name() in process_list:
  51. process.kill()
  52. except Exception as e:
  53. print(f'Exception while trying to kill process: {e}')
  54. def on_rmtree_error(func, path, exc_info):
  55. if os.path.exists(path):
  56. try:
  57. os.chmod(path, stat.S_IWRITE)
  58. os.unlink(path)
  59. except Exception as e:
  60. print(f'Exception while trying to remove {path}: {e}')
  61. def clean_folder(folder):
  62. if os.path.exists(folder):
  63. print(f'[ci_build_metrics] Cleaning {folder}...', flush=True)
  64. if not METRICS_TEST_MODE:
  65. shutil.rmtree(folder, onerror=on_rmtree_error)
  66. print(f'[ci_build_metrics] Cleaned {folder}', flush=True)
  67. def compute_folder_size(folder):
  68. total = 0
  69. if os.path.exists(folder):
  70. folder_path = Path(folder)
  71. print(f'[ci_build_metrics] Computing size of {folder_path}...', flush=True)
  72. if not METRICS_TEST_MODE:
  73. total += sum(f.stat().st_size for f in folder_path.glob('**/*') if f.is_file())
  74. print(f'[ci_build_metrics] Computed size of {folder_path}', flush=True)
  75. return total
  76. def build(metrics, folders_of_interest, build_config_filename, platform, build_type, output_directory, time_delta = 0):
  77. build_start = time.time()
  78. if not METRICS_TEST_MODE:
  79. metrics['result'] = ci_build.build(build_config_filename, platform, build_type)
  80. else:
  81. metrics['result'] = -1 # mark as failure so the data is not used in elastic
  82. build_end = time.time()
  83. # truncate the duration (expressed in seconds), we dont need more precision
  84. metrics['duration'] = math.trunc(build_end - build_start - time_delta)
  85. metrics['output_sizes'] = []
  86. output_sizes = metrics['output_sizes']
  87. for folder in folders_of_interest:
  88. output_size = dict()
  89. if folder != output_directory:
  90. output_size['folder'] = folder
  91. else:
  92. output_size['folder'] = 'OUTPUT_DIRECTORY' # make it homogenous to facilitate search
  93. output_size['output_size'] = compute_folder_size(os.path.join(engine_dir, folder))
  94. output_sizes.append(output_size)
  95. def gather_build_metrics(current_dir, build_config_filename, platform):
  96. config_dir = os.path.abspath(os.path.join(current_dir, 'Platform', platform))
  97. build_config_abspath = os.path.join(config_dir, build_config_filename)
  98. if not os.path.exists(build_config_abspath):
  99. cwd_dir = os.path.abspath(os.path.join(current_dir, '../..')) # engine's root
  100. config_dir = os.path.abspath(os.path.join(cwd_dir, 'restricted', platform, os.path.relpath(current_dir, cwd_dir)))
  101. build_config_abspath = os.path.join(config_dir, build_config_filename)
  102. if not os.path.exists(build_config_abspath):
  103. print(f'[ci_build_metrics] File: {build_config_abspath} not found', flush=True)
  104. sys.exit(-1)
  105. with open(build_config_abspath) as f:
  106. build_config_json = json.load(f)
  107. all_builds_metrics = []
  108. for build_type in build_config_json:
  109. build_config = build_config_json[build_type]
  110. if not 'weekly-build-metrics' in build_config['TAGS']:
  111. # skip build configs that are not tagged with 'weekly-build-metrics'
  112. continue
  113. print(f'[ci_build_metrics] Starting {build_type}', flush=True)
  114. metrics = dict()
  115. metrics['build_type'] = build_type
  116. build_parameters = build_config['PARAMETERS']
  117. if not build_parameters:
  118. metrics['result'] = -1
  119. reason = f'PARAMETERS entry {build_type} in {build_config_abspath} is missing.'
  120. metrics['reason'] = reason
  121. print(f'[ci_build_metrics] {reason}', flush=True)
  122. continue
  123. # Clean the build output
  124. output_directory = build_parameters['OUTPUT_DIRECTORY'] if 'OUTPUT_DIRECTORY' in build_parameters else None
  125. if not output_directory:
  126. metrics['result'] = -1
  127. reason = f'OUTPUT_DIRECTORY entry in {build_config_abspath} is missing.'
  128. metrics['reason'] = reason
  129. print(f'[ci_build_metrics] {reason}', flush=True)
  130. continue
  131. folders_of_interest = [output_directory]
  132. # Clean the AP output
  133. cmake_ly_projects = build_parameters['CMAKE_LY_PROJECTS'] if 'CMAKE_LY_PROJECTS' in build_parameters else None
  134. if cmake_ly_projects:
  135. projects = cmake_ly_projects.split(';')
  136. for project in projects:
  137. folders_of_interest.append(os.path.join(project, 'user', 'AssetProcessorTemp'))
  138. folders_of_interest.append(os.path.join(project, 'Cache'))
  139. metrics['build_metrics'] = []
  140. build_metrics = metrics['build_metrics']
  141. # Do the clean build
  142. shutdown_processes()
  143. for folder in folders_of_interest:
  144. clean_folder(os.path.join(engine_dir, folder))
  145. build_metric_clean = dict()
  146. build_metric_clean['build_metric'] = 'clean'
  147. build(build_metric_clean, folders_of_interest, build_config_filename, platform, build_type, output_directory)
  148. build_metrics.append(build_metric_clean)
  149. # Do the incremental "zero" build
  150. build_metric_zero = dict()
  151. build_metric_zero['build_metric'] = 'zero'
  152. build(build_metric_zero, folders_of_interest, build_config_filename, platform, build_type, output_directory)
  153. build_metrics.append(build_metric_zero)
  154. # Do a reconfigure
  155. # To measure a reconfigure, we will delete the "ci_last_configure_cmd.txt" file from the output and trigger a
  156. # zero build, then we will substract the time from the zero_build above
  157. last_configure_file = os.path.join(output_directory, 'ci_last_configure_cmd.txt')
  158. if os.path.exists(last_configure_file):
  159. os.remove(last_configure_file)
  160. build_metric_generation = dict()
  161. build_metric_generation['build_metric'] = 'generation'
  162. build(build_metric_generation, folders_of_interest, build_config_filename, platform, build_type, output_directory, build_metric_zero['duration'])
  163. build_metrics.append(build_metric_generation)
  164. # Clean the otuput before ending to reduce the size of these workspaces
  165. shutdown_processes()
  166. for folder in folders_of_interest:
  167. clean_folder(os.path.join(engine_dir, folder))
  168. metrics['result'] = 0
  169. metrics['reason'] = 'OK'
  170. all_builds_metrics.append(metrics)
  171. return all_builds_metrics
  172. def prepare_metrics(args, build_metrics):
  173. return {
  174. 'changelist': args.changelist,
  175. 'job': {'name': args.jobname, 'number': args.jobnumber, 'node': args.jobnode},
  176. 'platform': args.platform,
  177. 'repository': args.repository,
  178. 'build_types': build_metrics,
  179. 'timestamp': timestamp.strftime("%Y-%m-%dT%H:%M:%S")
  180. }
  181. def upload_to_s3(upload_script_path, base_dir, bucket, key_prefix):
  182. try:
  183. subprocess.run([sys.executable, upload_script_path,
  184. '--base_dir', base_dir,
  185. '--file_regex', '.*',
  186. '--bucket', bucket,
  187. '--key_prefix', key_prefix],
  188. check=True)
  189. except subprocess.CalledProcessError as err:
  190. print(f'[ci_build_metrics] {upload_script_path} failed with error {err}')
  191. sys.exit(1)
  192. def submit_report_document(report_file):
  193. print(f'[ci_build_metrics] Submitting {report_file}')
  194. with open(report_file) as json_file:
  195. report_json = json.load(json_file)
  196. ret = True
  197. for build_type in report_json['build_types']:
  198. for build_metric in build_type['build_metrics']:
  199. newjson = {
  200. 'timestamp': report_json['timestamp'],
  201. 'changelist': report_json['changelist'],
  202. 'job': report_json['job'],
  203. 'platform': report_json['platform'],
  204. 'repository': report_json['repository'],
  205. 'type': build_type['build_type'],
  206. 'result': int(build_type['result']) or int(build_metric['result']),
  207. 'reason': build_type['reason'],
  208. 'metric': build_metric['build_metric'],
  209. 'duration': build_metric['duration'],
  210. 'output_sizes': build_metric['output_sizes']
  211. }
  212. index = "pappeste.build_metrics." + datetime.datetime.strptime(report_json['timestamp'], DATE_FORMAT).strftime("%Y.%m")
  213. ret &= submit_metrics.submit(index, newjson)
  214. if ret:
  215. print(f'[ci_build_metrics] {report_file} submitted')
  216. else:
  217. print(f'[ci_build_metrics] {report_file} failed to submit')
  218. return ret
  219. if __name__ == "__main__":
  220. args = parse_args()
  221. print(f"[ci_build_metrics] Generating build metrics for:"
  222. f"\n\tPlatform: {args.platform}"
  223. f"\n\tRepository: {args.repository}"
  224. f"\n\tJob Name: {args.jobname}"
  225. f"\n\tJob Number: {args.jobnumber}"
  226. f"\n\tJob Node: {args.jobnode}"
  227. f"\n\tChangelist: {args.changelist}")
  228. # Read build_config
  229. current_dir = os.path.dirname(os.path.abspath(__file__))
  230. engine_dir = os.path.abspath(os.path.join(current_dir, '../..')) # engine's root
  231. timestamp = datetime.datetime.now()
  232. build_metrics = gather_build_metrics(current_dir, args.build_config_filename, args.platform)
  233. metrics = prepare_metrics(args, build_metrics)
  234. # Temporarly just printing the metrics until we get an API to uplaod it to CloudWatch
  235. # SPEC-1810 will then upload these metrics
  236. print("[ci_build_metrics] metrics:")
  237. print(json.dumps(metrics, sort_keys=True, indent=4))
  238. metric_file_path = os.path.join(engine_dir, 'build_metrics')
  239. if os.path.exists(metric_file_path):
  240. shutil.rmtree(metric_file_path)
  241. os.makedirs(metric_file_path)
  242. metric_file_path = os.path.join(metric_file_path, timestamp.strftime("%Y%m%d_%H%M%S.json"))
  243. with open(metric_file_path, 'w') as metric_file:
  244. json.dump(metrics, metric_file, sort_keys=True, indent=4)
  245. # transfer
  246. upload_script = os.path.join(current_dir, 'tools', 'upload_to_s3.py')
  247. upload_to_s3(upload_script, os.path.join(engine_dir, 'build_metrics'), 'ly-jenkins-cmake-metrics', args.jobname)
  248. # submit
  249. submit_report_document(metric_file_path)
  250. # Dont cleanup, next build will remove the file, but leaving it helps to do some post-build forensics