diff --git a/assets/_styles.scss b/assets/_styles.scss index c08994b2c..1f2408880 100644 --- a/assets/_styles.scss +++ b/assets/_styles.scss @@ -590,7 +590,8 @@ body.admin-color-light #wp-admin-bar-query-monitor:not(.qm-all-clear):not(:hover margin: 20px !important; } - .qm-concerns table { + .qm-concerns table, + .qm-discovered table { border-top: 1px solid $qm-cell-border !important; margin-bottom: 20px !important; } diff --git a/assets/query-monitor.css b/assets/query-monitor.css index 1af641606..6dfc5072d 100644 --- a/assets/query-monitor.css +++ b/assets/query-monitor.css @@ -539,7 +539,8 @@ body.admin-color-light #wp-admin-bar-query-monitor:not(.qm-all-clear):not(:hover font-size: 14px !important; margin: 20px !important; } -#query-monitor-main .qm-concerns table { +#query-monitor-main .qm-concerns table, +#query-monitor-main .qm-discovered table { border-top: 1px solid #e0e0e0 !important; margin-bottom: 20px !important; } diff --git a/classes/Dispatcher.php b/classes/Dispatcher.php index 77b0416c4..2954f2e15 100644 --- a/classes/Dispatcher.php +++ b/classes/Dispatcher.php @@ -15,6 +15,10 @@ public function __construct( QM_Plugin $qm ) { define( 'QM_COOKIE', 'wp-query_monitor_' . COOKIEHASH ); } + if ( ! defined( 'QM_MAX_HOOKS_DISCOVERED' ) ) { + define( 'QM_MAX_HOOKS_DISCOVERED', 500 ); + } + add_action( 'init', array( $this, 'init' ) ); } diff --git a/collectors/hooks_within_bounds.php b/collectors/hooks_within_bounds.php new file mode 100644 index 000000000..078ab445a --- /dev/null +++ b/collectors/hooks_within_bounds.php @@ -0,0 +1,138 @@ +data = array( + 'hooks' => array(), + 'bounds' => array(), + 'counts' => array(), + ); + } + + function action_function_listener_start( $id ) { + if ( $this->is_active( $id ) ) { + return; + } + + if ( 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' ), + '' . $id . '' + ), E_USER_NOTICE ); + return; + } + + $this->maybe_add_all_callback(); + + $this->active[ $id ] = 1; + $this->data['hooks'][ $id ] = array(); + $this->data['bounds'][ $id ] = array( + 'start' => new QM_Backtrace(), + 'stop' => null, + ); + $this->data['counts'][ $id ] = 0; + } + + function action_function_all( $var = null ) { + if ( ! $this->is_active() ) { + remove_action( 'all', array( $this, 'action_function_all' ) ); + return $var; + } + + if ( in_array( current_action(), array( + 'qm/listen/start', + 'qm/listen/stop', + ) ) ) { + return $var; + } + + global $wp_actions; + + foreach ( array_keys( $this->active ) as $id ) { + end( $this->data['hooks'][ $id ] ); + $last = current( $this->data['hooks'][ $id ] ); + + if ( current_action() === $last['hook'] ) { + $i = key( $this->data['hooks'][ $id ] ); + $this->data['hooks'][ $id ][ $i ]['fires']++; + } else { + $this->data['hooks'][ $id ][] = array( + 'hook' => current_action(), + 'is_action' => array_key_exists( current_action(), $wp_actions ), + 'fires' => 1, + ); + } + + if ( QM_MAX_HOOKS_DISCOVERED < ++$this->data['counts'][ $id ] ) { + $this->action_function_listener_stop( $id ); + $this->data['bounds'][ $id ]['was_terminated'] = true; + } + + return $var; + } + } + + function action_function_listener_stop( $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' ), + '' . $id . '' + ), E_USER_NOTICE ); + return; + } + + unset( $this->active[ $id ] ); + $this->data['bounds'][ $id ]['stop'] = new QM_Backtrace(); + + if ( ! $this->is_active() ) { + remove_action( 'all', array( $this, 'action_function_all' ) ); + } + } + + function action_function_shutdown() { + if ( $this->is_active() ) { + foreach ( array_keys( $this->active ) as $id ) { + $this->action_function_listener_stop( $id ); + } + } + } + + function is_active( $id = false ) { + return false !== $id ? array_key_exists( $id, $this->active ) : ! empty( $this->active ); + } + + function maybe_add_all_callback() { + if ( ! $this->is_active() ) { + add_action( 'all', array( $this, 'action_function_all' ), 0 ); + } + } + +} + +# Load early so early hooks can be discovered +QM_Collectors::add( new QM_Collector_Hooks_Within_Bounds() ); diff --git a/dispatchers/Html.php b/dispatchers/Html.php index 495edaf4a..e0dc3395d 100644 --- a/dispatchers/Html.php +++ b/dispatchers/Html.php @@ -381,39 +381,47 @@ protected function after_output() { echo '
'; $constants = array( - 'QM_DB_EXPENSIVE' => array( + 'QM_DB_EXPENSIVE' => array( /* translators: %s: The default value for a PHP constant */ 'label' => __( 'If an individual database query takes longer than this time to execute, it\'s considered "slow" and triggers a warning. Default value: %s.', 'query-monitor' ), 'default' => 0.05, ), - 'QM_DISABLED' => array( + 'QM_DISABLED' => array( 'label' => __( 'Disable Query Monitor entirely.', 'query-monitor' ), 'default' => false, ), - 'QM_DISABLE_ERROR_HANDLER' => array( + 'QM_DISABLE_ERROR_HANDLER' => array( 'label' => __( 'Disable the handling of PHP errors.', 'query-monitor' ), 'default' => false, ), - 'QM_ENABLE_CAPS_PANEL' => array( + 'QM_ENABLE_CAPS_PANEL' => array( 'label' => __( 'Enable the Capability Checks panel.', 'query-monitor' ), 'default' => false, ), - 'QM_HIDE_CORE_ACTIONS' => array( + 'QM_HIDE_CORE_ACTIONS' => array( 'label' => __( 'Hide WordPress core on the Hooks & Actions panel.', 'query-monitor' ), 'default' => false, ), - 'QM_HIDE_SELF' => array( + 'QM_HIDE_SELF' => array( 'label' => __( 'Hide Query Monitor itself from various panels.', 'query-monitor' ), 'default' => false, ), - 'QM_NO_JQUERY' => array( + 'QM_NO_JQUERY' => array( 'label' => __( 'Don\'t specify jQuery as a dependency of Query Monitor. If jQuery isn\'t enqueued then Query Monitor will still operate, but with some reduced functionality.', 'query-monitor' ), 'default' => false, ), - 'QM_SHOW_ALL_HOOKS' => array( + 'QM_SHOW_ALL_HOOKS' => array( 'label' => __( 'In the Hooks & Actions panel, show every hook that has an action or filter attached (instead of every action hook that fired during the request).', 'query-monitor' ), 'default' => false, ), + 'QM_DISABLE_HOOK_DISCOVERY' => array( + 'label' => __( 'Prevent hook discovery, to safeguard against performance degradation in production.' ), + 'default' => false, + ), + 'QM_MAX_HOOKS_DISCOVERED' => array( + 'label' => __( 'Maximum number of hooks to discover before auto-terminating.' ), + 'default' => 300, + ) ); echo '
'; diff --git a/output/html/hooks.php b/output/html/hooks.php index 9bda68c06..8da644523 100644 --- a/output/html/hooks.php +++ b/output/html/hooks.php @@ -96,11 +96,20 @@ public static function output_hook_table( array $hooks ) { echo ''; echo '' . esc_html( $hook['name'] ) . ''; if ( 'all' === $hook['name'] ) { + $_hooks_within_bounds = QM_Collectors::get( 'hooks_within_bounds' ); + $hooks_within_bounds = $_hooks_within_bounds->get_data(); + + $extra = ' Try to avoid using it.'; + + if ( $_hooks_within_bounds && !empty( $hooks_within_bounds['hooks'] ) ) + $extra = '
Remove or disable hook discovery when not actively using.'; // @TODO Link to panel. + echo '
'; printf( /* translators: %s: Action name */ - esc_html__( 'Warning: The %s action is extremely resource intensive. Try to avoid using it.', 'query-monitor' ), - 'all' + esc_html__( 'Warning: The %1$s action is extremely resource intensive.%2$s', 'query-monitor' ), + 'all', + $extra ); echo ''; } diff --git a/output/html/hooks_within_bounds.php b/output/html/hooks_within_bounds.php new file mode 100644 index 000000000..ce56ccff7 --- /dev/null +++ b/output/html/hooks_within_bounds.php @@ -0,0 +1,136 @@ +menu( array( + 'title' => '└ ' . esc_html__( 'Discovered Hooks', 'query-monitor' ), + ) ); + } + + return $menu; + } + + public function output() { + $data = $this->collector->get_data(); + + if ( empty( $data['hooks'] ) ) { + $this->before_non_tabular_output(); + + $_notice = 'No discovered hooks.'; + + if ( defined( 'QM_DISABLE_HOOK_DISCOVERY' ) && QM_DISABLE_HOOK_DISCOVERY ) { + $_notice = 'Hook discovery disabled.'; + } + + $notice = __( $_notice, 'query-monitor' ); + 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__( 'Discovered Hooks', 'query-monitor' ) + ); + + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + + echo ''; + + foreach ( $data['hooks'] as $id => $hooks ) { + $trace_file__start + = $trace_file__stop + = ''; + + $bound__start = $data['bounds'][ $id ]['start']; + $bound__stop = $data['bounds'][ $id ]['stop']; + + if ( is_a( $bound__start, 'QM_Backtrace' ) ) { + $trace__start = $bound__start->get_trace(); + $trace_text__start = self::output_filename( '', $trace__start[0]['file'], $trace__start[0]['line'] ); + } + + if ( !empty( $data['bounds'][ $id ]['was_terminated'] ) ) { + $trace_text__stop = '
Auto-terminated (max hooks)'; + } else if ( is_a( $bound__stop, 'QM_Backtrace' ) ) { + $trace__stop = $bound__stop->get_trace(); + $trace_text__stop = self::output_filename( '', $trace__stop[0]['file'], $trace__stop[0]['line'] ); + } + + foreach ( $hooks as $i => $hook ) { + echo ''; + + if ( 0 === $i ) { + echo ''; + } + + echo ''; + + echo ''; + echo ''; + echo ''; + } + } + + echo ''; + echo '

%2$s

' . esc_html__( 'Label', 'query-monitor' ) . '' . esc_html__( 'Hook', 'query-monitor' ) . '' . esc_html__( 'Type', 'query-monitor' ) . '
'; + echo ''; + echo esc_html( $id ); + echo $trace_text__start; // WPCS: XSS ok. + echo $trace_text__stop; // WPCS: XSS ok. + echo ''; + echo '' . esc_html( ++$i ) . ''; + echo '' . esc_html( $hook['hook'] ) . ''; + + if ( 1 < $hook['fires'] ) { + echo '
Fired ' . esc_html( $hook['fires'] ) . ' times'; + } + echo '
' . ( $hook['is_action'] ? 'Action' : 'Filter' ) . '
'; + + echo '
'; + } + +} + +function register_qm_output_html_hooks_within_bounds( array $output, QM_Collectors $collectors ) { + $collector = $collectors::get( 'hooks_within_bounds' ); + if ( $collector ) { + $output['hooks_within_bounds'] = new QM_Output_Html_Hooks_Within_Bounds( $collector ); + } + return $output; +} + +add_filter( 'qm/outputter/html', 'register_qm_output_html_hooks_within_bounds', 80, 2 );