queries to owners via the caller stack. * * @package Bloatwright */ namespace Bloatwright\Profiler; /** * Walks SAVEQUERIES data. */ class QueryAttributor { /** * Owner resolver. * * @var Owner */ private $owner; /** * Constructor. * * @param Owner $owner Owner resolver. */ public function __construct( Owner $owner ) { $this->owner = $owner; } /** * Attribute queries. * * @param array $queries $wpdb->queries entries: [sql, seconds, caller, start, data]. * @param int $top Slowest queries kept per owner. * @return array{owners: array}>, total:int, total_ms:float} */ public function attribute( array $queries, int $top = 10 ): array { $owners = array(); $total_ms = 0.0; foreach ( $queries as $q ) { if ( ! is_array( $q ) || ! isset( $q[0] ) ) { continue; } $ms = round( (float) ( $q[1] ?? 0 ) * 1000, 3 ); $caller = (string) ( $q[2] ?? '' ); $key = $this->owner_for_stack( $caller ); if ( $this->owner->is_self( $key ) ) { continue; } if ( ! isset( $owners[ $key ] ) ) { $owners[ $key ] = array( 'count' => 0, 'ms' => 0.0, 'slowest' => array(), ); } ++$owners[ $key ]['count']; $owners[ $key ]['ms'] += $ms; $total_ms += $ms; $owners[ $key ]['slowest'][] = array( 'sql' => (string) $q[0], 'ms' => $ms, 'caller' => $caller, ); } foreach ( $owners as &$o ) { usort( $o['slowest'], static function ( $a, $b ) { return $b['ms'] <=> $a['ms']; } ); $o['slowest'] = array_map( static function ( $s ) { return array( 'sql' => self::redact( $s['sql'] ), 'ms' => $s['ms'], 'caller' => self::short_caller( $s['caller'] ), ); }, array_slice( $o['slowest'], 0, $top ) ); $o['ms'] = round( $o['ms'], 3 ); } unset( $o ); return array( 'owners' => $owners, 'total' => count( $queries ), 'total_ms' => round( $total_ms, 3 ), ); } /** * Owner for a wpdb caller string ("a, b, c" from wp_debug_backtrace_summary) or a data-array of frames. * * @param string $caller Caller summary. */ public function owner_for_stack( string $caller ): string { $frames = array_reverse( array_map( 'trim', explode( ',', $caller ) ) ); // Most recent frame first. foreach ( $frames as $frame ) { if ( self::is_wrapper( $frame ) ) { continue; // HookTimer's wrapper only forwards to the callback: never the owner. } $key = $this->owner_for_frame( $frame ); if ( null !== $key && Owner::CORE !== $key ) { return $key; } } return Owner::CORE; } /** * Owner for a single frame like "Foo\Bar->baz", "some_function", "include('/path')", "{closure}", * or since PHP 8.4 "{closure:/path/file.php:12}" and "Foo\Bar->{closure:Foo\Bar::baz():12}". * * @param string $frame Frame. * @return string|null Null when it can't be resolved. */ private function owner_for_frame( string $frame ): ?string { if ( '' === $frame ) { return null; } if ( preg_match( "/^(?:include|require)(?:_once)?\\('(.+)'\\)$/", $frame, $m ) ) { return $this->owner->for_file( (string) $m[1] ); } if ( preg_match( '/^\{closure:(.+):\d+\}$/', $frame, $m ) && false === strpos( $m[1], '::' ) ) { return $this->owner->for_file( (string) $m[1] ); // A closure outside any class names its file. } if ( preg_match( '/^(.+?)(->|::)([A-Za-z0-9_\\\\]*\{closure[^}]*\}|[A-Za-z0-9_]+)$/', $frame, $m ) ) { $class = (string) $m[1]; $method = (string) $m[3]; if ( class_exists( $class, false ) ) { try { $ref = new \ReflectionClass( $class ); if ( false === strpos( $method, '{closure' ) && $ref->hasMethod( $method ) ) { return $this->owner->for_file( (string) $ref->getMethod( $method )->getFileName() ); } return $this->owner->for_file( (string) $ref->getFileName() ); } catch ( \ReflectionException $e ) { return null; } } return null; } $fn = preg_replace( '/\(.*$/', '', $frame ); if ( is_string( $fn ) && function_exists( $fn ) ) { try { $ref = new \ReflectionFunction( $fn ); return $this->owner->for_file( (string) $ref->getFileName() ); } catch ( \ReflectionException $e ) { return null; } } return null; } /** * Replace literals in SQL: single- and double-quoted strings (one left-to-right pass, so a quote * inside the other kind of string cannot unbalance the match), hex literals and numbers. * Backtick-quoted identifiers are kept. * * @param string $sql SQL. */ public static function redact( string $sql ): string { $sql = (string) preg_replace_callback( "/'(?:[^'\\\\]|\\\\.|'')*'|\"(?:[^\"\\\\]|\\\\.|\"\")*\"/s", static function ( $m ) { return $m[0][0] . '?' . $m[0][0]; }, $sql ); $sql = (string) preg_replace( '/\b0x[0-9a-f]+\b/i', '?', $sql ); $sql = (string) preg_replace( '/\b\d+(\.\d+)?\b/', '?', $sql ); $sql = (string) preg_replace( '/\s+/', ' ', $sql ); return trim( substr( $sql, 0, 500 ) ); } /** * Last three frames of a caller summary, without HookTimer's wrapper and with file paths relative * to the wp-content or WordPress folder (a file outside both keeps only its name). * * @param string $caller Caller. */ public static function short_caller( string $caller ): string { $frames = array(); foreach ( array_map( 'trim', explode( ',', $caller ) ) as $frame ) { if ( ! self::is_wrapper( $frame ) ) { $frames[] = self::relative_frame( $frame ); } } return implode( ' → ', array_slice( $frames, -3 ) ); } /** * Whether a frame is the closure HookTimer wraps around each hook callback. * * @param string $frame Frame, e.g. "Bloatwright\Profiler\HookTimer->{closure:...}". */ private static function is_wrapper( string $frame ): bool { return (bool) preg_match( '/^' . preg_quote( HookTimer::class, '/' ) . '(?:->|::).*\{closure/', $frame ); } /** * A frame with the path in "{closure:/path/file.php:12}" (PHP 8.4+) and "include('/path')" made * relative. Core already shortens include paths inside WordPress; the rest are reduced here. * * @param string $frame Frame. */ public static function relative_frame( string $frame ): string { if ( preg_match( '/^\{closure:(.+)(:\d+\})$/', $frame, $m ) && false === strpos( $m[1], '::' ) ) { return '{closure:' . self::relative_path( $m[1] ) . $m[2]; } if ( preg_match( "/^((?:include|require)(?:_once)?\\(')(.+)('\\))$/", $frame, $m ) ) { return $m[1] . self::relative_path( $m[2], true ) . $m[3]; } return $frame; } /** * Path relative to wp-content ("/plugins/x/x.php") or the WordPress folder ("wp-includes/x.php"), * the same forms wp_debug_backtrace_summary() gives include paths; else only the file name. * * @param string $file File path. * @param bool $shortened Whether core may already have shortened it (include frames). */ private static function relative_path( string $file, bool $shortened = false ): string { $norm = wp_normalize_path( $file ); $content = Paths::content(); $root = trailingslashit( Paths::core() ); if ( '' !== $content && 0 === strpos( $norm, $content . '/' ) ) { return substr( $norm, strlen( $content ) ); } if ( '/' !== $root && 0 === strpos( $norm, $root ) ) { return substr( $norm, strlen( $root ) ); } if ( ! preg_match( '#^(/|[A-Za-z]:/)#', $norm ) ) { return $norm; // Relative already. } if ( $shortened && '' !== $content && file_exists( $content . $norm ) ) { return $norm; // Shortened by core: "/plugins/x/x.php". } return wp_basename( $norm ); } }