diff --git a/10up-experience.php b/10up-experience.php index 533ffdc..b4ca8b1 100644 --- a/10up-experience.php +++ b/10up-experience.php @@ -75,19 +75,19 @@ function( $plugin_info, $http_response = null ) { define( 'TENUP_EXPERIENCE_IS_NETWORK', (bool) $network_activated ); if ( ! defined( 'TENUP_DISABLE_BRANDING' ) || ! TENUP_DISABLE_BRANDING ) { - AdminCustomizations\Customizations::instance()->setup(); + AdminCustomizations\Customizations::instance(); } -API\API::instance()->setup(); -Authentication\Usernames::instance()->setup(); -Authors\Authors::instance()->setup(); -Gutenberg\Gutenberg::instance()->setup(); -Headers\Headers::instance()->setup(); -Plugins\Plugins::instance()->setup(); -PostPasswords\PostPasswords::instance()->setup(); -SupportMonitor\Monitor::instance()->setup(); -SupportMonitor\Debug::instance()->setup(); -Notifications\Welcome::instance()->setup(); +API\API::instance(); +Authentication\Usernames::instance(); +Authors\Authors::instance(); +Gutenberg\Gutenberg::instance(); +Headers\Headers::instance(); +Plugins\Plugins::instance(); +PostPasswords\PostPasswords::instance(); +SupportMonitor\Monitor::instance(); +SupportMonitor\Debug::instance(); +Notifications\Welcome::instance(); /** * We load this later to make sure there are no conflicts with other plugins. @@ -95,8 +95,8 @@ function( $plugin_info, $http_response = null ) { add_action( 'plugins_loaded', function() { - Authentication\Passwords::instance()->setup(); - SSO\SSO::instance()->setup(); + Authentication\Passwords::instance(); + SSO\SSO::instance(); } ); diff --git a/includes/classes/SupportMonitor/DBQueryMonitor.php b/includes/classes/SupportMonitor/DBQueryMonitor.php new file mode 100644 index 0000000..e22c582 --- /dev/null +++ b/includes/classes/SupportMonitor/DBQueryMonitor.php @@ -0,0 +1,496 @@ + 1, + 'medium' => 2, + 'low' => 3, + ]; + + /** + * Setup module + */ + public function setup() { + if ( ! $this->is_enabled() ) { + return; + } + + if ( TENUP_EXPERIENCE_IS_NETWORK ) { + add_action( 'network_admin_menu', [ $this, 'register_network_menu' ] ); + } else { + add_action( 'admin_menu', [ $this, 'register_menu' ] ); + } + + add_action( 'admin_init', [ $this, 'empty_queries' ] ); + + add_filter( 'query', [ $this, 'maybe_log_query' ] ); + } + + /** + * Utilitary function to check if the feature is available to be enabled or not. + * + * @return boolean + */ + public function is_available() { + $is_production = 'no' === Monitor::instance()->get_setting( 'production_environment' ); + + /** + * Filter if the Query Monitor should be available. Defaults to false on non-production environments. + * + * If it is available, it's still needed to enable the feature in the dashboard. Having it enabled + * does not mean all queries will be logged, as they will be checked as being + * heavy or not first. + * + * @since x.x + * @hook tenup_experience_disable_query_monitor + * @param {bool} $should_log Whether Query Monitor should be enabled. + * @return {bool} New value + */ + return apply_filters( 'tenup_experience_disable_query_monitor', $is_production ); + } + + /** + * Return whether the submodule is enabled or not. + * + * @return boolean + */ + public function is_enabled() { + $is_enabled = 'yes' === Monitor::instance()->get_setting( 'enable_db_query_monitor' ); + + return $this->is_available() && $is_enabled; + } + + /** + * Registers the Query Monitor link under the 'Tools' menu + * + * @since x.x + */ + public function register_menu() { + add_submenu_page( + 'tools.php', + esc_html__( '10up Query Monitor', 'tenup' ), + esc_html__( '10up Query Monitor', 'tenup' ), + 'manage_options', + 'tenup_query_monitor', + [ $this, 'queries_list_screen' ] + ); + } + + /** + * Registers the Query Monitor link under the network settings + * + * @since x.x + */ + public function register_network_menu() { + add_submenu_page( + 'settings.php', + esc_html__( '10up Query Monitor', 'tenup' ), + esc_html__( '10up Query Monitor', 'tenup' ), + 'manage_network_options', + 'tenup_query_monitor', + [ $this, 'queries_list_screen' ] + ); + } + + /** + * Output the queries screen + * + * @since x.x + */ + public function queries_list_screen() { + $queries_per_date = $this->get_categorized_queries(); + $status_names = array_flip( self::QUERY_CATEGORIES ); + ?> + +
+ options ) ) { + return $query; + } + + // For INSERT, DELETE, UPDATE, and REPLACE queries, only log nested SELECTs or big queries. + if ( preg_match( '/^\s*(insert|delete|update|replace)\s/i', $query ) && + false === strpos( $query, 'select' ) && + strlen( $query ) < self::CUD_QUERY_SIZE ) { + return $query; + } + + /** + * Filter if a specific SQL query should be logged. Defaults to true. + * + * If code reached this filter, it means the query already passed the plugin default checks. + * + * @since x.x + * @hook tenup_experience_log_query + * @param {bool} $should_log Whether the query should be logged or not. + * @param {string} $query The SQL query. + * @return {bool} New value of $should_log + */ + if ( apply_filters( 'tenup_experience_log_query', true, $query ) ) { + $this->log_query( $query ); + } + + return $query; + } + + /** + * Generate the response for the Support Monitor. + * + * Also, as this is called periodically, removes from the transient all old queries. + * + * @see cleanup_queries() + * + * @return bool Whether queries were logged recently or not. + */ + public function get_report() { + $this->cleanup_queries(); + + $all_queries_stored = $this->get_categorized_queries(); + $queries_to_send = $all_queries_stored; + $response = [ + 'trimmed' => false, + 'queries' => [], + ]; + + $queries_str = wp_json_encode( $all_queries_stored ); + while ( mb_strlen( $queries_str ) > self::SEND_API_MAX_SIZE ) { + array_shift( $queries_to_send ); + $response['trimmed'] = true; + $queries_str = wp_json_encode( $queries_to_send ); + } + + $response['queries'] = $all_queries_stored; + + return $response; + } + + /** + * Remove old queries from the transient. + * + * @return void + */ + protected function cleanup_queries() { + $date_limit = strtotime( '7 days ago' ); + + $stored_queries = array_filter( + $this->get_transient(), + function ( $query_date ) use ( $date_limit ) { + return strtotime( $query_date ) > $date_limit; + }, + ARRAY_FILTER_USE_KEY + ); + + $this->set_transient( $stored_queries ); + } + + /** + * Log/store a SQL query + * + * Queries are stored like: + * + * 'YYYY-MM-DD' => [ + * 'a5be998feee8968155052c4d332a7223' => [ // md5 of file:line:query + * 'query' => 'ALTER TABLE wp_my_db_heavy_plugin CHANGE COLUMN `id` id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT', + * 'file' => '/var/www/html/wp-content/plugins/my-db-heavy-plugin/my-db-heavy-plugin.php', + * 'line' => 53, + * 'count' => 3, // how many times the same query was sent in a day + * ] + * ] + * + * @param string $query The SQL query. + */ + protected function log_query( $query ) { + static $stored_queries; + if ( empty( $stored_queries ) ) { + $stored_queries = $this->get_transient( self::TRANSIENT_NAME ) ?? []; + } + + $current_date = date_i18n( 'Y-m-d' ); + + if ( ! isset( $stored_queries[ $current_date ] ) ) { + $stored_queries[ $current_date ] = []; + } + + $main_caller = $this->find_main_caller(); + + $key = md5( + $main_caller['file'] . ':' . + $main_caller['line'] . ':' . + $query + ); + + if ( isset( $stored_queries[ $current_date ][ $key ] ) ) { + $stored_queries[ $current_date ][ $key ]['count']++; + } else { + $stored_queries[ $current_date ][ $key ] = [ + 'query' => $this->escape_query( $query ), + 'file' => $main_caller['file'], + 'line' => $main_caller['line'], + 'count' => 1, + ]; + } + + $this->set_transient( $stored_queries ); + } + + /** + * Based on the debug backtrace, try to find the main caller, i.e., the plugin/theme + * that fired the query. + * + * Simple SQL queries generally come from wp-db.php. dbDelta calls come from upgrade.php. + * We are usually interested in the caller immediately before those. + * + * @return array + */ + protected function find_main_caller() { + $debug_backtrace = debug_backtrace(); // phpcs:ignore + + // Remove this plugin references of the backtrace. + array_shift( $debug_backtrace ); + array_shift( $debug_backtrace ); + + $main_caller = null; + + $wp_db_found = false; + foreach ( $debug_backtrace as $caller ) { + $is_wp_db_file = ( false !== strpos( $caller['file'], 'wp-db.php' ) || false !== strpos( $caller['file'], 'upgrade.php' ) ); + if ( $is_wp_db_file ) { + $wp_db_found = true; + } + if ( ! $wp_db_found || $is_wp_db_file ) { + continue; + } + $main_caller = $caller; + break; + } + + // If a caller was not found simply get the first item of the backtrace. + if ( ! $main_caller ) { + $main_caller = array_shift( $debug_backtrace ); + } + + return $main_caller; + } + + /** + * Escape queries to avoid storing sensitive info. + * + * This function takes INSERTs and UPDATEs and replace all parameter values + * between the `'` and `"` chars with `?` + * + * @param string $query The SQL query. + * @return string + */ + protected function escape_query( $query ) { + if ( ! preg_match( '/UPDATE|INSERT/', $query ) ) { + return $query; + } + + $query = preg_replace( "/[\"'](.*?)[\"']/", '?', $query ); + + return $query; + } + + /** + * Get the categorized and ordered queries list. + * + * Depending on the query, it may be more or less resource expensive. This function gets all + * queries and return them ordered from the more expensive to the less, and then from the ones + * with higher count to the fewer ones. + * + * @return string + */ + protected function get_categorized_queries() { + $queries_per_date = $this->get_transient(); + foreach ( $queries_per_date as &$queries_in_day ) { + array_walk( + $queries_in_day, + function( &$query ) { + if ( preg_match( '/^\s*(create|alter|truncate|drop)\s/i', $query['query'] ) ) { + $query['status'] = self::QUERY_CATEGORIES['medium']; + + if ( $query['count'] > 1 ) { + $query['status'] = self::QUERY_CATEGORIES['high']; + } + } else { + $query['status'] = self::QUERY_CATEGORIES['low']; + } + } + ); + uasort( + $queries_in_day, + function ( $a, $b ) { + if ( $a['status'] === $b['status'] ) { + return $a['count'] >= $b['count'] ? -1 : 1; + } + return $a['status'] <= $b['status'] ? -1 : 1; + } + ); + } + + return $queries_per_date; + } + + /** + * Get the transient value. + * + * @return array + */ + protected function get_transient() { + if ( TENUP_EXPERIENCE_IS_NETWORK ) { + $transient = get_site_transient( self::TRANSIENT_NAME ); + } else { + $transient = get_transient( self::TRANSIENT_NAME ); + } + + $transient = json_decode( $transient, true ); + + if ( ! is_array( $transient ) || JSON_ERROR_NONE !== json_last_error() ) { + $transient = []; + } + + return $transient; + } + + /** + * Set the transient value. + * + * @param array $queries Queries to be stored. + */ + protected function set_transient( $queries ) { + // Order the array, so older entries will be at the start. + ksort( $queries ); + + // JSON objects will be smaller than PHP serialized() ones. + $queries_str = wp_json_encode( $queries ); + + while ( mb_strlen( $queries_str ) > self::STORE_MAX_SIZE ) { + array_shift( $queries ); + $queries_str = wp_json_encode( $queries ); + } + + if ( TENUP_EXPERIENCE_IS_NETWORK ) { + set_site_transient( self::TRANSIENT_NAME, $queries_str ); + } else { + set_transient( self::TRANSIENT_NAME, $queries_str ); + } + } +} diff --git a/includes/classes/SupportMonitor/Monitor.php b/includes/classes/SupportMonitor/Monitor.php index 35fb200..7ee8590 100644 --- a/includes/classes/SupportMonitor/Monitor.php +++ b/includes/classes/SupportMonitor/Monitor.php @@ -15,12 +15,22 @@ * Monitor class */ class Monitor extends Singleton { + /** + * The DBQueryMonitor instance. + * + * @since x.x + * @var DBQueryMonitor + */ + protected $db_query_monitor; + /** * Setup module * * @since 1.7 */ public function setup() { + $this->db_query_monitor = new DBQueryMonitor(); + $this->db_query_monitor->setup(); if ( TENUP_EXPERIENCE_IS_NETWORK ) { add_action( 'wpmu_options', [ $this, 'ms_settings' ] ); @@ -79,6 +89,12 @@ public function ms_save_settings() { $setting['server_url'] = sanitize_text_field( $_POST['tenup_support_monitor_settings']['server_url'] ); } + if ( ! $this->db_query_monitor->is_available() || ! isset( $_POST['tenup_support_monitor_settings']['enable_db_query_monitor'] ) ) { + $setting['enable_db_query_monitor'] = 'no'; + } else { + $setting['enable_db_query_monitor'] = sanitize_text_field( $_POST['tenup_support_monitor_settings']['enable_db_query_monitor'] ); + } + update_site_option( 'tenup_support_monitor_settings', $setting ); } @@ -124,6 +140,14 @@ public function ms_settings() { + db_query_monitor->is_available() ) : ?> ++ +
+ db_query_monitor->is_enabled() ) { + $messages[] = $this->format_message( + $this->db_query_monitor->get_report(), + 'notice', + 'db_queries' + ); + } + $this->send_request( $messages ); }