Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 181 additions & 66 deletions classes/Backtrace.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,22 @@
}

if ( ! class_exists( 'QM_Backtrace' ) ) {
/**
* @phpstan-type BacktraceArgs array{
* ignore_class?: mixed[],
* ignore_namespace?: mixed[],
* ignore_method?: mixed[],
* ignore_func?: mixed[],
* ignore_hook?: mixed[],
* show_args?: mixed[],
* callsite?: QM_Data_Callsite,
* }
*/
class QM_Backtrace implements JsonSerializable {

/**
* Classes to trim from the top of the stack trace.
*
* @var array<string, bool>
*/
protected static $ignore_class = array(
Expand All @@ -22,7 +35,6 @@ class QM_Backtrace implements JsonSerializable {
'QueryMonitor' => true,
'W3_Db' => true,
'Debug_Bar_PHP' => true,
'WP_Hook' => true,
'Altis\Cloud\DB' => true,
'Yoast\WP\Lib\ORM' => true,
'Perflab_SQLite_DB' => true,
Expand All @@ -39,27 +51,6 @@ class QM_Backtrace implements JsonSerializable {
*/
protected static $ignore_method = array();

/**
* @var array<string, bool>
*/
protected static $ignore_func = array(
'include_once' => true,
'require_once' => true,
'include' => true,
'require' => true,
'call_user_func_array' => true,
'call_user_func' => true,
'trigger_error' => true,
'_doing_it_wrong' => true,
'_deprecated_argument' => true,
'_deprecated_constructor' => true,
'_deprecated_file' => true,
'_deprecated_function' => true,
'_deprecated_hook' => true,
'dbDelta' => true,
'maybe_create_table' => true,
);

/**
* @var array<string, int|string>
*/
Expand Down Expand Up @@ -89,6 +80,10 @@ class QM_Backtrace implements JsonSerializable {
'user_can_for_site' => 5,
'current_user_can_for_site' => 4,
'author_can' => 4,
'include_once' => 'dir',
'require_once' => 'dir',
'include' => 'dir',
'require' => 'dir',
);

/**
Expand Down Expand Up @@ -116,6 +111,26 @@ class QM_Backtrace implements JsonSerializable {
*/
protected $filtered_trace = null;

/**
* The caller frame (first frame after filtering and trimming,
* before the frame 0 drop). Used by get_caller() for attribution.
*
* @var QM_Backtrace_Frame|null
*/
protected $caller_frame = null;

/**
* File/line from the innermost dropped frame. Seeded from frame 0
* and overwritten by trim_top_frames as it removes additional frames.
* Used during serialization to give the top visible frame a location.
*
* @var array{file: string|null, line: int|null}
*/
protected $top_frame_location = array(
'file' => null,
'line' => null,
);

/**
* @var QM_Component|null
*/
Expand All @@ -127,15 +142,7 @@ class QM_Backtrace implements JsonSerializable {
protected $callsite = null;

/**
* @param array{
* ignore_class?: mixed[],
* ignore_namespace?: mixed[],
* ignore_method?: mixed[],
* ignore_func?: mixed[],
* ignore_hook?: mixed[],
* show_args?: mixed[],
* callsite?: QM_Data_Callsite,
* } $args
* @phpstan-param BacktraceArgs $args
* @param mixed[] $trace
*/
public function __construct( array $args = array(), ?array $trace = null ) {
Expand Down Expand Up @@ -216,14 +223,14 @@ public function get_stack() {
*/
public function get_caller() {

$trace = $this->get_filtered_trace();
$frame = reset( $trace );
// Ensure the trace has been processed.
$this->get_filtered_trace();

if ( ! $frame ) {
if ( ! $this->caller_frame ) {
return false;
}

return $frame->to_data();
return $this->caller_frame->to_data();

}

Expand Down Expand Up @@ -340,6 +347,18 @@ public function get_filtered_trace() {
$trace = array_map( array( $this, 'filter_trace' ), $this->trace );
$trace = array_values( array_filter( $trace ) );

// Trim ignored classes and functions from the top of the stack.
$trace = $this->trim_top_frames( $trace );

// Trim include/require frames from the bottom of the stack
// (WordPress bootstrap noise).
$trace = $this->trim_bottom_frames( $trace );

// Store frame 0 as the caller for get_caller() attribution.
if ( ! empty( $trace ) ) {
$this->caller_frame = $trace[0];
}

if ( empty( $trace ) && ! empty( $this->trace ) ) {
$lowest = $this->trace[0];
$file = QM_Util::standard_dir( $lowest['file'], '' );
Expand All @@ -364,6 +383,80 @@ public function get_filtered_trace() {

}

/**
* Trims ignored classes and functions from the top of the stack trace.
*
* @param QM_Backtrace_Frame[] $trace
* @return QM_Backtrace_Frame[]
*/
protected function trim_top_frames( array $trace ): array {
$this->ensure_filters_loaded();

$ignore_class = array_filter( array_merge( self::$ignore_class, $this->args['ignore_class'] ) );
$ignore_func = array_filter( $this->args['ignore_func'] );

while ( ! empty( $trace ) ) {
$frame = $trace[0];

// QM's own classes are always trimmed from the top.
// Their file/line is never useful so don't update top_frame_location.
if ( isset( $frame->class ) && 0 === strpos( $frame->class, 'QM' ) ) {
array_shift( $trace );
continue;
}

if ( isset( $frame->class ) && isset( $ignore_class[ $frame->class ] ) ) {
$this->top_frame_location = array(
'file' => $frame->file,
'line' => $frame->line,
);
array_shift( $trace );
continue;
}

if ( isset( $frame->function ) && isset( $ignore_func[ $frame->function ] ) ) {
$this->top_frame_location = array(
'file' => $frame->file,
'line' => $frame->line,
);
array_shift( $trace );
continue;
}

break;
}

return $trace;
}

/**
* Trims include/require frames from the bottom of the stack trace.
*
* @param QM_Backtrace_Frame[] $trace
* @return QM_Backtrace_Frame[]
*/
protected static function trim_bottom_frames( array $trace ): array {
$include_functions = array(
'include' => true,
'include_once' => true,
'require' => true,
'require_once' => true,
);

while ( ! empty( $trace ) ) {
$frame = end( $trace );

if ( isset( $frame->function ) && isset( $include_functions[ $frame->function ] ) ) {
array_pop( $trace );
continue;
}

break;
}

return $trace;
}

/**
* @param array<int, string> $stack
* @return array<int, string>
Expand Down Expand Up @@ -420,12 +513,11 @@ public function ignore( $num ) {
}

/**
* Formats a single trace frame and determines whether it should be hidden.
* Ensures that the filter hooks for trace configuration have been applied.
*
* @param mixed[] $frame
* @return void
*/
public function filter_trace( array $frame ) :? QM_Backtrace_Frame {

protected static function ensure_filters_loaded(): void {
if ( ! self::$filtered && function_exists( 'did_action' ) && did_action( 'plugins_loaded' ) ) {

/**
Expand Down Expand Up @@ -459,16 +551,6 @@ public function filter_trace( array $frame ) :? QM_Backtrace_Frame {
*/
self::$ignore_method = apply_filters( 'qm/trace/ignore_method', self::$ignore_method );

/**
* Filters which functions to ignore when constructing user-facing call stacks.
*
* @since 2.7.0
*
* @param array<string, bool> $ignore_func Array of function names to ignore. The array keys are function names to ignore,
* the array values are whether to ignore the function (usually true).
*/
self::$ignore_func = apply_filters( 'qm/trace/ignore_func', self::$ignore_func );

/**
* Filters which action and filter names to ignore when constructing user-facing call stacks.
*
Expand All @@ -494,11 +576,18 @@ public function filter_trace( array $frame ) :? QM_Backtrace_Frame {
self::$filtered = true;

}
}

/**
* Formats a single trace frame and determines whether it should be hidden.
*
* @param mixed[] $frame
*/
public function filter_trace( array $frame ) :? QM_Backtrace_Frame {
self::ensure_filters_loaded();

$ignore_class = array_filter( array_merge( self::$ignore_class, $this->args['ignore_class'] ) );
$ignore_namespace = array_filter( array_merge( self::$ignore_namespace, $this->args['ignore_namespace'] ) );
$ignore_method = array_filter( array_merge( self::$ignore_method, $this->args['ignore_method'] ) );
$ignore_func = array_filter( array_merge( self::$ignore_func, $this->args['ignore_func'] ) );
$ignore_hook = array_filter( array_merge( self::$ignore_hook, $this->args['ignore_hook'] ) );
$show_args = array_merge( self::$show_args, $this->args['show_args'] );

Expand All @@ -518,19 +607,17 @@ public function filter_trace( array $frame ) :? QM_Backtrace_Frame {
$id = '';
$display = '';

if ( isset( $frame['class'] ) ) {
if ( isset( $ignore_class[ $frame['class'] ] ) ) {
if ( isset( $frame['class'] ) ) {
// WP_Hook methods are always filtered out. The do_action/apply_filters
// frame below them is retained with its original file/line.
if ( 'WP_Hook' === $frame['class'] ) {
return null;
}

if ( isset( $ignore_method[ $frame['class'] ][ $frame['function'] ] ) ) {
return null;
}

if ( 0 === strpos( $frame['class'], 'QM' ) ) {
return null;
}

foreach ( array_keys( $ignore_namespace ) as $namespace ) {
if ( 0 === strpos( $frame['class'], $namespace . '\\' ) ) {
return null;
Expand All @@ -540,10 +627,6 @@ public function filter_trace( array $frame ) :? QM_Backtrace_Frame {
$id = $frame['class'] . $frame['type'] . $frame['function'];
$display = $id . '()';
} else {
if ( isset( $ignore_func[ $frame['function'] ] ) ) {
return null;
}

foreach ( array_keys( $ignore_namespace ) as $namespace ) {
if ( 0 === strpos( $frame['function'], $namespace . '\\' ) ) {
return null;
Expand All @@ -555,9 +638,8 @@ public function filter_trace( array $frame ) :? QM_Backtrace_Frame {

if ( 'dir' === $show ) {
if ( isset( $frame['args'][0] ) ) {
$arg = QM_Util::standard_dir( $frame['args'][0], '' );
$id = $frame['function'];
$display = $frame['function'] . "('{$arg}')";
$display = QM_Util::standard_dir( $frame['args'][0], '' );
}
} else {
if ( isset( $hook_functions[ $frame['function'] ], $frame['args'][0] ) && is_string( $frame['args'][0] ) && isset( $ignore_hook[ $frame['args'][0] ] ) ) {
Expand Down Expand Up @@ -594,6 +676,13 @@ public function filter_trace( array $frame ) :? QM_Backtrace_Frame {
$result->class = $frame['class'];
}

// Hook dispatch functions keep their original file/line so clicking
// them takes you to the do_action/apply_filters call site, not to
// the internal location where WP_Hook invokes the callback.
if ( isset( $hook_functions[ $frame['function'] ] ) ) {
$result->keep_file_line = true;
}

return $result;

}
Expand All @@ -606,9 +695,35 @@ public function filter_trace( array $frame ) :? QM_Backtrace_Frame {
* }
*/
public function jsonSerialize(): array {
$frames = array_map( static function ( QM_Backtrace_Frame $frame ): QM_Data_Stack_Frame {
return $frame->to_data();
}, $this->get_filtered_trace() );
$trace = $this->get_filtered_trace();
$frames = array();

// Seed the shift from the innermost dropped/trimmed frame's location,
// or from the call site for PHP error traces.
$prev_file = $this->top_frame_location['file'] ?? ( $this->callsite ? $this->callsite->file : null );
$prev_line = $this->top_frame_location['line'] ?? ( $this->callsite ? $this->callsite->line : null );

foreach ( $trace as $frame ) {
$data = $frame->to_data();

if ( $frame->keep_file_line ) {
// Hook dispatch frames (do_action, apply_filters) keep their
// original file/line. Update prev so the frame below gets
// shifted to this location (where it calls the hook).
$prev_file = $frame->file;
$prev_line = $frame->line;
} else {
// Shift file/line up by one frame so each frame shows where it
// makes its call to the frame above, not where it was called from.
$data->file = $prev_file;
$data->line = $prev_line;

$prev_file = $frame->file;
$prev_line = $frame->line;
}

$frames[] = $data;
}

return array(
'component' => $this->get_component(),
Expand Down
Loading
Loading