*/ private $wrapped = array(); /** * Original callable => wrapper (spl id keyed). * * @var array */ private $map = array(); /** * Callable key => whether it takes a parameter by reference (or cannot be inspected). * * @var array */ private $by_ref = array(); /** * Per owner totals: incl_ns, self_ns, calls. * * @var array */ private $totals = array(); /** * Per owner per hook: hook => [ns, calls]. * * @var array> */ private $hooks = array(); /** * Call stack of running owners (for self-time subtraction). * * @var array */ private $stack = array(); /** * Hooks never wrapped (fired before plugins_loaded finished or internal). * * @var string[] */ const SKIP = array( 'all', 'shutdown', 'bloatwright_record', 'gettext', 'gettext_with_context', 'ngettext', 'ngettext_with_context' ); /** * Active? * * @var bool */ private $active = false; /** * Hooks wrapped over the whole request (survives unwrap_all()). * * @var int */ private $hook_count = 0; /** * Constructor. * * @param Owner $owner Owner resolver. */ public function __construct( Owner $owner ) { $this->owner = $owner; } /** * Start listening. */ public function start(): void { $this->active = true; add_action( 'all', array( $this, 'on_all' ), -PHP_INT_MAX ); } /** * Stop; unwrap nothing (request is ending), just stop wrapping new hooks. */ public function stop(): void { $this->active = false; remove_action( 'all', array( $this, 'on_all' ), -PHP_INT_MAX ); } /** * The `all` action: wrap callbacks of the current hook once. * * @param string $hook Hook name. */ public function on_all( $hook ): void { if ( ! $this->active || isset( $this->wrapped[ $hook ] ) || in_array( $hook, self::SKIP, true ) ) { return; } $this->wrapped[ $hook ] = true; ++$this->hook_count; $this->wrap_hook( (string) $hook ); } /** * Stop timing and restore every original callback (shutdown, before other callbacks run). */ public function detach(): void { $this->stop(); $this->unwrap_all(); } /** * Replace each callback with a timing wrapper (in place, same priority and accepted args). * * @param string $hook Hook. */ public function wrap_hook( string $hook ): void { global $wp_filter; if ( ! isset( $wp_filter[ $hook ] ) || ! $wp_filter[ $hook ] instanceof \WP_Hook ) { return; } $wp_hook = $wp_filter[ $hook ]; foreach ( $wp_hook->callbacks as $priority => $callbacks ) { foreach ( $callbacks as $idx => $cb ) { $original = $cb['function']; if ( $original instanceof \Closure && isset( $this->map[ spl_object_hash( $original ) ] ) ) { continue; // Already a wrapper. } if ( is_array( $original ) && isset( $original[0] ) && $original[0] === $this ) { continue; } $owner_key = $this->owner->for_callable( $original ); if ( $this->owner->is_self( $owner_key ) ) { continue; // Never time the profiler itself. } if ( $this->takes_reference( $original ) ) { continue; // The wrapper would pass the argument on as a copy: leave it as it is. } $wrapper = $this->make_wrapper( $original, $owner_key, $hook ); $wp_hook->callbacks[ $priority ][ $idx ]['function'] = $wrapper; $this->map[ spl_object_hash( $wrapper ) ] = array( 'original' => $original, 'wrapper' => $wrapper, 'hook' => $hook, ); } } } /** * Whether a callback takes any parameter by reference, looked up once per callable. The wrapper * receives its arguments as values (func_get_args()), so a by-reference parameter would lose its * changes: such callbacks are never wrapped. Neither is a callable that cannot be inspected. * * @param mixed $callback Callback. */ public function takes_reference( $callback ): bool { if ( is_object( $callback ) ) { $key = 'o:' . spl_object_hash( $callback ); } elseif ( is_string( $callback ) ) { $key = 's:' . strtolower( $callback ); } elseif ( is_array( $callback ) && isset( $callback[0], $callback[1] ) && ( is_object( $callback[0] ) || is_string( $callback[0] ) ) && is_string( $callback[1] ) ) { $key = 'm:' . strtolower( ( is_object( $callback[0] ) ? get_class( $callback[0] ) : $callback[0] ) . '::' . $callback[1] ); } else { return true; } if ( ! isset( $this->by_ref[ $key ] ) ) { $this->by_ref[ $key ] = self::reflect_by_reference( $callback ); } return $this->by_ref[ $key ]; } /** * Reflect a callable's parameters: true when one of them is passed by reference, or when the * callable cannot be reflected. Methods reached through __call() or __callStatic() receive their * arguments as an array of values, so they are inspected as those magic methods. * * @param mixed $callback Closure, function name, 'Class::method', [object|class, method] or invokable object. */ private static function reflect_by_reference( $callback ): bool { try { if ( $callback instanceof \Closure ) { $ref = new \ReflectionFunction( $callback ); } elseif ( is_string( $callback ) && false === strpos( $callback, '::' ) ) { $ref = new \ReflectionFunction( $callback ); } elseif ( is_string( $callback ) ) { list( $class, $method ) = explode( '::', $callback, 2 ); $ref = self::reflect_method( $class, $method ); } elseif ( is_array( $callback ) ) { $ref = self::reflect_method( $callback[0], (string) $callback[1] ); } elseif ( is_object( $callback ) && method_exists( $callback, '__invoke' ) ) { $ref = new \ReflectionMethod( $callback, '__invoke' ); } else { return true; } } catch ( \ReflectionException $e ) { return true; } foreach ( $ref->getParameters() as $param ) { if ( $param->isPassedByReference() ) { return true; } } return false; } /** * Reflection of a method, or of the __call()/__callStatic() method that handles it. * * @param object|string $target Object or class name. * @param string $method Method name. * @throws \ReflectionException When neither the method nor a magic handler exists. */ private static function reflect_method( $target, string $method ): \ReflectionMethod { if ( ! method_exists( $target, $method ) ) { if ( is_object( $target ) && method_exists( $target, '__call' ) ) { return new \ReflectionMethod( $target, '__call' ); } if ( method_exists( $target, '__callStatic' ) ) { return new \ReflectionMethod( $target, '__callStatic' ); } } return new \ReflectionMethod( $target, $method ); } /** * Build a wrapper closure. The fully qualified \call_user_func_array() compiles to a direct call, * so a backtrace taken inside the original callback shows one extra frame: this closure. * * @param mixed $original Original callable. * @param string $owner_key Owner. * @param string $hook Hook. */ private function make_wrapper( $original, string $owner_key, string $hook ): \Closure { $timer = $this; return function () use ( $original, $owner_key, $hook, $timer ) { $args = func_get_args(); $start = hrtime( true ); $timer->push( $owner_key ); try { return \call_user_func_array( $original, $args ); } finally { $elapsed = hrtime( true ) - $start; $timer->pop( $owner_key, $hook, (float) $elapsed ); } }; } /** * Enter a callback. * * @param string $owner_key Owner. */ public function push( string $owner_key ): void { $this->stack[] = array( 'owner' => $owner_key, 'child' => 0.0, ); } /** * Leave a callback and record inclusive/self time. * * @param string $owner_key Owner. * @param string $hook Hook. * @param float $elapsed_ns Inclusive nanoseconds. */ public function pop( string $owner_key, string $hook, float $elapsed_ns ): void { $frame = array_pop( $this->stack ); $child = $frame ? (float) $frame['child'] : 0.0; $self = max( 0.0, $elapsed_ns - $child ); if ( ! isset( $this->totals[ $owner_key ] ) ) { $this->totals[ $owner_key ] = array( 'incl' => 0.0, 'self' => 0.0, 'calls' => 0, ); } $this->totals[ $owner_key ]['incl'] += $elapsed_ns; $this->totals[ $owner_key ]['self'] += $self; ++$this->totals[ $owner_key ]['calls']; if ( ! isset( $this->hooks[ $owner_key ][ $hook ] ) ) { $this->hooks[ $owner_key ][ $hook ] = array( 'ns' => 0.0, 'calls' => 0, ); } $this->hooks[ $owner_key ][ $hook ]['ns'] += $self; ++$this->hooks[ $owner_key ][ $hook ]['calls']; // Charge the inclusive time to the parent frame as child time. $n = count( $this->stack ); if ( $n > 0 ) { $this->stack[ $n - 1 ]['child'] += $elapsed_ns; } } /** * Restore original callbacks (so remove_action() with the original callable keeps working afterwards). */ public function unwrap_all(): void { global $wp_filter; foreach ( array_keys( $this->wrapped ) as $hook ) { if ( ! isset( $wp_filter[ $hook ] ) || ! $wp_filter[ $hook ] instanceof \WP_Hook ) { continue; } foreach ( $wp_filter[ $hook ]->callbacks as $priority => $callbacks ) { foreach ( $callbacks as $idx => $cb ) { $fn = $cb['function']; if ( $fn instanceof \Closure && isset( $this->map[ spl_object_hash( $fn ) ] ) ) { $wp_filter[ $hook ]->callbacks[ $priority ][ $idx ]['function'] = $this->map[ spl_object_hash( $fn ) ]['original']; } } } } $this->wrapped = array(); $this->map = array(); } /** * Original callable for a wrapper (null for anything that is not one of this timer's wrappers). * * @param mixed $wrapper Wrapper. * @return mixed|null */ public function original_of( $wrapper ) { if ( $wrapper instanceof \Closure && isset( $this->map[ spl_object_hash( $wrapper ) ] ) ) { return $this->map[ spl_object_hash( $wrapper ) ]['original']; } return null; } /** * Results in milliseconds. * * @param int $top_hooks Max hooks per owner. * @return array}> */ public function results( int $top_hooks = 50 ): array { $out = array(); foreach ( $this->totals as $key => $t ) { $hooks = $this->hooks[ $key ] ?? array(); uasort( $hooks, static function ( $a, $b ) { return $b['ns'] <=> $a['ns']; } ); $list = array(); foreach ( array_slice( $hooks, 0, $top_hooks, true ) as $hook => $h ) { $list[] = array( 'hook' => (string) $hook, 'ms' => round( $h['ns'] / 1e6, 3 ), 'calls' => (int) $h['calls'], ); } $out[ $key ] = array( 'incl_ms' => round( $t['incl'] / 1e6, 3 ), 'self_ms' => round( $t['self'] / 1e6, 3 ), 'calls' => (int) $t['calls'], 'hooks' => $list, ); } return $out; } /** * Hooks currently wrapped. * * @return string[] */ public function wrapped_hooks(): array { return array_keys( $this->wrapped ); } /** * Hooks wrapped during the request, including ones unwrapped since. */ public function hook_count(): int { return $this->hook_count; } }