diff --git a/classes/Dispatcher.php b/classes/Dispatcher.php index 0e1cc827..e56255ad 100644 --- a/classes/Dispatcher.php +++ b/classes/Dispatcher.php @@ -41,6 +41,9 @@ public function __construct( QM_Plugin $qm ) { if ( ! defined( 'QM_EDITOR_COOKIE' ) ) { define( 'QM_EDITOR_COOKIE', 'wp-query_monitor_editor_' . COOKIEHASH ); } + if ( ! defined( 'QM_MAX_DISCOVERED_HOOKS' ) ) { + define( 'QM_MAX_DISCOVERED_HOOKS', 100 ); + } add_action( 'init', array( $this, 'init' ) ); diff --git a/collectors/hooks_discovered.php b/collectors/hooks_discovered.php new file mode 100644 index 00000000..5fd62ce1 --- /dev/null +++ b/collectors/hooks_discovered.php @@ -0,0 +1,206 @@ + + */ +class QM_Collector_Hooks_Discovered extends QM_DataCollector { + + public $id = 'hooks_discovered'; + + public function get_storage(): QM_Data { + return new QM_Data_Hooks_Discovered(); + } + + /** + * @return string + */ + public function name() { + return __( 'Discovered Hooks', 'query-monitor' ); + } + + /** + * @return void + */ + public function set_up() { + parent::set_up(); + + if ( defined( 'QM_DISABLED_HOOK_DISCOVERY' ) && constant( 'QM_DISABLED_HOOK_DISCOVERY' ) ) { + return; + } + + add_action( 'qm/listen/start', array( $this, 'action_listener_start' ) ); + add_action( 'qm/listen/stop', array( $this, 'action_listener_stop' ) ); + add_action( 'shutdown', array( $this, 'action_shutdown' ) ); + } + + /** + * @return void + */ + public function tear_down() { + parent::tear_down(); + + remove_action( 'qm/listen/start', array( $this, 'action_listener_start' ) ); + remove_action( 'qm/listen/stop', array( $this, 'action_listener_stop' ) ); + remove_action( 'shutdown', array( $this, 'action_shutdown' ) ); + } + + /** + * @param string $id + * @return void + */ + public function action_listener_start( string $id ) { + if ( $this->is_active( $id ) ) { + return; + } + + if ( is_array( $this->data->bounds ) && array_key_exists( $id, $this->data->bounds ) ) { + trigger_error( sprintf( + /* translators: %s: Hook discovery ID */ + esc_html__( 'Hook discovery ID `%s` already exists', 'query-monitor' ), + esc_html( $id ), + ), E_USER_NOTICE ); + + return; + } + + $this->maybe_add_all_callback(); + + if ( ! is_array( $this->data->active ) ) { + $this->data->active = array(); + } + + if ( ! is_array( $this->data->hooks ) ) { + $this->data->hooks = array(); + } + + if ( ! is_array( $this->data->counts ) ) { + $this->data->counts = array(); + } + + if ( ! is_array( $this->data->bounds ) ) { + $this->data->bounds = array(); + } + + $this->data->active[ $id ] = 1; + $this->data->hooks[ $id ] = array(); + $this->data->counts[ $id ] = 0; + $this->data->bounds[ $id ] = array( + 'start' => new QM_Backtrace(), + 'stop' => null, + ); + } + + /** + * @param string $id + * @return void + */ + public function action_listener_stop( string $id ) { + if ( ! $this->is_active( $id ) && ! array_key_exists( $id, $this->data->hooks ) ) { + trigger_error( sprintf( + /* translators: %s: Hook discovery ID */ + esc_html__( 'Hook discovery starting bound for `%s` has not been set', 'query-monitor' ), + esc_html( $id ) + ), E_USER_NOTICE ); + + return; + } + + unset( $this->data->active[ $id ] ); + $this->data->bounds[ $id ]['stop'] = new QM_Backtrace(); + + if ( $this->is_active() ) { + return; + } + + remove_filter( 'all', array( $this, 'filter_all' ) ); + } + + /** + * @param mixed $var + * @return mixed + */ + public function filter_all( $var ) { + if ( ! $this->is_active() ) { + remove_filter( 'all', array( $this, 'filter_all' ) ); + + return $var; + } + + if ( in_array( current_action(), array( + 'qm/listen/start', + 'qm/listen/stop', + ) ) ) { + return $var; + } + + global $wp_actions; + + foreach ( array_keys( $this->data->active ) as $id ) { + end( $this->data->hooks[ $id ] ); + $last = current( $this->data->hooks[ $id ] ); + + if ( ! empty( $last ) && current_action() === $last['name'] ) { + $i = absint( key( $this->data->hooks[ $id ] ) ); + $this->data->hooks[ $id ][ $i ]['fires']++; + } else { + $this->data->hooks[ $id ][] = array( + 'name' => current_action(), + 'is_action' => array_key_exists( current_action(), $wp_actions ), + 'fires' => 1, + ); + } + + if ( constant( 'QM_MAX_DISCOVERED_HOOKS' ) < ++$this->data->counts[ $id ] ) { + $this->action_listener_stop( $id ); + $this->data->bounds[ $id ]['terminated'] = true; + } + + return $var; + } + } + + /** + * @return void + */ + public function action_shutdown() { + if ( ! $this->is_active() ) { + return; + } + + foreach ( array_keys( $this->data->active ) as $id ) { + $this->action_listener_stop( $id ); + } + } + + /** + * @param string $id + * @return bool + */ + protected function is_active( string $id = '' ) { + if ( empty( $id ) ) { + return ! empty( $this->data->active ); + } + + return is_array( $this->data->active ) && array_key_exists( $id, $this->data->active ); + } + + /** + * @return void + */ + protected function maybe_add_all_callback() { + if ( $this->is_active() ) { + return; + } + + add_filter( 'all', array( $this, 'filter_all' ) ); + } + +} + +# Load early to catch all hooks +QM_Collectors::add( new QM_Collector_Hooks_Discovered() ); diff --git a/data/hooks_discovered.php b/data/hooks_discovered.php new file mode 100644 index 00000000..d94aeb97 --- /dev/null +++ b/data/hooks_discovered.php @@ -0,0 +1,28 @@ + + */ + public $active; + + /** + * @var array> + */ + public $bounds; + + /** + * @var array + */ + public $counts; + + /** + * @var array>> + */ + public $hooks; +} diff --git a/dispatchers/Html.php b/dispatchers/Html.php index 17de0ee1..48c31d88 100644 --- a/dispatchers/Html.php +++ b/dispatchers/Html.php @@ -663,6 +663,14 @@ protected function after_output() { 'label' => __( 'Allow the wp-content/db.php file symlink to be put into place during activation. Set to false to prevent the symlink creation.', 'query-monitor' ), 'default' => true, ), + 'QM_DISABLED_HOOK_DISCOVERY' => array( + 'label' => __( 'Prevent hook discovery, to safeguard against performance impact in production.', 'query-monitor' ), + 'default' => false, + ), + 'QM_MAX_DISCOVERED_HOOKS' => array( + 'label' => __( 'Maximum number of hooks to discover before terminating.', 'query-monitor' ), + 'default' => 100, + ), ); /** diff --git a/output/html/hooks_discovered.php b/output/html/hooks_discovered.php new file mode 100644 index 00000000..e2a7a6b0 --- /dev/null +++ b/output/html/hooks_discovered.php @@ -0,0 +1,172 @@ + $menu + * @return array + */ + public function action_output_menus( array $menu ) { + $hooks = QM_Collectors::get( 'hooks' ); + + if ( ! $hooks ) { + return $menu; + } + + $menu['qm-hooks']['children'][] = $this->menu( array( + 'title' => $this->name(), + ) ); + + return $menu; + } + + /** + * @return void + */ + public function output() { + /** @var QM_Data_Hooks_Discovered */ + $data = $this->collector->get_data(); + + $types = array( + 'action' => __( 'Action', 'query-monitor' ), + 'filter' => __( 'Filter', 'query-monitor' ), + ); + + if ( empty( $data->hooks ) ) { + $notice = __( 'No discovered hooks.', 'query-monitor' ); + + if ( defined( 'QM_DISABLED_HOOK_DISCOVERY' ) && constant( 'QM_DISABLED_HOOK_DISCOVERY' ) ) { + $notice = __( 'Hook discovery disabled.', 'query-monitor' ); + } + + $this->before_non_tabular_output(); + echo $this->build_notice( $notice ); // WPCS: XSS ok. + $this->after_non_tabular_output(); + + return; + } + + printf( '
', esc_attr( $this->collector->id() ) ); + + echo '
'; + + printf( '', esc_attr( $this->collector->id() ), esc_html( $this->name() ) ); + + echo ''; + echo ''; + printf( '', esc_html__( 'Label', 'query-monitor' ) ); + echo ''; + printf( '', esc_html__( 'Hook', 'query-monitor' ) ); + printf( '', esc_html__( 'Type', 'query-monitor' ) ); + echo ''; + echo ''; + + echo ''; + + foreach ( $data->hooks as $id => $hooks ) { + $trace_text__start = ''; + $trace_text__stop = ''; + + $bounds = $data->bounds[ $id ]; + + if ( is_a( $bounds['start'], QM_Backtrace::class ) ) { + $trace__start = $bounds['start']->get_trace(); + $trace_text__start = self::output_filename( '', $trace__start[0]['file'], $trace__start[0]['line'] ); + } + + $trace_text__stop = sprintf( '
Limit reached', constant( 'QM_MAX_DISCOVERED_HOOKS' ) ); + + if ( empty( $bounds['terminated'] ) && is_a( $bounds['stop'], QM_Backtrace::class ) ) { + $trace__stop = $bounds['stop']->get_trace(); + $trace_text__stop = self::output_filename( '', $trace__stop[0]['file'], $trace__stop[0]['line'] ); + } + + $first = true; + + foreach ( $hooks as $i => $hook ) { + $type = $types['filter']; + + if ( $hook['is_action'] ) { + $type = $types['action']; + } + + printf( '', esc_attr( strtolower( $type ) ) ); + + if ( $first ) { + $first = false; + + printf( + '', absint( ++$i ) ); + + echo ''; + + printf( '', esc_html( $type ) ); + + echo ''; + } + } + + echo ''; + + echo '

%2$s

%s#%s%s
%s%s%s', + absint( count( $hooks ) ), + esc_html( $id ), + $trace_text__start, + $trace_text__stop + ); // WPCS: XSS ok. + } + + printf( '%d'; + printf( '%s', esc_html( $hook['name'] ) ); + if ( 1 < $hook['fires'] ) { + printf( '
Fired %d times', absint( $hook['fires'] ) ); + } + echo '
%s
'; + + echo '
'; + } + +} + +/** + * @param array $output + * @param QM_Collectors $collectors + * @return array + */ +function register_qm_output_html_discovered_hooks( array $output, QM_Collectors $collectors ) { + $collector = QM_Collectors::get( 'hooks_discovered' ); + if ( $collector ) { + $output['hooks_discovered'] = new QM_Output_Html_Hooks_Discovered( $collector ); + } + return $output; +} + +add_filter( 'qm/outputter/html', 'register_qm_output_html_discovered_hooks', 80, 2 );