aboutsummaryrefslogtreecommitdiffstats
path: root/debug_toolbar/panels
diff options
context:
space:
mode:
Diffstat (limited to 'debug_toolbar/panels')
-rw-r--r--debug_toolbar/panels/logger.py83
-rw-r--r--debug_toolbar/panels/profiling.py169
-rw-r--r--debug_toolbar/panels/sql.py328
-rw-r--r--debug_toolbar/panels/template.py6
4 files changed, 409 insertions, 177 deletions
diff --git a/debug_toolbar/panels/logger.py b/debug_toolbar/panels/logger.py
index 620102e..5e82a13 100644
--- a/debug_toolbar/panels/logger.py
+++ b/debug_toolbar/panels/logger.py
@@ -8,16 +8,21 @@ from django.template.loader import render_to_string
from django.utils.translation import ugettext_lazy as _
from debug_toolbar.panels import DebugPanel
-class ThreadTrackingHandler(logging.Handler):
+
+class LogCollector(object):
def __init__(self):
if threading is None:
raise NotImplementedError("threading module is not available, \
the logging panel cannot be used without it")
- logging.Handler.__init__(self)
self.records = {} # a dictionary that maps threads to log records
- def emit(self, record):
- self.get_records().append(record)
+ def add_record(self, record, thread=None):
+ # Avoid logging SQL queries since they are already in the SQL panel
+ # TODO: Make this check whether SQL panel is enabled
+ if record.get('channel', '') == 'django.db.backends':
+ return
+
+ self.get_records(thread).append(record)
def get_records(self, thread=None):
"""
@@ -36,20 +41,67 @@ class ThreadTrackingHandler(logging.Handler):
if thread in self.records:
del self.records[thread]
-handler = ThreadTrackingHandler()
+
+class ThreadTrackingHandler(logging.Handler):
+ def __init__(self, collector):
+ logging.Handler.__init__(self)
+ self.collector = collector
+
+ def emit(self, record):
+ record = {
+ 'message': record.getMessage(),
+ 'time': datetime.datetime.fromtimestamp(record.created),
+ 'level': record.levelname,
+ 'file': record.pathname,
+ 'line': record.lineno,
+ 'channel': record.name,
+ }
+ self.collector.add_record(record)
+
+
+collector = LogCollector()
+logging_handler = ThreadTrackingHandler(collector)
logging.root.setLevel(logging.NOTSET)
-logging.root.addHandler(handler)
+logging.root.addHandler(logging_handler) # register with logging
+
+try:
+ import logbook
+ logbook_supported = True
+except ImportError:
+ # logbook support is optional, so fail silently
+ logbook_supported = False
+
+if logbook_supported:
+ class LogbookThreadTrackingHandler(logbook.handlers.Handler):
+ def __init__(self, collector):
+ logbook.handlers.Handler.__init__(self, bubble=True)
+ self.collector = collector
+
+ def emit(self, record):
+ record = {
+ 'message': record.message,
+ 'time': record.time,
+ 'level': record.level_name,
+ 'file': record.filename,
+ 'line': record.lineno,
+ 'channel': record.channel,
+ }
+ self.collector.add_record(record)
+
+
+ logbook_handler = LogbookThreadTrackingHandler(collector)
+ logbook_handler.push_application() # register with logbook
class LoggingPanel(DebugPanel):
name = 'Logging'
has_content = True
def process_request(self, request):
- handler.clear_records()
+ collector.clear_records()
def get_and_delete(self):
- records = handler.get_records()
- handler.clear_records()
+ records = collector.get_records()
+ collector.clear_records()
return records
def nav_title(self):
@@ -57,7 +109,7 @@ class LoggingPanel(DebugPanel):
def nav_subtitle(self):
# FIXME l10n: use ngettext
- return "%s message%s" % (len(handler.get_records()), (len(handler.get_records()) == 1) and '' or 's')
+ return "%s message%s" % (len(collector.get_records()), (len(collector.get_records()) == 1) and '' or 's')
def title(self):
return _('Log Messages')
@@ -66,16 +118,7 @@ class LoggingPanel(DebugPanel):
return ''
def content(self):
- records = []
- for record in self.get_and_delete():
- records.append({
- 'message': record.getMessage(),
- 'time': datetime.datetime.fromtimestamp(record.created),
- 'level': record.levelname,
- 'file': record.pathname,
- 'line': record.lineno,
- })
-
+ records = self.get_and_delete()
context = self.context.copy()
context.update({'records': records})
diff --git a/debug_toolbar/panels/profiling.py b/debug_toolbar/panels/profiling.py
new file mode 100644
index 0000000..68aafb7
--- /dev/null
+++ b/debug_toolbar/panels/profiling.py
@@ -0,0 +1,169 @@
+from __future__ import division
+
+from django.template.loader import render_to_string
+from django.utils.translation import ugettext_lazy as _
+from django.utils.safestring import mark_safe
+from debug_toolbar.panels import DebugPanel
+
+import cProfile
+from pstats import Stats
+from colorsys import hsv_to_rgb
+
+class DjangoDebugToolbarStats(Stats):
+ __root = None
+
+ def get_root_func(self):
+ if self.__root is None:
+ for func, (cc, nc, tt, ct, callers) in self.stats.iteritems():
+ if len(callers) == 0:
+ self.__root = func
+ break
+ return self.__root
+
+ def print_call_tree_node(self, function, depth, max_depth, cum_filter=0.1):
+ self.print_line(function, depth=depth)
+ if depth < max_depth:
+ for called in self.all_callees[function].keys():
+ if self.stats[called][3] >= cum_filter:
+ self.print_call_tree_node(called, depth+1, max_depth, cum_filter=cum_filter)
+
+class FunctionCall(object):
+ def __init__(self, statobj, func, depth=0, stats=None, id=0, parent_ids=[], hsv=(0,0.5,1)):
+ self.statobj = statobj
+ self.func = func
+ if stats:
+ self.stats = stats
+ else:
+ self.stats = statobj.stats[func][:4]
+ self.depth = depth
+ self.id = id
+ self.parent_ids = parent_ids
+ self.hsv = hsv
+
+ def parent_classes(self):
+ return self.parent_classes
+
+ def background(self):
+ r,g,b = hsv_to_rgb(*self.hsv)
+ return 'rgb(%f%%,%f%%,%f%%)' %(r*100, g*100, b*100)
+
+ def func_std_string(self): # match what old profile produced
+ func_name = self.func
+ if func_name[:2] == ('~', 0):
+ # special case for built-in functions
+ name = func_name[2]
+ if name.startswith('<') and name.endswith('>'):
+ return '{%s}' % name[1:-1]
+ else:
+ return name
+ else:
+ file_name, line_num, method = self.func
+ idx = file_name.find('/site-packages/')
+ if idx > -1:
+ file_name=file_name[idx+14:]
+
+ file_path, file_name = file_name.rsplit('/', 1)
+
+ return mark_safe('<span class="path">{0}/</span><span class="file">{1}</span> in <span class="func">{3}</span>(<span class="lineno">{2}</span>)'.format(
+ file_path,
+ file_name,
+ line_num,
+ method,
+ ))
+
+ def subfuncs(self):
+ i=0
+ h,s,v = self.hsv
+ count = len(self.statobj.all_callees[self.func])
+ for func, stats in self.statobj.all_callees[self.func].iteritems():
+ i += 1
+ h1 = h + (i/count)/(self.depth+1)
+ if stats[3] == 0:
+ s1 = 0
+ else:
+ s1 = s*(stats[3]/self.stats[3])
+ yield FunctionCall(self.statobj,
+ func,
+ self.depth+1,
+ stats=stats,
+ id=str(self.id) + '_' + str(i),
+ parent_ids=self.parent_ids + [self.id],
+ hsv=(h1,s1,1))
+
+ def count(self):
+ return self.stats[1]
+
+ def tottime(self):
+ return self.stats[2]
+
+ def cumtime(self):
+ cc, nc, tt, ct = self.stats
+ return self.stats[3]
+
+ def tottime_per_call(self):
+ cc, nc, tt, ct = self.stats
+
+ if nc == 0:
+ return 0
+
+ return tt/nc
+
+ def cumtime_per_call(self):
+ cc, nc, tt, ct = self.stats
+
+ if cc == 0:
+ return 0
+
+ return ct/cc
+
+ def indent(self):
+ return 16 * self.depth
+
+class ProfilingDebugPanel(DebugPanel):
+ """
+ Panel that displays the Django version.
+ """
+ name = 'Profiling'
+ has_content = True
+
+ def nav_title(self):
+ return _('Profiling')
+
+ def url(self):
+ return ''
+
+ def title(self):
+ return _('Profiling')
+
+ def process_view(self, request, view_func, view_args, view_kwargs):
+ self.profiler = cProfile.Profile()
+ args = (request,) + view_args
+ return self.profiler.runcall(view_func, *args, **view_kwargs)
+
+ def process_response(self, request, response):
+ self.profiler.create_stats()
+ self.stats = DjangoDebugToolbarStats(self.profiler)
+ return response
+
+ def add_node(self, func_list, func, max_depth, cum_time=0.1):
+ func_list.append(func)
+ func.has_subfuncs = False
+ if func.depth < max_depth:
+ for subfunc in func.subfuncs():
+ if subfunc.stats[3] >= cum_time:
+ func.has_subfuncs = True
+ self.add_node(func_list, subfunc, max_depth, cum_time=cum_time)
+
+ def content(self):
+
+ self.stats.calc_callees()
+ root = FunctionCall(self.stats, self.stats.get_root_func(), depth=0)
+
+ func_list = []
+ self.add_node(func_list, root, 10, root.stats[3]/8)
+ context = self.context.copy()
+ context.update({
+ 'func_list': func_list,
+ })
+
+ return render_to_string('debug_toolbar/panels/profiling.html', context)
diff --git a/debug_toolbar/panels/sql.py b/debug_toolbar/panels/sql.py
index e1e9bdf..ff63b6f 100644
--- a/debug_toolbar/panels/sql.py
+++ b/debug_toolbar/panels/sql.py
@@ -1,133 +1,60 @@
-from datetime import datetime
-import os
-import sys
-import SocketServer
-import traceback
-
-import django
-from django.conf import settings
-from django.db import connection
-from django.db.backends import util
-from django.views.debug import linebreak_iter
-from django.template import Node
+import re
+import uuid
+
+from django.db.backends import BaseDatabaseWrapper
from django.template.loader import render_to_string
-from django.utils import simplejson
-from django.utils.encoding import force_unicode
-from django.utils.hashcompat import sha_constructor
-from django.utils.translation import ugettext_lazy as _
+from django.utils.html import escape
+from django.utils.safestring import mark_safe
+from django.utils.translation import ugettext_lazy as _, ungettext_lazy as __
+from debug_toolbar.utils.compat.db import connections
+from debug_toolbar.middleware import DebugToolbarMiddleware
from debug_toolbar.panels import DebugPanel
from debug_toolbar.utils import sqlparse
-
-# Figure out some paths
-django_path = os.path.realpath(os.path.dirname(django.__file__))
-socketserver_path = os.path.realpath(os.path.dirname(SocketServer.__file__))
-
-# TODO:This should be set in the toolbar loader as a default and panels should
-# get a copy of the toolbar object with access to its config dictionary
-SQL_WARNING_THRESHOLD = getattr(settings, 'DEBUG_TOOLBAR_CONFIG', {}) \
- .get('SQL_WARNING_THRESHOLD', 500)
-
-def tidy_stacktrace(strace):
- """
- Clean up stacktrace and remove all entries that:
- 1. Are part of Django (except contrib apps)
- 2. Are part of SocketServer (used by Django's dev server)
- 3. Are the last entry (which is part of our stacktracing code)
- """
- trace = []
- for s in strace[:-1]:
- s_path = os.path.realpath(s[0])
- if getattr(settings, 'DEBUG_TOOLBAR_CONFIG', {}).get('HIDE_DJANGO_SQL', True) \
- and django_path in s_path and not 'django/contrib' in s_path:
- continue
- if socketserver_path in s_path:
- continue
- trace.append((s[0], s[1], s[2], s[3]))
- return trace
-
-def get_template_info(source, context_lines=3):
- line = 0
- upto = 0
- source_lines = []
- before = during = after = ""
-
- origin, (start, end) = source
- template_source = origin.reload()
-
- for num, next in enumerate(linebreak_iter(template_source)):
- if start >= upto and end <= next:
- line = num
- before = template_source[upto:start]
- during = template_source[start:end]
- after = template_source[end:next]
- source_lines.append((num, template_source[upto:next]))
- upto = next
-
- top = max(1, line - context_lines)
- bottom = min(len(source_lines), line + 1 + context_lines)
-
- context = []
- for num, content in source_lines[top:bottom]:
- context.append({
- 'num': num,
- 'content': content,
- 'highlight': (num == line),
- })
-
- return {
- 'name': origin.name,
- 'context': context,
- }
-
-class DatabaseStatTracker(util.CursorDebugWrapper):
- """
- Replacement for CursorDebugWrapper which stores additional information
- in `connection.queries`.
- """
- def execute(self, sql, params=()):
- start = datetime.now()
- try:
- return self.cursor.execute(sql, params)
- finally:
- stop = datetime.now()
- duration = ms_from_timedelta(stop - start)
- stacktrace = tidy_stacktrace(traceback.extract_stack())
- _params = ''
- try:
- _params = simplejson.dumps([force_unicode(x, strings_only=True) for x in params])
- except TypeError:
- pass # object not JSON serializable
-
- template_info = None
- cur_frame = sys._getframe().f_back
- try:
- while cur_frame is not None:
- if cur_frame.f_code.co_name == 'render':
- node = cur_frame.f_locals['self']
- if isinstance(node, Node):
- template_info = get_template_info(node.source)
- break
- cur_frame = cur_frame.f_back
- except:
- pass
- del cur_frame
-
- # We keep `sql` to maintain backwards compatibility
- self.db.queries.append({
- 'sql': self.db.ops.last_executed_query(self.cursor, sql, params),
- 'duration': duration,
- 'raw_sql': sql,
- 'params': _params,
- 'hash': sha_constructor(settings.SECRET_KEY + sql + _params).hexdigest(),
- 'stacktrace': stacktrace,
- 'start_time': start,
- 'stop_time': stop,
- 'is_slow': (duration > SQL_WARNING_THRESHOLD),
- 'is_select': sql.lower().strip().startswith('select'),
- 'template_info': template_info,
- })
-util.CursorDebugWrapper = DatabaseStatTracker
+from debug_toolbar.utils.tracking.db import CursorWrapper
+from debug_toolbar.utils.tracking import replace_call
+
+# Inject our tracking cursor
+@replace_call(BaseDatabaseWrapper.cursor)
+def cursor(func, self):
+ result = func(self)
+
+ djdt = DebugToolbarMiddleware.get_current()
+ if not djdt:
+ return result
+ logger = djdt.get_panel(SQLDebugPanel)
+
+ return CursorWrapper(result, self, logger=logger)
+
+def get_isolation_level_display(engine, level):
+ if engine == 'psycopg2':
+ import psycopg2.extensions
+ choices = {
+ psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT: 'Autocommit',
+ psycopg2.extensions.ISOLATION_LEVEL_READ_UNCOMMITTED: 'Read uncommitted',
+ psycopg2.extensions.ISOLATION_LEVEL_READ_COMMITTED: 'Read committed',
+ psycopg2.extensions.ISOLATION_LEVEL_REPEATABLE_READ: 'Repeatable read',
+ psycopg2.extensions.ISOLATION_LEVEL_SERIALIZABLE: 'Serializable',
+ }
+ else:
+ raise ValueError(engine)
+
+ return choices.get(level)
+
+def get_transaction_status_display(engine, level):
+ if engine == 'psycopg2':
+ import psycopg2.extensions
+ choices = {
+ psycopg2.extensions.TRANSACTION_STATUS_IDLE: 'Idle',
+ psycopg2.extensions.TRANSACTION_STATUS_ACTIVE: 'Active',
+ psycopg2.extensions.TRANSACTION_STATUS_INTRANS: 'In transaction',
+ psycopg2.extensions.TRANSACTION_STATUS_INERROR: 'In error',
+ psycopg2.extensions.TRANSACTION_STATUS_UNKNOWN: 'Unknown',
+ }
+ else:
+ raise ValueError(engine)
+
+ return choices.get(level)
class SQLDebugPanel(DebugPanel):
"""
@@ -139,56 +66,148 @@ class SQLDebugPanel(DebugPanel):
def __init__(self, *args, **kwargs):
super(self.__class__, self).__init__(*args, **kwargs)
- self._offset = len(connection.queries)
+ self._offset = dict((k, len(connections[k].queries)) for k in connections)
self._sql_time = 0
+ self._num_queries = 0
self._queries = []
+ self._databases = {}
+ self._transaction_status = {}
+ self._transaction_ids = {}
+
+ def get_transaction_id(self, alias):
+ conn = connections[alias].connection
+ if not conn:
+ return None
+
+ engine = conn.__class__.__module__.split('.', 1)[0]
+ if engine == 'psycopg2':
+ cur_status = conn.get_transaction_status()
+ else:
+ raise ValueError(engine)
+
+ last_status = self._transaction_status.get(alias)
+ self._transaction_status[alias] = cur_status
+
+ if not cur_status:
+ # No available state
+ return None
+
+ if cur_status != last_status:
+ if cur_status:
+ self._transaction_ids[alias] = uuid.uuid4().hex
+ else:
+ self._transaction_ids[alias] = None
+
+ return self._transaction_ids[alias]
+
+ def record(self, alias, **kwargs):
+ self._queries.append((alias, kwargs))
+ if alias not in self._databases:
+ self._databases[alias] = {
+ 'time_spent': kwargs['duration'],
+ 'num_queries': 1,
+ }
+ else:
+ self._databases[alias]['time_spent'] += kwargs['duration']
+ self._databases[alias]['num_queries'] += 1
+ self._sql_time += kwargs['duration']
+ self._num_queries += 1
def nav_title(self):
return _('SQL')
def nav_subtitle(self):
- self._queries = connection.queries[self._offset:]
- self._sql_time = sum([q['duration'] for q in self._queries])
- num_queries = len(self._queries)
# TODO l10n: use ngettext
return "%d %s in %.2fms" % (
- num_queries,
- (num_queries == 1) and 'query' or 'queries',
+ self._num_queries,
+ (self._num_queries == 1) and 'query' or 'queries',
self._sql_time
)
def title(self):
- return _('SQL Queries')
+ count = len(self._databases)
+
+ return __('SQL Queries from %(count)d connection', 'SQL Queries from %(count)d connections', count) % dict(
+ count=count,
+ )
def url(self):
return ''
def content(self):
- width_ratio_tally = 0
- for query in self._queries:
- query['sql'] = reformat_sql(query['sql'])
- try:
- query['width_ratio'] = (query['duration'] / self._sql_time) * 100
- except ZeroDivisionError:
- query['width_ratio'] = 0
- query['start_offset'] = width_ratio_tally
- width_ratio_tally += query['width_ratio']
-
+ if self._queries:
+ width_ratio_tally = 0
+ colors = [
+ (256, 0, 0), # red
+ (0, 256, 0), # blue
+ (0, 0, 256), # green
+ ]
+ factor = int(256.0/(len(self._databases)*2.5))
+ for n, db in enumerate(self._databases.itervalues()):
+ rgb = [0, 0, 0]
+ color = n % 3
+ rgb[color] = 256 - n/3*factor
+ nn = color
+ # XXX: pretty sure this is horrible after so many aliases
+ while rgb[color] < factor:
+ nc = min(256 - rgb[color], 256)
+ rgb[color] += nc
+ nn += 1
+ if nn > 2:
+ nn = 0
+ rgb[nn] = nc
+ db['rgb_color'] = rgb
+
+ trans_ids = {}
+ trans_id = None
+ i = 0
+ for alias, query in self._queries:
+ trans_id = query.get('trans_id')
+ last_trans_id = trans_ids.get(alias)
+
+ if trans_id != last_trans_id:
+ if last_trans_id:
+ self._queries[i-1][1]['ends_trans'] = True
+ trans_ids[alias] = trans_id
+ if trans_id:
+ query['starts_trans'] = True
+ if trans_id:
+ query['in_trans'] = True
+
+ query['alias'] = alias
+ if 'iso_level' in query:
+ query['iso_level'] = get_isolation_level_display(query['engine'], query['iso_level'])
+ if 'trans_status' in query:
+ query['trans_status'] = get_transaction_status_display(query['engine'], query['trans_status'])
+ query['sql'] = reformat_sql(query['sql'])
+ query['rgb_color'] = self._databases[alias]['rgb_color']
+ try:
+ query['width_ratio'] = (query['duration'] / self._sql_time) * 100
+ except ZeroDivisionError:
+ query['width_ratio'] = 0
+ query['start_offset'] = width_ratio_tally
+ query['end_offset'] = query['width_ratio'] + query['start_offset']
+ width_ratio_tally += query['width_ratio']
+
+ stacktrace = []
+ for frame in query['stacktrace']:
+ params = map(escape, frame[0].rsplit('/', 1) + list(frame[1:]))
+ stacktrace.append('<span class="path">{0}/</span><span class="file">{1}</span> in <span class="func">{3}</span>(<span class="lineno">{2}</span>)\n <span class="code">{4}</span>"'.format(*params))
+ query['stacktrace'] = mark_safe('\n'.join(stacktrace))
+ i += 1
+
+ if trans_id:
+ self._queries[i-1][1]['ends_trans'] = True
+
context = self.context.copy()
context.update({
- 'queries': self._queries,
+ 'databases': sorted(self._databases.items(), key=lambda x: -x[1]['time_spent']),
+ 'queries': [q for a, q in self._queries],
'sql_time': self._sql_time,
- 'is_mysql': settings.DATABASE_ENGINE == 'mysql',
})
return render_to_string('debug_toolbar/panels/sql.html', context)
-def ms_from_timedelta(td):
- """
- Given a timedelta object, returns a float representing milliseconds
- """
- return (td.seconds * 1000) + (td.microseconds / 1000.0)
-
class BoldKeywordFilter(sqlparse.filters.Filter):
"""sqlparse filter to bold SQL keywords"""
def process(self, stack, stream):
@@ -197,12 +216,15 @@ class BoldKeywordFilter(sqlparse.filters.Filter):
is_keyword = token_type in sqlparse.tokens.Keyword
if is_keyword:
yield sqlparse.tokens.Text, '<strong>'
- yield token_type, django.utils.html.escape(value)
+ yield token_type, escape(value)
if is_keyword:
yield sqlparse.tokens.Text, '</strong>'
+def swap_fields(sql):
+ return re.sub('SELECT</strong> (.*) <strong>FROM', 'SELECT</strong> <span class="djDebugCollapse">\g<1></span> <strong>FROM', sql)
+
def reformat_sql(sql):
stack = sqlparse.engine.FilterStack()
stack.preprocess.append(BoldKeywordFilter()) # add our custom filter
stack.postprocess.append(sqlparse.filters.SerializerUnicode()) # tokens -> strings
- return ''.join(stack.run(sql))
+ return swap_fields(''.join(stack.run(sql)))
diff --git a/debug_toolbar/panels/template.py b/debug_toolbar/panels/template.py
index d9cd071..44b8b3e 100644
--- a/debug_toolbar/panels/template.py
+++ b/debug_toolbar/panels/template.py
@@ -3,8 +3,6 @@ from pprint import pformat
from django import http
from django.conf import settings
-from django.core.signals import request_started
-from django.dispatch import Signal
from django.template.context import get_standard_processors
from django.template.loader import render_to_string
from django.test.signals import template_rendered
@@ -56,7 +54,7 @@ class TemplateDebugPanel(DebugPanel):
def title(self):
num_templates = len([t for t in self.templates
- if not t['template'].name.startswith('debug_toolbar/')])
+ if not (t['template'].name and t['template'].name.startswith('debug_toolbar/'))])
return _('Templates (%(num_templates)s rendered)') % {'num_templates': num_templates}
def url(self):
@@ -78,7 +76,7 @@ class TemplateDebugPanel(DebugPanel):
# Clean up some info about templates
template = template_data.get('template', None)
# Skip templates that we are generating through the debug toolbar.
- if template.name.startswith('debug_toolbar/'):
+ if template.name and template.name.startswith('debug_toolbar/'):
continue
if template.origin and template.origin.name:
template.origin_name = template.origin.name