diff --git a/profiler/README.rst b/profiler/README.rst
new file mode 100644
index 000000000..42dd1f131
--- /dev/null
+++ b/profiler/README.rst
@@ -0,0 +1,95 @@
+.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg
+ :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
+ :alt: License: AGPL-3
+
+========
+Profiler
+========
+
+Integration of cProfile and PgBadger.
+
+Installation
+============
+
+To install this module, you need the following requirements:
+
+* Install `pgbadger `_ binary package.
+* Install `pstats_print2list `_ python package.
+* Set `PG_LOG_PATH` environment variable to know location of the `postgresql.log` file by default is `/var/lib/postgresql/9.X/main/pg_log/postgresql.log`
+* Enable postgresql logs from postgresql's configuration file (Default location for Linux Debian is `/etc/postgresql/*/main/postgresql.conf`)
+ - Add the following lines at final (A postgresql restart is required `/etc/init.d/postgresql restart`)
+
+.. code-block:: text
+
+ logging_collector=on
+ log_destination='stderr'
+ log_directory='pg_log'
+ log_filename='postgresql.log'
+ log_rotation_age=0
+ log_checkpoints=on
+ log_hostname=on
+ log_line_prefix='%t [%p]: [%l-1] db=%d,user=%u '
+
+
+Configuration
+=============
+
+By default profiler module adds two system parameters
+ - exclude_fnames > '/.repo_requirements,~/odoo-8.0,/usr/,>'
+ - exclude_query > 'ir_translation'.
+
+These parameters can be configurated in order to exclude some outputs from
+profiling stats or pgbadger output.
+
+Usage
+=====
+
+After installation, a player is add on the header bar, with following buttons:
+
+ - .. figure:: static/description/player.png
+ :alt: Player to manage profiler
+
+
+* Start profiling
+ - .. figure:: static/description/start_profiling.png
+ :alt: Start profiling
+ :height: 35px
+* Stop profiling
+ - .. figure:: static/description/stop_profiling.png
+ :alt: Stop profiling
+ :height: 35px
+* Download stats: download stats file
+ - .. figure:: static/description/dump_stats.png
+ :alt: Download cprofile stats file
+ :height: 35px
+* Clear stats
+ - .. figure:: static/description/clear_stats.png
+ :alt: Clear and remove stats file
+ :height: 35px
+
+
+Credits
+=======
+
+Contributors
+------------
+
+* Georges Racinet
+ - Remotely inspired from ZopeProfiler, although there is no online visualisation and there may never be one.
+* Moisés López
+* Hugo Adan
+
+Maintainer
+----------
+
+.. image:: https://odoo-community.org/logo.png
+ :alt: Odoo Community Association
+ :target: https://odoo-community.org
+
+This module is maintained by the OCA.
+
+OCA, or the Odoo Community Association, is a nonprofit organization whose
+mission is to support the collaborative development of Odoo features and
+promote its widespread use.
+
+To contribute to this module, please visit https://odoo-community.org.
diff --git a/profiler/__init__.py b/profiler/__init__.py
new file mode 100644
index 000000000..5b85f8689
--- /dev/null
+++ b/profiler/__init__.py
@@ -0,0 +1,6 @@
+# coding: utf-8
+# License AGPL-3 or later (http://www.gnu.org/licenses/lgpl).
+# Copyright 2014 Anybox
+# Copyright 2016 Vauxoo (https://www.vauxoo.com)
+from . import controllers
+from .hooks import post_load
diff --git a/profiler/__openerp__.py b/profiler/__openerp__.py
new file mode 100644
index 000000000..666b062f6
--- /dev/null
+++ b/profiler/__openerp__.py
@@ -0,0 +1,32 @@
+# coding: utf-8
+# License AGPL-3 or later (http://www.gnu.org/licenses/lgpl).
+# Copyright 2014 Anybox
+# Copyright 2016 Vauxoo (https://www.vauxoo.com)
+
+{
+ 'name': 'profiler',
+ 'version': '8.0.1.0.0',
+ 'category': 'Tools',
+ 'license': 'AGPL-3',
+ 'author': 'Anybox, Vauxoo, Odoo Community Association (OCA)',
+ 'website': 'https://odoo-community.org',
+ 'depends': ['website'],
+ 'data': [
+ 'data/profiler_excluding.xml',
+ 'security/group.xml',
+ 'views/profiler.xml',
+ 'views/assets.xml',
+ ],
+ 'external_dependencies': {
+ 'python': [
+ 'pstats_print2list',
+ ],
+ },
+ 'qweb': [
+ 'static/src/xml/player.xml',
+ ],
+ 'installable': True,
+ 'application': False,
+ 'auto_install': False,
+ 'post_load': 'post_load',
+}
diff --git a/profiler/controllers/__init__.py b/profiler/controllers/__init__.py
new file mode 100644
index 000000000..ca5f234cf
--- /dev/null
+++ b/profiler/controllers/__init__.py
@@ -0,0 +1,5 @@
+# coding: utf-8
+# License AGPL-3 or later (http://www.gnu.org/licenses/lgpl).
+# Copyright 2014 Anybox
+# Copyright 2016 Vauxoo (https://www.vauxoo.com)
+from . import main
diff --git a/profiler/controllers/main.py b/profiler/controllers/main.py
new file mode 100644
index 000000000..5d3b78d66
--- /dev/null
+++ b/profiler/controllers/main.py
@@ -0,0 +1,228 @@
+# coding: utf-8
+# License AGPL-3 or later (http://www.gnu.org/licenses/lgpl).
+# Copyright 2014 Anybox
+# Copyright 2016 Vauxoo (https://www.vauxoo.com)
+import errno
+import logging
+import os
+import sys
+import tempfile
+from cStringIO import StringIO
+from datetime import datetime
+
+from openerp import http, sql_db, tools
+from openerp.addons.web.controllers.main import content_disposition
+from openerp.http import request
+from openerp.service.db import dump_db_manifest
+from openerp.tools.misc import find_in_path
+
+from ..hooks import CoreProfile as core
+
+_logger = logging.getLogger(__name__)
+
+try:
+ from pstats_print2list import get_pstats_print2list, print_pstats_list
+except ImportError as err: # pragma: no cover
+ _logger.debug(err)
+
+DFTL_LOG_PATH = '/var/lib/postgresql/%s/main/pg_log/postgresql.log'
+
+PGOPTIONS = (
+ '-c client_min_messages=notice -c log_min_messages=warning '
+ '-c log_min_error_statement=error '
+ '-c log_min_duration_statement=0 -c log_connections=on '
+ '-c log_disconnections=on -c log_duration=off '
+ '-c log_error_verbosity=verbose -c log_lock_waits=on '
+ '-c log_statement=none -c log_temp_files=0 '
+)
+
+
+class Capturing(list):
+ def __enter__(self):
+ self._stdout = sys.stdout
+ sys.stdout = self._stringio = StringIO()
+ return self
+
+ def __exit__(self, *args):
+ self.extend(self._stringio.getvalue().splitlines())
+ del self._stringio # free up some memory
+ sys.stdout = self._stdout
+
+
+class ProfilerController(http.Controller):
+
+ _cp_path = '/web/profiler'
+
+ player_state = 'profiler_player_clear'
+ begin_date = ''
+ end_date = ''
+ """Indicate the state(css class) of the player:
+
+ * profiler_player_clear
+ * profiler_player_enabled
+ * profiler_player_disabled
+ """
+
+ @http.route(['/web/profiler/enable'], type='json', auth="user")
+ def enable(self):
+ _logger.info("Enabling")
+ core.enabled = True
+ ProfilerController.begin_date = datetime.now().strftime(
+ "%Y-%m-%d %H:%M:%S")
+ ProfilerController.player_state = 'profiler_player_enabled'
+ os.environ['PGOPTIONS'] = PGOPTIONS
+ self.empty_cursor_pool()
+
+ @http.route(['/web/profiler/disable'], type='json', auth="user")
+ def disable(self, **post):
+ _logger.info("Disabling")
+ core.enabled = False
+ ProfilerController.end_date = datetime.now().strftime(
+ "%Y-%m-%d %H:%M:%S")
+ ProfilerController.player_state = 'profiler_player_disabled'
+ os.environ.pop("PGOPTIONS", None)
+ self.empty_cursor_pool()
+
+ @http.route(['/web/profiler/clear'], type='json', auth="user")
+ def clear(self, **post):
+ core.profile.clear()
+ _logger.info("Cleared stats")
+ ProfilerController.player_state = 'profiler_player_clear'
+ ProfilerController.end_date = ''
+ ProfilerController.begin_date = ''
+
+ @http.route(['/web/profiler/dump'], type='http', auth="user")
+ def dump(self, token, **post):
+ """Provide the stats as a file download.
+
+ Uses a temporary file, because apparently there's no API to
+ dump stats in a stream directly.
+ """
+ exclude_fname = self.get_exclude_fname()
+ with tools.osutil.tempdir() as dump_dir:
+ ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+ filename = 'openerp_%s' % ts
+ stats_path = os.path.join(dump_dir, '%s.stats' % filename)
+ core.profile.dump_stats(stats_path)
+ _logger.info("Pstats Command:")
+ params = {'fnames': stats_path, 'sort': 'cumulative', 'limit': 45,
+ 'exclude_fnames': exclude_fname}
+ _logger.info(
+ "fnames=%(fnames)s, sort=%(sort)s,"
+ " limit=%(limit)s, exclude_fnames=%(exclude_fnames)s", params)
+ pstats_list = get_pstats_print2list(**params)
+ with Capturing() as output:
+ print_pstats_list(pstats_list)
+ result_path = os.path.join(dump_dir, '%s.txt' % filename)
+ with open(result_path, "a") as res_file:
+ for line in output:
+ res_file.write('%s\n' % line)
+ # PG_BADGER
+ self.dump_pgbadger(dump_dir, 'pgbadger_output.txt', request.cr)
+ t_zip = tempfile.TemporaryFile()
+ tools.osutil.zip_dir(dump_dir, t_zip, include_dir=False)
+ t_zip.seek(0)
+ headers = [
+ ('Content-Type', 'application/octet-stream; charset=binary'),
+ ('Content-Disposition', content_disposition(
+ '%s.zip' % filename))]
+ _logger.info('Download Profiler zip: %s', t_zip.name)
+ return request.make_response(
+ t_zip, headers=headers, cookies={'fileToken': token})
+
+ @http.route(['/web/profiler/initial_state'], type='json', auth="user")
+ def initial_state(self, **post):
+ user = request.env['res.users'].browse(request.uid)
+ return {
+ 'has_player_group': user.has_group(
+ 'profiler.group_profiler_player'),
+ 'player_state': ProfilerController.player_state,
+ }
+
+ def dump_pgbadger(self, dir_dump, output, cursor):
+ pgbadger = find_in_path("pgbadger")
+ if not pgbadger:
+ _logger.error("Pgbadger not found")
+ return
+ filename = os.path.join(dir_dump, output)
+ pg_version = dump_db_manifest(cursor)['pg_version']
+ log_path = os.environ.get('PG_LOG_PATH', DFTL_LOG_PATH % pg_version)
+ if not os.path.exists(os.path.dirname(filename)):
+ try:
+ os.makedirs(os.path.dirname(filename))
+ except OSError as exc:
+ # error is different than File exists
+ if exc.errno != errno.EEXIST:
+ _logger.error("Folder %s can not be created",
+ os.path.dirname(filename))
+ return
+ _logger.info("Generating PG Badger report.")
+ exclude_query = self.get_exclude_query()
+ dbname = cursor.dbname
+ command = [
+ pgbadger, '-f', 'stderr', '-T', 'Odoo-Profiler',
+ '-o', '-', '-d', dbname, '-b', ProfilerController.begin_date,
+ '-e', ProfilerController.end_date, '--sample', '2',
+ '--disable-type', '--disable-error', '--disable-hourly',
+ '--disable-session', '--disable-connection',
+ '--disable-temporary', '--quiet']
+ command.extend(exclude_query)
+ command.append(log_path)
+
+ _logger.info("Pgbadger Command:")
+ _logger.info(command)
+ result = tools.exec_command_pipe(*command)
+ with open(filename, 'w') as fw:
+ fw.write(result[1].read())
+ _logger.info("Done")
+
+ def get_exclude_fname(self):
+ efnameid = request.env.ref(
+ 'profiler.default_exclude_fnames_pstas', raise_if_not_found=False)
+ if not efnameid:
+ return []
+ return [os.path.expanduser(path)
+ for path in efnameid and efnameid.value.strip(',').split(',')
+ if path]
+
+ def get_exclude_query(self):
+ """Example '^(COPY|COMMIT)'
+ """
+ equeryid = request.env.ref(
+ 'profiler.default_exclude_query_pgbadger',
+ raise_if_not_found=False)
+ if not equeryid:
+ return []
+ exclude_queries = []
+ for path in equeryid and equeryid.value.strip(',').split(','):
+ exclude_queries.extend(
+ ['--exclude-query', '"^(%s)" ' % path.encode('UTF-8')])
+ return exclude_queries
+
+ def empty_cursor_pool(self):
+ """This method cleans (rollback) all current transactions over actual
+ cursor in order to avoid errors with waiting transactions.
+ - request.cr.rollback()
+
+ Also connections on current database's only are closed by the next
+ statement
+ - dsn = openerp.sql_db.dsn(request.cr.dbname)
+ - openerp.sql_db._Pool.close_all(dsn[1])
+ Otherwise next error will be trigger
+ 'InterfaceError: connection already closed'
+
+ Finally new cursor is assigned to the request object, this cursor will
+ take the os.environ setted. In this case the os.environ is setted with
+ all 'PGOPTIONS' required to log all sql transactions in postgres.log
+ file.
+
+ If this method is called one more time, it will create a new cursor and
+ take the os.environ again, this is usefully if we want to reset
+ 'PGOPTIONS'
+
+ """
+ request.cr._cnx.reset()
+ dsn = sql_db.dsn(request.cr.dbname)
+ sql_db._Pool.close_all(dsn[1])
+ db = sql_db.db_connect(request.cr.dbname)
+ request._cr = db.cursor()
diff --git a/profiler/data/profiler_excluding.xml b/profiler/data/profiler_excluding.xml
new file mode 100644
index 000000000..d4ef12a4f
--- /dev/null
+++ b/profiler/data/profiler_excluding.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+ exclude_fnames
+ /.repo_requirements,~/odoo-8.0,/usr/,>
+
+
+
+
+ exclude_query
+ ir_translation
+
+
+
diff --git a/profiler/hooks.py b/profiler/hooks.py
new file mode 100644
index 000000000..7e192a5d4
--- /dev/null
+++ b/profiler/hooks.py
@@ -0,0 +1,103 @@
+# coding: utf-8
+# License AGPL-3 or later (http://www.gnu.org/licenses/lgpl).
+# Copyright 2014 Anybox
+# Copyright 2016 Vauxoo (https://www.vauxoo.com)
+import logging
+import os
+from contextlib import contextmanager
+from cProfile import Profile
+
+import openerp
+from openerp import sql_db
+from openerp.http import WebRequest
+from openerp.service.server import ThreadedServer
+
+_logger = logging.getLogger(__name__)
+
+
+class CoreProfile(object):
+ """The thread-shared profile object"""
+ profile = None
+ # Indicates if the whole profiling functionality is globally active or not.
+ enabled = False
+
+
+@contextmanager
+def profiling():
+ """Thread local profile management, according to the shared :data:`enabled`
+ """
+ if CoreProfile.enabled:
+ CoreProfile.profile.enable()
+ yield
+
+ if CoreProfile.enabled:
+ CoreProfile.profile.disable()
+
+
+def patch_odoo():
+ """Modify Odoo entry points so that profile can record.
+
+ Odoo is a multi-threaded program. Therefore, the :data:`profile` object
+ needs to be enabled/disabled each in each thread to capture all the
+ execution.
+
+ For instance, Odoo spawns a new thread for each request.
+
+ Modify database connect method to add options to enable postgresql logging
+ based on PGOPTIONS environment variable
+ """
+ _logger.info('Patching openerp.http.WebRequest._call_function')
+ webreq_f_origin = WebRequest._call_function
+
+ def webreq_f(*args, **kwargs):
+ with profiling():
+ return webreq_f_origin(*args, **kwargs)
+ WebRequest._call_function = webreq_f
+
+ _logger.info('Patching openerp.sql_db.db_connect')
+ db_connect_origin = sql_db.db_connect
+
+ def dbconnect_f(to, *args, **kwargs):
+ try:
+ to += " options='%s' " % (os.environ['PGOPTIONS'] or '')
+ except KeyError:
+ pass
+ return db_connect_origin(to, *args, **kwargs)
+ sql_db.db_connect = dbconnect_f
+
+
+def dump_stats():
+ """Dump stats to standard file"""
+ _logger.info('Dump stats')
+ CoreProfile.profile.dump_stats(
+ os.path.expanduser('~/.openerp_server.stats'))
+
+
+def create_profile():
+ """Create the global, shared profile object."""
+ _logger.info('Create profile')
+ CoreProfile.profile = Profile()
+
+
+def patch_stop():
+ """When the server is stopped then save the result of cProfile stats"""
+ origin_stop = ThreadedServer.stop
+
+ _logger.info('Patching openerp.service.server.ThreadedServer.stop')
+
+ def stop(*args, **kwargs):
+ if openerp.tools.config['test_enable']:
+ dump_stats()
+ return origin_stop(*args, **kwargs)
+ ThreadedServer.stop = stop
+
+
+def post_load():
+ _logger.info('Post load')
+ create_profile()
+ patch_odoo()
+ if openerp.tools.config['test_enable']:
+ # Enable profile in test mode for orm methods.
+ _logger.info('Enabling profiler and apply patch')
+ CoreProfile.enabled = True
+ patch_stop()
diff --git a/profiler/security/group.xml b/profiler/security/group.xml
new file mode 100644
index 000000000..980f27973
--- /dev/null
+++ b/profiler/security/group.xml
@@ -0,0 +1,11 @@
+
+
+
+
+ Profiling capability
+
+
+
+
+
+
diff --git a/profiler/static/description/clear_stats.png b/profiler/static/description/clear_stats.png
new file mode 100644
index 000000000..8c66f4af8
Binary files /dev/null and b/profiler/static/description/clear_stats.png differ
diff --git a/profiler/static/description/dump_stats.png b/profiler/static/description/dump_stats.png
new file mode 100644
index 000000000..f007f5415
Binary files /dev/null and b/profiler/static/description/dump_stats.png differ
diff --git a/profiler/static/description/player.png b/profiler/static/description/player.png
new file mode 100644
index 000000000..86ff65760
Binary files /dev/null and b/profiler/static/description/player.png differ
diff --git a/profiler/static/description/start_profiling.png b/profiler/static/description/start_profiling.png
new file mode 100644
index 000000000..39e384868
Binary files /dev/null and b/profiler/static/description/start_profiling.png differ
diff --git a/profiler/static/description/stop_profiling.png b/profiler/static/description/stop_profiling.png
new file mode 100644
index 000000000..c8a49f480
Binary files /dev/null and b/profiler/static/description/stop_profiling.png differ
diff --git a/profiler/static/src/css/player.css b/profiler/static/src/css/player.css
new file mode 100644
index 000000000..b65a4d06d
--- /dev/null
+++ b/profiler/static/src/css/player.css
@@ -0,0 +1,21 @@
+.oe_topbar_item.profiler_player i {
+ cursor: pointer;
+}
+.oe_topbar_item.profiler_player.profiler_player_enabled a.profiler_enable {
+ display: None;
+}
+.oe_topbar_item.profiler_player.profiler_player_enabled a.profiler_dump {
+ display: None;
+}
+.oe_topbar_item.profiler_player.profiler_player_disabled a.profiler_disable {
+ display: None;
+}
+.oe_topbar_item.profiler_player.profiler_player_clear a.profiler_disable {
+ display: None;
+}
+.oe_topbar_item.profiler_player.profiler_player_clear a.profiler_clear {
+ display: None;
+}
+.oe_topbar_item.profiler_player.profiler_player_clear a.profiler_dump {
+ display: None;
+}
diff --git a/profiler/static/src/js/player.js b/profiler/static/src/js/player.js
new file mode 100644
index 000000000..82c2e07d2
--- /dev/null
+++ b/profiler/static/src/js/player.js
@@ -0,0 +1,52 @@
+openerp.profiler = function(instance) {
+ instance.profiler.Player = instance.web.Widget.extend({
+ template: 'profiler.player',
+ events: {
+ "click .profiler_enable": "enable",
+ "click .profiler_disable": "disable",
+ "click .profiler_clear": "clear",
+ "click .profiler_dump": "dump"
+ },
+ apply_class: function(css_class) {
+ this.$el.removeClass('profiler_player_enabled');
+ this.$el.removeClass('profiler_player_disabled');
+ this.$el.removeClass('profiler_player_clear');
+ this.$el.addClass(css_class);
+ },
+ enable: function() {
+ this.rpc('/web/profiler/enable', {});
+ this.apply_class('profiler_player_enabled');
+ },
+ disable: function() {
+ this.rpc('/web/profiler/disable', {});
+ this.apply_class('profiler_player_disabled');
+ },
+ clear: function() {
+ this.rpc('/web/profiler/clear', {});
+ this.apply_class('profiler_player_clear');
+ },
+ dump: function() {
+ $.blockUI();
+ this.session.get_file({
+ url: '/web/profiler/dump',
+ complete: $.unblockUI
+ });
+ }
+ });
+
+ instance.web.UserMenu.include({
+ do_update: function () {
+ var self = this;
+ this.update_promise.done(function () {
+ self.rpc('/web/profiler/initial_state', {}).done(function(state) {
+ if (state.has_player_group) {
+ this.profiler_player = new instance.profiler.Player(this);
+ this.profiler_player.prependTo(instance.webclient.$('.oe_systray'));
+ this.profiler_player.apply_class(state.player_state);
+ }
+ });
+ });
+ return this._super();
+ }
+ });
+};
diff --git a/profiler/static/src/js/test_profiler.js b/profiler/static/src/js/test_profiler.js
new file mode 100644
index 000000000..ff072f711
--- /dev/null
+++ b/profiler/static/src/js/test_profiler.js
@@ -0,0 +1,52 @@
+(function(){
+ 'use_strict';
+ openerp.Tour.register({
+ id: 'profile_run',
+ name: 'Profile run',
+ path: '/web',
+ mode: 'test',
+ steps: [
+ {
+ title: 'Check if is cleared',
+ waitFor: 'li.oe_topbar_item.profiler_player.profiler_player_clear'
+ },
+ {
+ title: 'Start profiling',
+ onload: function () {
+ $('a.profiler_enable').trigger('click');
+ }
+ },
+ {
+ title: 'Check if is enabled',
+ waitFor: 'li.oe_topbar_item.profiler_player.profiler_player_enabled'
+ },
+ {
+ title: 'Stop profiling',
+ onload: function () {
+ $('a.profiler_disable').trigger('click');
+ }
+ },
+ {
+ title: 'Check if is disabled',
+ waitFor: 'li.oe_topbar_item.profiler_player.profiler_player_disabled'
+ },
+ {
+ title: 'Dump profiling',
+ onload: function () {
+ $('a.profiler_dump').trigger('click');
+ }
+ },
+ {
+ title: 'Clear profiling',
+ onload: function () {
+ $('a.profiler_clear').trigger('click');
+ }
+ },
+ {
+ title: 'Check if is cleared again',
+ waitFor: 'li.oe_topbar_item.profiler_player.profiler_player_clear'
+ },
+
+ ]
+ });
+}());
diff --git a/profiler/static/src/less/player.less b/profiler/static/src/less/player.less
new file mode 100644
index 000000000..3318f6b3e
--- /dev/null
+++ b/profiler/static/src/less/player.less
@@ -0,0 +1,24 @@
+#oe_main_menu_navbar .o_menu_systray {
+
+ .oe_topbar_item.profiler_player i {
+ cursor: pointer;
+ }
+ .oe_topbar_item.profiler_player.profiler_player_enabled a.profiler_enable {
+ display: None;
+ }
+ .oe_topbar_item.profiler_player.profiler_player_enabled a.profiler_dump {
+ display: None;
+ }
+ .oe_topbar_item.profiler_player.profiler_player_disabled a.profiler_disable {
+ display: None;
+ }
+ .oe_topbar_item.profiler_player.profiler_player_clear a.profiler_disable {
+ display: None;
+ }
+ .oe_topbar_item.profiler_player.profiler_player_clear a.profiler_clear {
+ display: None;
+ }
+ .oe_topbar_item.profiler_player.profiler_player_clear a.profiler_dump {
+ display: None;
+ }
+}
diff --git a/profiler/static/src/xml/player.xml b/profiler/static/src/xml/player.xml
new file mode 100644
index 000000000..18e885989
--- /dev/null
+++ b/profiler/static/src/xml/player.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/profiler/tests/__init__.py b/profiler/tests/__init__.py
new file mode 100644
index 000000000..690d59efe
--- /dev/null
+++ b/profiler/tests/__init__.py
@@ -0,0 +1,5 @@
+# coding: utf-8
+# License AGPL-3 or later (http://www.gnu.org/licenses/lgpl).
+# Copyright 2014 Anybox
+# Copyright 2016 Vauxoo (https://www.vauxoo.com)
+from . import test_profiler
diff --git a/profiler/tests/test_profiler.py b/profiler/tests/test_profiler.py
new file mode 100644
index 000000000..9ce0a51d0
--- /dev/null
+++ b/profiler/tests/test_profiler.py
@@ -0,0 +1,17 @@
+# coding: utf-8
+# License AGPL-3 or later (http://www.gnu.org/licenses/lgpl).
+# Copyright 2016 Vauxoo (https://www.vauxoo.com)
+
+import unittest
+
+from openerp import tests
+
+
+@tests.at_install(False)
+@tests.post_install(True)
+class TestProfiler(tests.HttpCase):
+
+ @unittest.skip("phantomjs tests async for 8.0 are so flaky")
+ def test_profiler_tour(self):
+ self.phantom_js('/web', "openerp.Tour.run('profile_run', 'test')",
+ 'openerp.Tour.tours.profile_run', login='admin')
diff --git a/profiler/views/assets.xml b/profiler/views/assets.xml
new file mode 100644
index 000000000..373acac0f
--- /dev/null
+++ b/profiler/views/assets.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/profiler/views/profiler.xml b/profiler/views/profiler.xml
new file mode 100644
index 000000000..686e1f229
--- /dev/null
+++ b/profiler/views/profiler.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/requirements.txt b/requirements.txt
index c489fc0eb..d58c79584 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,4 @@
+ pstats_print2list
python-ldap
unidecode
validate_email