You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

472 lines
18 KiB

6 years ago
  1. import base64
  2. import logging
  3. import os
  4. import pstats
  5. import re
  6. import subprocess
  7. import lxml.html
  8. from contextlib import contextmanager
  9. from cProfile import Profile
  10. from io import BytesIO
  11. from psycopg2 import OperationalError, ProgrammingError
  12. from odoo import api, exceptions, fields, models, sql_db, tools, _
  13. DATETIME_FORMAT_FILE = "%Y%m%d_%H%M%S"
  14. CPROFILE_EMPTY_CHARS = b"{0"
  15. PGOPTIONS = {
  16. 'log_min_duration_statement': '0',
  17. 'client_min_messages': 'notice',
  18. 'log_min_messages': 'warning',
  19. 'log_min_error_statement': 'error',
  20. 'log_duration': 'off',
  21. 'log_error_verbosity': 'verbose',
  22. 'log_lock_waits': 'on',
  23. 'log_statement': 'none',
  24. 'log_temp_files': '0',
  25. }
  26. PGOPTIONS_ENV = ' '.join(["-c %s=%s" % (param, value)
  27. for param, value in PGOPTIONS.items()])
  28. PY_STATS_FIELDS = [
  29. 'ncalls',
  30. 'tottime', 'tt_percall',
  31. 'cumtime', 'ct_percall',
  32. 'file', 'lineno', 'method',
  33. ]
  34. LINE_STATS_RE = re.compile(
  35. r'(?P<%s>\d+/?\d+|\d+)\s+(?P<%s>\d+\.?\d+)\s+(?P<%s>\d+\.?\d+)\s+'
  36. r'(?P<%s>\d+\.?\d+)\s+(?P<%s>\d+\.?\d+)\s+(?P<%s>.*):(?P<%s>\d+)'
  37. r'\((?P<%s>.*)\)' % tuple(PY_STATS_FIELDS))
  38. _logger = logging.getLogger(__name__)
  39. class ProfilerProfilePythonLine(models.Model):
  40. _name = 'profiler.profile.python.line'
  41. _description = 'Profiler Python Line to save cProfiling results'
  42. _rec_name = 'cprof_fname'
  43. _order = 'cprof_cumtime DESC'
  44. profile_id = fields.Many2one('profiler.profile', required=True,
  45. ondelete='cascade')
  46. cprof_tottime = fields.Float("Total time")
  47. cprof_ncalls = fields.Float("Calls")
  48. cprof_nrcalls = fields.Float("Recursive Calls")
  49. cprof_ttpercall = fields.Float("Time per call")
  50. cprof_cumtime = fields.Float("Cumulative time")
  51. cprof_ctpercall = fields.Float("CT per call")
  52. cprof_fname = fields.Char("Filename:lineno(method)")
  53. class ProfilerProfile(models.Model):
  54. _name = 'profiler.profile'
  55. _description = 'Profiler Profile'
  56. @api.model
  57. def _find_loggers_path(self):
  58. self.env.cr.execute("SHOW log_directory")
  59. log_directory = self.env.cr.fetchone()[0]
  60. self.env.cr.execute("SHOW log_filename")
  61. log_filename = self.env.cr.fetchone()[0]
  62. log_path = os.path.join(log_directory, log_filename)
  63. if not os.path.isabs(log_path):
  64. # It is relative path then join data_directory
  65. self.env.cr.execute("SHOW data_directory")
  66. data_dir = self.env.cr.fetchone()[0]
  67. log_path = os.path.join(data_dir, log_path)
  68. return log_path
  69. name = fields.Char(required=True)
  70. enable_python = fields.Boolean(default=True)
  71. enable_postgresql = fields.Boolean(
  72. default=False,
  73. help="It requires postgresql server logs seudo-enabled")
  74. use_py_index = fields.Boolean(
  75. name="Get cProfiling report", default=False,
  76. help="Index human-readable cProfile attachment."
  77. "\nTo access this report, you must open the cprofile attachment view "
  78. "using debug mode.\nWarning: Uses more resources.")
  79. date_started = fields.Char(readonly=True)
  80. date_finished = fields.Char(readonly=True)
  81. state = fields.Selection([
  82. ('enabled', 'Enabled'),
  83. ('disabled', 'Disabled'),
  84. ], default='disabled', readonly=True, required=True)
  85. description = fields.Text(readonly=True)
  86. attachment_count = fields.Integer(compute="_compute_attachment_count")
  87. pg_log_path = fields.Char(help="Getting the path to the logger",
  88. default=_find_loggers_path)
  89. pg_remote = fields.Char()
  90. pg_stats_slowest_html = fields.Html(
  91. "PostgreSQL Stats - Slowest", readonly=True)
  92. pg_stats_time_consuming_html = fields.Html(
  93. "PostgreSQL Stats - Time Consuming", readonly=True)
  94. pg_stats_most_frequent_html = fields.Html(
  95. "PostgreSQL Stats - Most Frequent", readonly=True)
  96. py_stats_lines = fields.One2many(
  97. "profiler.profile.python.line", "profile_id", "PY Stats Lines")
  98. @api.multi
  99. def _compute_attachment_count(self):
  100. for record in self:
  101. self.attachment_count = self.env['ir.attachment'].search_count([
  102. ('res_model', '=', self._name), ('res_id', '=', record.id)])
  103. @api.onchange('enable_postgresql')
  104. def onchange_enable_postgresql(self):
  105. if not self.enable_postgresql:
  106. return
  107. self.env.cr.execute("SHOW config_file")
  108. pg_config_file = self.env.cr.fetchone()[0]
  109. db_host = tools.config.get('db_host')
  110. if db_host == 'localhost' or db_host == '127.0.0.1':
  111. db_host = False
  112. if db_host:
  113. pg_config_file = 'postgres@%s:%s' % (db_host, pg_config_file)
  114. self.pg_remote = db_host
  115. self.description = (
  116. "You need seudo-enable logs from your "
  117. "postgresql-server configuration file.\n\t- %s\n"
  118. "or your can looking for the service using: "
  119. "'ps aux | grep postgres'\n\n"
  120. ) % pg_config_file
  121. self.description += """Adds the following parameters:
  122. # Pre-enable logs
  123. logging_collector=on
  124. log_destination='stderr'
  125. log_directory='/var/log/postgresql'
  126. log_filename='postgresql.log'
  127. log_rotation_age=0
  128. log_checkpoints=on
  129. log_hostname=on
  130. log_line_prefix='%t [%p]: [%l-1] db=%d,user=%u '
  131. log_connections=on
  132. log_disconnections=on
  133. lc_messages='en_US.UTF-8'
  134. log_timezone='UTC'
  135. Reload configuration using the following query:
  136. - select pg_reload_conf()
  137. Or restart the postgresql server service.
  138. FYI This module will enable the following parameter from the client
  139. It's not needed added them to configuration file if database user is
  140. superuser or use PGOPTIONS environment variable in the terminal
  141. that you start your odoo server.
  142. If you don't add these parameters or PGOPTIONS this module will try do it.
  143. # Enable logs from postgresql.conf
  144. log_min_duration_statement=0
  145. client_min_messages=notice
  146. log_min_messages=warning
  147. log_min_error_statement=error
  148. log_duration=off
  149. log_error_verbosity=verbose
  150. log_lock_waits=on
  151. log_statement=none
  152. log_temp_files=0
  153. # Or enable logs from PGOPTIONS environment variable before to start odoo
  154. # server
  155. export PGOPTIONS="-c log_min_duration_statement=0 \\
  156. -c client_min_messages=notice -c log_min_messages=warning \\
  157. -c log_min_error_statement=error -c log_connections=on \\
  158. -c log_disconnections=on -c log_duration=off -c log_error_verbosity=verbose \\
  159. -c log_lock_waits=on -c log_statement=none -c log_temp_files=0"
  160. ~/odoo_path/odoo-bin ...
  161. """
  162. profile = Profile()
  163. enabled = None
  164. pglogs_enabled = None
  165. # True to activate it False to inactivate None to do nothing
  166. activate_deactivate_pglogs = None
  167. # Params dict with values before to change it.
  168. psql_params_original = {}
  169. @api.model
  170. def now_utc(self):
  171. self.env.cr.execute("SHOW log_timezone")
  172. zone = self.env.cr.fetchone()[0]
  173. self.env.cr.execute("SELECT to_char(current_timestamp AT TIME "
  174. "ZONE %s, 'YYYY-MM-DD HH24:MI:SS')", (zone,))
  175. now = self.env.cr.fetchall()[0][0]
  176. # now = fields.Datetime.to_string(
  177. # fields.Datetime.context_timestamp(self, datetime.now()))
  178. return now
  179. @api.multi
  180. def enable(self):
  181. self.ensure_one()
  182. if tools.config.get('workers'):
  183. raise exceptions.UserError(
  184. _("Start the odoo server using the parameter '--workers=0'"))
  185. _logger.info("Enabling profiler")
  186. self.write(dict(
  187. date_started=self.now_utc(),
  188. state='enabled'
  189. ))
  190. ProfilerProfile.enabled = self.enable_python
  191. self._reset_postgresql()
  192. @api.multi
  193. def _reset_postgresql(self):
  194. if not self.enable_postgresql:
  195. return
  196. if ProfilerProfile.pglogs_enabled:
  197. _logger.info("Using postgresql.conf or PGOPTIONS predefined.")
  198. return
  199. os.environ['PGOPTIONS'] = (
  200. PGOPTIONS_ENV if self.state == 'enabled' else '')
  201. self._reset_connection(self.state == 'enabled')
  202. def _reset_connection(self, enable):
  203. for connection in sql_db._Pool._connections:
  204. with connection[0].cursor() as pool_cr:
  205. params = (PGOPTIONS if enable
  206. else ProfilerProfile.psql_params_original)
  207. for param, value in params.items():
  208. try:
  209. pool_cr.execute('SET %s TO %s' % (param, value))
  210. except (OperationalError, ProgrammingError) as oe:
  211. pool_cr.connection.rollback()
  212. raise exceptions.UserError(
  213. _("It's not possible change parameter.\n%s\n"
  214. "Please, disable postgresql or re-enable it "
  215. "in order to read the instructions") % str(oe))
  216. ProfilerProfile.activate_deactivate_pglogs = enable
  217. def get_stats_string(self, cprofile_path):
  218. pstats_stream = BytesIO()
  219. pstats_obj = pstats.Stats(cprofile_path, stream=pstats_stream)
  220. pstats_obj.sort_stats('cumulative')
  221. pstats_obj.print_stats()
  222. pstats_stream.seek(0)
  223. stats_string = pstats_stream.read()
  224. pstats_stream = None
  225. return stats_string
  226. @api.multi
  227. def dump_postgresql_logs(self, indexed=None):
  228. self.ensure_one()
  229. self.description = ''
  230. pgbadger_cmd = self._get_pgbadger_command()
  231. if pgbadger_cmd is None:
  232. return
  233. pgbadger_cmd_str = subprocess.list2cmdline(pgbadger_cmd)
  234. self.description += (
  235. '\nRunning the command: %s') % pgbadger_cmd_str
  236. result = tools.exec_command_pipe(*pgbadger_cmd)
  237. datas = result[1].read()
  238. if not datas:
  239. self.description += "\nPgbadger output is empty!"
  240. return
  241. fname = self._get_attachment_name("pg_stats", ".html")
  242. self.env['ir.attachment'].create({
  243. 'name': fname,
  244. 'res_id': self.id,
  245. 'res_model': self._name,
  246. 'datas': base64.b64encode(datas),
  247. 'datas_fname': fname,
  248. 'description': 'pgbadger html output',
  249. })
  250. xpaths = [
  251. '//*[@id="slowest-individual-queries"]',
  252. '//*[@id="time-consuming-queries"]',
  253. '//*[@id="most-frequent-queries"]',
  254. ]
  255. # pylint: disable=unbalanced-tuple-unpacking
  256. self.pg_stats_slowest_html, self.pg_stats_time_consuming_html, \
  257. self.pg_stats_most_frequent_html = self._compute_pgbadger_html(
  258. datas, xpaths)
  259. @staticmethod
  260. def _compute_pgbadger_html(html_doc, xpaths):
  261. html = lxml.html.document_fromstring(html_doc)
  262. result = []
  263. for this_xpath in xpaths:
  264. this_result = html.xpath(this_xpath)
  265. result.append(
  266. tools.html_sanitize(lxml.html.tostring(this_result[0])))
  267. return result
  268. @api.multi
  269. def _get_pgbadger_command(self):
  270. self.ensure_one()
  271. # TODO: Catch early the following errors.
  272. try:
  273. pgbadger_bin = tools.find_in_path('pgbadger')
  274. except IOError:
  275. self.description += (
  276. "\nInstall 'apt-get install pgbadger'")
  277. try:
  278. with open(self.pg_log_path, "r"):
  279. pass
  280. except IOError:
  281. self.description += (
  282. "\nCheck if exists and has permission to read the log file."
  283. "\nMaybe running: chmod 604 '%s'"
  284. ) % self.pg_log_path
  285. pgbadger_cmd = [
  286. pgbadger_bin, '-f', 'stderr', '--sample', '15',
  287. '-o', '-', '-x', 'html', '--quiet',
  288. '-T', self.name,
  289. '-d', self.env.cr.dbname,
  290. '-b', self.date_started,
  291. '-e', self.date_finished,
  292. self.pg_log_path,
  293. ]
  294. return pgbadger_cmd
  295. def _get_attachment_name(self, prefix, suffix):
  296. started = fields.Datetime.from_string(
  297. self.date_started).strftime(DATETIME_FORMAT_FILE)
  298. finished = fields.Datetime.from_string(
  299. self.date_finished).strftime(DATETIME_FORMAT_FILE)
  300. fname = '%s_%d_%s_to_%s%s' % (
  301. prefix, self.id, started, finished, suffix)
  302. return fname
  303. @api.model
  304. def dump_stats(self):
  305. attachment = None
  306. with tools.osutil.tempdir() as dump_dir:
  307. cprofile_fname = self._get_attachment_name("py_stats", ".cprofile")
  308. cprofile_path = os.path.join(dump_dir, cprofile_fname)
  309. _logger.info("Dumping cProfile '%s'", cprofile_path)
  310. ProfilerProfile.profile.dump_stats(cprofile_path)
  311. with open(cprofile_path, "rb") as f_cprofile:
  312. datas = f_cprofile.read()
  313. if datas and datas != CPROFILE_EMPTY_CHARS:
  314. attachment = self.env['ir.attachment'].create({
  315. 'name': cprofile_fname,
  316. 'res_id': self.id,
  317. 'res_model': self._name,
  318. 'datas': base64.b64encode(datas),
  319. 'datas_fname': cprofile_fname,
  320. 'description': 'cProfile dump stats',
  321. })
  322. _logger.info("A datas was saved, here %s", attachment.name)
  323. try:
  324. if self.use_py_index:
  325. py_stats = self.get_stats_string(cprofile_path)
  326. self.env['profiler.profile.python.line'].search([
  327. ('profile_id', '=', self.id)]).unlink()
  328. for py_stat_line in py_stats.splitlines():
  329. py_stat_line = py_stat_line.strip('\r\n ')
  330. py_stat_line_match = LINE_STATS_RE.match(
  331. py_stat_line) if py_stat_line else None
  332. if not py_stat_line_match:
  333. continue
  334. data = dict((
  335. field, py_stat_line_match.group(field))
  336. for field in PY_STATS_FIELDS)
  337. data['rcalls'], data['calls'] = (
  338. "%(ncalls)s/%(ncalls)s" % data).split('/')[:2]
  339. self.env['profiler.profile.python.line'].create({
  340. 'cprof_tottime': data['tottime'],
  341. 'cprof_ncalls': data['calls'],
  342. 'cprof_nrcalls': data['rcalls'],
  343. 'cprof_ttpercall': data['tt_percall'],
  344. 'cprof_cumtime': data['cumtime'],
  345. 'cprof_ctpercall': data['ct_percall'],
  346. 'cprof_fname': (
  347. "%(file)s:%(lineno)s (%(method)s)" % data),
  348. 'profile_id': self.id,
  349. })
  350. attachment.index_content = py_stats
  351. except IOError:
  352. # Fancy feature but not stop process if fails
  353. _logger.info("There was an error while getting the stats"
  354. "from the cprofile_path")
  355. # pylint: disable=unnecessary-pass
  356. pass
  357. self.dump_postgresql_logs()
  358. _logger.info("cProfile stats stored.")
  359. else:
  360. _logger.info("cProfile stats empty.")
  361. return attachment
  362. @api.multi
  363. def clear(self, reset_date=True):
  364. self.ensure_one()
  365. _logger.info("Clear profiler")
  366. if reset_date:
  367. self.date_started = self.now_utc()
  368. ProfilerProfile.profile.clear()
  369. @api.multi
  370. def disable(self):
  371. self.ensure_one()
  372. _logger.info("Disabling profiler")
  373. ProfilerProfile.enabled = False
  374. self.state = 'disabled'
  375. self.date_finished = self.now_utc()
  376. self.dump_stats()
  377. self.clear(reset_date=False)
  378. self._reset_postgresql()
  379. @staticmethod
  380. @contextmanager
  381. def profiling():
  382. """Thread local profile management, according to the shared "enabled"
  383. """
  384. if ProfilerProfile.enabled:
  385. _logger.debug("Catching profiling")
  386. ProfilerProfile.profile.enable()
  387. try:
  388. yield
  389. finally:
  390. if ProfilerProfile.enabled:
  391. ProfilerProfile.profile.disable()
  392. @api.multi
  393. def action_view_attachment(self):
  394. attachments = self.env['ir.attachment'].search([
  395. ('res_model', '=', self._name), ('res_id', '=', self.id)])
  396. action = self.env.ref("base.action_attachment").read()[0]
  397. action['domain'] = [('id', 'in', attachments.ids)]
  398. return action
  399. @api.model
  400. def set_pgoptions_enabled(self):
  401. """Verify if postgresql has configured the parameters for logging"""
  402. ProfilerProfile.pglogs_enabled = True
  403. pgoptions_enabled = bool(os.environ.get('PGOPTIONS'))
  404. _logger.info('Logging enabled from environment '
  405. 'variable PGOPTIONS? %s', pgoptions_enabled)
  406. if pgoptions_enabled:
  407. return
  408. pgparams_required = {
  409. 'log_min_duration_statement': '0',
  410. }
  411. for param, value in pgparams_required.items():
  412. # pylint: disable=sql-injection
  413. self.env.cr.execute("SHOW %s" % param)
  414. db_value = self.env.cr.fetchone()[0].lower()
  415. if value.lower() != db_value:
  416. ProfilerProfile.pglogs_enabled = False
  417. break
  418. ProfilerProfile.psql_params_original = self.get_psql_params(
  419. self.env.cr, PGOPTIONS.keys())
  420. _logger.info('Logging enabled from postgresql.conf? %s',
  421. ProfilerProfile.pglogs_enabled)
  422. @staticmethod
  423. def get_psql_params(cr, params):
  424. result = {}
  425. for param in set(params):
  426. # pylint: disable=sql-injection
  427. cr.execute('SHOW %s' % param)
  428. result.update(cr.dictfetchone())
  429. return result
  430. @api.model
  431. def _setup_complete(self):
  432. self.set_pgoptions_enabled()
  433. return super(ProfilerProfile, self)._setup_complete()