NEW debugbar: Add backtrace capture for database query failures (#36612)
* debugbar: Add backtrace capture for database query failures Implements backtrace forwarding to capture and display backtraces when database queries fail, making it easier for developers to identify the source of database errors. Backtrace are captured in TraceableDB::endTracing() when queries fail using debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS) and stored to be fowarded to the DolQueryCollector that will expose it to the debugbar integration. Because of that, the backtrace is now captured at the correct location, ie. when the query fails, rather than when the error is retrieved, ensuring it shows the actual application code that triggered the problematic query. This is particularly useful for debugging MySQL to PostgreSQL compatibility issues, module development and maintenance, and quickly identifying query origins during development. In particular, paired with the list and time profiling of each request, it will allow finding the location where queries are not properly optimized. Before this patch, it was possible to try and find snippets from the queries but because queries are generated at runtime, it was tedious and could lead to the wrong location. Related to #34050 * debugbar: Add SQL query backtrace Extends the debugbar SQL widget with backtrace display capabilities. To keep vendor files pristine, a custom widget (TracingSQLQueriesWidget) extends the vendor SQLQueriesWidget using DOM manipulation to inject Dolibarr-specific features after parent render completes. The widget intercepts the parent's data binding callback via wrapDataBinding() to enhance rendered queries with backtrace buttons. This patch pattern depends on php-debugbar internals but might break if the vendor API changes, without breaking the rendering done by php-debugbar. A tracing toggle icon in the status bar allows switching between tracing failed queries only (default, minimal overhead) and tracing all queries. The setting persists via cookie (debugbar_full_tracing) which the PHP backend reads to decide whether to capture backtraces. Custom styles in widgets.css is for the tracing state (eye/eye-slash icons) and so as to format the backtrace display within a frame and using monospace font. Closes #34050
This commit is contained in:
parent
9af8e8037a
commit
163c51ae31
4 changed files with 342 additions and 2 deletions
|
|
@ -71,7 +71,8 @@ class DolQueryCollector extends DataCollector implements Renderable, AssetProvid
|
|||
'memory' => $query['memory_usage'],
|
||||
'is_success' => $query['is_success'],
|
||||
'error_code' => $query['error_code'],
|
||||
'error_message' => $query['error_message']
|
||||
'error_message' => $query['error_message'],
|
||||
'backtrace' => isset($query['backtrace']) ? $query['backtrace'] : null
|
||||
);
|
||||
$totalExecTime += $query['duration'];
|
||||
$totalMemoryUsage += $query['memory_usage'];
|
||||
|
|
|
|||
|
|
@ -377,6 +377,22 @@ class TraceableDB extends DoliDB
|
|||
$this->startMemory = memory_get_usage(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if full query tracing is enabled
|
||||
*
|
||||
* Full tracing captures backtrace for ALL queries, not just failed ones.
|
||||
* This is useful for debugging but has performance impact.
|
||||
*
|
||||
* @return bool True if full tracing is enabled
|
||||
*/
|
||||
protected function isFullTracingEnabled()
|
||||
{
|
||||
if (isset($_COOKIE['debugbar_full_tracing'])) {
|
||||
return $_COOKIE['debugbar_full_tracing'] === '1';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* End query tracing
|
||||
*
|
||||
|
|
@ -391,13 +407,20 @@ class TraceableDB extends DoliDB
|
|||
$endMemory = memory_get_usage(true);
|
||||
$memoryDelta = $endMemory - $this->startMemory;
|
||||
|
||||
// Capture backtrace for failed queries, or if full tracing is enabled
|
||||
$backtrace = null;
|
||||
if (!$resql || $this->isFullTracingEnabled()) {
|
||||
$backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
|
||||
}
|
||||
|
||||
$this->queries[] = array(
|
||||
'sql' => $sql,
|
||||
'duration' => $duration,
|
||||
'memory_usage' => $memoryDelta,
|
||||
'is_success' => $resql ? true : false,
|
||||
'error_code' => $resql ? null : $this->db->lasterrno(),
|
||||
'error_message' => $resql ? null : $this->db->lasterror()
|
||||
'error_message' => $resql ? null : $this->db->lasterror(),
|
||||
'backtrace' => $backtrace
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,3 +46,110 @@ dl.phpdebugbar-widgets-kvlist dd span:nth-of-type(3) {
|
|||
min-width: 80px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Additions for the SQL widget
|
||||
*/
|
||||
|
||||
/* Lightweight tracing icon toggle */
|
||||
div.phpdebugbar-widgets-sqlqueries span.phpdebugbar-widgets-tracing-icon {
|
||||
float: right;
|
||||
margin-left: 8px;
|
||||
color: #888;
|
||||
}
|
||||
div.phpdebugbar-widgets-sqlqueries span.phpdebugbar-widgets-tracing-icon:before {
|
||||
font-family: "Font Awesome 5 Free" !important;
|
||||
font-weight: 900;
|
||||
margin-right: 4px;
|
||||
font-size: 12px;
|
||||
content: "\f070";
|
||||
}
|
||||
div.phpdebugbar-widgets-sqlqueries span.phpdebugbar-widgets-tracing-icon.phpdebugbar-widgets-tracing-enabled:before {
|
||||
content: "\f06e";
|
||||
}
|
||||
div.phpdebugbar-widgets-sqlqueries span.phpdebugbar-widgets-tracing-icon.phpdebugbar-widgets-tracing-enabled {
|
||||
color: #f0ad4e;
|
||||
}
|
||||
div.phpdebugbar-widgets-sqlqueries span.phpdebugbar-widgets-tracing-icon:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Show backtrace button */
|
||||
div.phpdebugbar-widgets-sqlqueries span.phpdebugbar-widgets-show-backtrace,
|
||||
div.phpdebugbar-widgets-sqlqueries span.phpdebugbar-widgets-copy-clipboard {
|
||||
float: right;
|
||||
margin-left: 8px;
|
||||
color: #888;
|
||||
}
|
||||
div.phpdebugbar-widgets-sqlqueries div.phpdebugbar-widgets-status span.phpdebugbar-widgets-tracing-icon,
|
||||
div.phpdebugbar-widgets-sqlqueries div.phpdebugbar-widgets-status span.phpdebugbar-widgets-show-backtrace,
|
||||
div.phpdebugbar-widgets-sqlqueries div.phpdebugbar-widgets-status span.phpdebugbar-widgets-copy-clipboard {
|
||||
color: #555;
|
||||
}
|
||||
div.phpdebugbar-widgets-sqlqueries span.phpdebugbar-widgets-tracing-icon:before,
|
||||
div.phpdebugbar-widgets-sqlqueries span.phpdebugbar-widgets-show-backtrace:before,
|
||||
div.phpdebugbar-widgets-sqlqueries span.phpdebugbar-widgets-copy-clipboard:before {
|
||||
font-family: "Font Awesome 5 Free" !important;
|
||||
font-weight: 900;
|
||||
margin-right: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
div.phpdebugbar-widgets-sqlqueries span.phpdebugbar-widgets-show-backtrace:before {
|
||||
content: "\f0cb";
|
||||
}
|
||||
div.phpdebugbar-widgets-sqlqueries span.phpdebugbar-widgets-show-backtrace.active {
|
||||
color: #d9534f;
|
||||
}
|
||||
div.phpdebugbar-widgets-sqlqueries span.phpdebugbar-widgets-show-backtrace:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Backtrace styling */
|
||||
div.phpdebugbar-widgets-sqlqueries div.phpdebugbar-widgets-backtrace-container {
|
||||
margin: 10px 0;
|
||||
padding: 8px;
|
||||
background: #f9f9f9;
|
||||
border-left: 3px solid #d9534f;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
div.phpdebugbar-widgets-sqlqueries div.phpdebugbar-widgets-backtrace-header {
|
||||
font-weight: bold;
|
||||
color: #d9534f;
|
||||
margin-bottom: 5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
div.phpdebugbar-widgets-sqlqueries span.phpdebugbar-widgets-backtrace-toggle {
|
||||
font-weight: normal;
|
||||
color: #666;
|
||||
font-size: 11px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
div.phpdebugbar-widgets-sqlqueries div.phpdebugbar-widgets-backtrace-list {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
div.phpdebugbar-widgets-sqlqueries div.phpdebugbar-widgets-backtrace-frame {
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
div.phpdebugbar-widgets-sqlqueries div.phpdebugbar-widgets-backtrace-frame:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
div.phpdebugbar-widgets-sqlqueries span.phpdebugbar-widgets-backtrace-function {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
div.phpdebugbar-widgets-sqlqueries span.phpdebugbar-widgets-backtrace-filename {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,16 @@
|
|||
|
||||
var csscls = PhpDebugBar.utils.makecsscls('phpdebugbar-');
|
||||
|
||||
// Configuration constants for TracingSQLQueriesWidget
|
||||
var MAX_BACKTRACE_FRAMES = 64;
|
||||
var COOKIE_MAX_AGE_SECONDS = 86400 * 30; // 30 days
|
||||
var INTERNAL_DB_CLASSES = ['TraceableDB', 'DoliDB'];
|
||||
var INTERNAL_DB_FUNCTIONS = ['query', 'startTracing', 'endTracing'];
|
||||
|
||||
function isFullTracingEnabled() {
|
||||
return document.cookie.includes('debugbar_full_tracing=1');
|
||||
}
|
||||
|
||||
/**
|
||||
* TooltipIndicator
|
||||
*
|
||||
|
|
@ -96,4 +106,203 @@
|
|||
});
|
||||
|
||||
|
||||
/**
|
||||
* TracingSQLQueriesWidget
|
||||
*
|
||||
* Extends the vendor SQLQueriesWidget by calling parent render() and then enhancing
|
||||
* the rendered DOM with Dolibarr-specific features:
|
||||
* - Lightweight tracing toggle icon (eye icon in status bar)
|
||||
* - Backtrace display with filtering (skip internal DB frames)
|
||||
* - Collapsible backtrace button (icon-only, positioned before copy button)
|
||||
*
|
||||
* This DOM manipulation approach avoids code duplication and inherits all parent functionality.
|
||||
*/
|
||||
var TracingSQLQueriesWidget = PhpDebugBar.Widgets.SQLQueriesWidget.extend({
|
||||
|
||||
render: function() {
|
||||
// Wrap bindAttr BEFORE calling parent render.
|
||||
// Parent's render() calls bindAttr('data', ...), so we must intercept it first.
|
||||
this.wrapDataBinding(function(data) {
|
||||
this.addTracingIcon();
|
||||
if (data && data.statements) {
|
||||
this.renderBacktraceInQueries(data.statements);
|
||||
}
|
||||
});
|
||||
|
||||
// Call parent render() to create the base widget structure
|
||||
TracingSQLQueriesWidget.__super__.render.apply(this);
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds the tracing toggle icon to the status bar.
|
||||
* Creates an eye icon that toggles between tracing failed queries only
|
||||
* and tracing all queries.
|
||||
*/
|
||||
addTracingIcon: function() {
|
||||
var self = this;
|
||||
var isTracingEnabled = isFullTracingEnabled();
|
||||
|
||||
this.$tracingButton = $('<span />')
|
||||
.addClass(csscls('widgets-tracing-icon'))
|
||||
.attr('title', isTracingEnabled ? 'Tracing ALL queries (click to trace failed only)' : 'Tracing failed queries only (click to trace all)')
|
||||
.css('cursor', 'pointer')
|
||||
.toggleClass(csscls('widgets-tracing-enabled'), isTracingEnabled)
|
||||
.appendTo(this.$status);
|
||||
|
||||
if (!this.$status.data('tracing-icon-bound')) {
|
||||
this.$status.on('click', '.' + csscls('widgets-tracing-icon'), function() {
|
||||
self.toggleTracing();
|
||||
});
|
||||
this.$status.data('tracing-icon-bound', true);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Wraps the parent's bindAttr method to intercept data binding and
|
||||
* extend it after the rendering of the parent.
|
||||
*/
|
||||
wrapDataBinding: function(postCallback) {
|
||||
var self = this;
|
||||
var parentBindAttr = this.bindAttr;
|
||||
|
||||
this.bindAttr = function(attr, callback) {
|
||||
if (attr === 'data') {
|
||||
// Wrap the data callback in a new function to modify
|
||||
// queries after parent renders them
|
||||
var originalCallback = callback;
|
||||
callback = function(data) {
|
||||
// Call parent's data handler first
|
||||
originalCallback.call(this, data);
|
||||
|
||||
// Call the provided post-callback
|
||||
if (postCallback) {
|
||||
postCallback.call(self, data);
|
||||
}
|
||||
};
|
||||
}
|
||||
return parentBindAttr.call(this, attr, callback);
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Complete rendered query list items with backtrace buttons.
|
||||
* For queries that have backtrace data, adds a collapsible backtrace view.
|
||||
*
|
||||
* @param {Array<Object>} statements - Array of query statement objects from collector
|
||||
*/
|
||||
renderBacktraceInQueries: function(statements) {
|
||||
/* The query records have already been rendered by the parent class.
|
||||
* Iterate through rendered query list items to add the backtrace panel
|
||||
* and the button to display it. */
|
||||
this.$list.$el.find('li').each(function(index) {
|
||||
var stmt = statements[index];
|
||||
if (!stmt || !stmt.backtrace || stmt.backtrace.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
var $li = $(this);
|
||||
|
||||
// Skip if backtrace button already exists
|
||||
if ($li.find('.' + csscls('widgets-show-backtrace')).length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert backtrace button after copy button
|
||||
// (both have float:right, so "after" in DOM = "before" visually)
|
||||
var $copyBtn = $li.find('.' + csscls('widgets-copy-clipboard'));
|
||||
var $showBacktraceBtn = $('<span title="Show backtrace" />')
|
||||
.addClass(csscls('widgets-show-backtrace'))
|
||||
.css('cursor', 'pointer')
|
||||
.insertAfter($copyBtn);
|
||||
|
||||
// Create hidden backtrace container
|
||||
var $backtraceContainer = $('<div />')
|
||||
.addClass(csscls('widgets-backtrace-container'))
|
||||
.hide()
|
||||
.appendTo($li);
|
||||
|
||||
$('<div />')
|
||||
.addClass(csscls('widgets-backtrace-header'))
|
||||
.html('<strong>Backtrace:</strong>')
|
||||
.appendTo($backtraceContainer);
|
||||
|
||||
var $backtraceList = $('<div />')
|
||||
.addClass(csscls('widgets-backtrace-list'))
|
||||
.appendTo($backtraceContainer);
|
||||
|
||||
// Filter out internal database frames
|
||||
var filteredFrames = stmt.backtrace.filter(function(frame) {
|
||||
if (frame.class) {
|
||||
for (var i = 0; i < INTERNAL_DB_CLASSES.length; i++) {
|
||||
if (frame.class.indexOf(INTERNAL_DB_CLASSES[i]) >= 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (INTERNAL_DB_FUNCTIONS.indexOf(frame.function) >= 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Now we can render backtrace frames
|
||||
filteredFrames.slice(0, MAX_BACKTRACE_FRAMES).forEach(function(frame) {
|
||||
var $frameLine = $('<div />')
|
||||
.addClass(csscls('widgets-backtrace-frame'))
|
||||
.appendTo($backtraceList);
|
||||
|
||||
// Format call signature
|
||||
var call = frame.class
|
||||
? frame.class + (frame.type || '::') + (frame.function || '')
|
||||
: frame.function || '';
|
||||
|
||||
if (call) {
|
||||
$('<span />')
|
||||
.addClass(csscls('widgets-backtrace-function'))
|
||||
.text(call + '()')
|
||||
.appendTo($frameLine);
|
||||
}
|
||||
|
||||
if (frame.file) {
|
||||
var fileDisplay = frame.file;
|
||||
$('<span />')
|
||||
.addClass(csscls('widgets-backtrace-filename'))
|
||||
.text(fileDisplay + (frame.line ? ':' + frame.line : ''))
|
||||
.appendTo($frameLine);
|
||||
}
|
||||
});
|
||||
|
||||
/* Setup toggle button event handler to show the backtrace */
|
||||
$showBacktraceBtn.on('click', function(event) {
|
||||
if ($backtraceContainer.is(':visible')) {
|
||||
$backtraceContainer.hide();
|
||||
$(this).removeClass('active').attr('title', 'Show backtrace');
|
||||
} else {
|
||||
$backtraceContainer.show();
|
||||
$(this).addClass('active').attr('title', 'Hide backtrace');
|
||||
}
|
||||
event.stopPropagation();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggles full tracing mode and persists the setting.
|
||||
* Prompts user to reload the page to apply changes.
|
||||
*/
|
||||
toggleTracing: function() {
|
||||
var newState = !isFullTracingEnabled();
|
||||
document.cookie = 'debugbar_full_tracing=' + (newState ? '1' : '0') +
|
||||
'; path=/; max-age=' + COOKIE_MAX_AGE_SECONDS + '; SameSite=Lax';
|
||||
|
||||
this.$tracingButton
|
||||
.attr('title', newState ? 'Tracing all queries (click to trace failed only)' : 'Tracing failed queries only (click to trace all)')
|
||||
.toggleClass(csscls('widgets-tracing-enabled'), newState);
|
||||
}
|
||||
});
|
||||
|
||||
/* We need to replace the global widget class with our version so as to
|
||||
* override the query rendering */
|
||||
PhpDebugBar.Widgets.SQLQueriesWidget = TracingSQLQueriesWidget;
|
||||
|
||||
})(PhpDebugBar.$);
|
||||
|
|
|
|||
Loading…
Reference in a new issue