Last active
November 1, 2026 01:08
-
-
Save patrickfreitasdev/d82716856dbdd06b04cd3928ae95f444 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * Plugin Name: OOM Elementor Query Trace | |
| * Description: On a memory fatal, log the SELECT wpdb was fetching and who ran it. | |
| */ | |
| if ( ! defined( 'ABSPATH' ) ) { | |
| exit; | |
| } | |
| final class OOM_Query_Trace { | |
| const RESERVE_BYTES = 8388608; | |
| const MAX_QUERIES = 12; | |
| private static $reserve = ''; | |
| private static $queries = array(); | |
| public static function boot() { | |
| if ( ! self::should_trace_request() ) { | |
| return; | |
| } | |
| self::$reserve = str_repeat( 'x', self::RESERVE_BYTES ); | |
| add_filter( 'query', array( __CLASS__, 'capture' ), PHP_INT_MAX ); | |
| register_shutdown_function( array( __CLASS__, 'shutdown' ) ); | |
| } | |
| public static function capture( $query ) { | |
| $sql = (string) $query; | |
| $item = array( | |
| 'bytes' => strlen( $sql ), | |
| 'sql' => self::clip( $sql, 800 ), | |
| 'mem' => memory_get_usage( true ), | |
| 'callers' => array(), | |
| ); | |
| self::$queries[] = $item; | |
| if ( count( self::$queries ) > self::MAX_QUERIES ) { | |
| array_shift( self::$queries ); | |
| } | |
| $index = count( self::$queries ) - 1; | |
| self::$queries[ $index ]['callers'] = self::callers(); | |
| return $query; | |
| } | |
| public static function shutdown() { | |
| self::$reserve = ''; | |
| $err = error_get_last(); | |
| if ( ! is_array( $err ) || empty( $err['message'] ) ) { | |
| return; | |
| } | |
| if ( false === stripos( $err['message'], 'Allowed memory size' ) ) { | |
| return; | |
| } | |
| global $wpdb; | |
| $lines = array(); | |
| $lines[] = '=== ' . gmdate( 'c' ) . ' memory exhausted ==='; | |
| $lines[] = 'message: ' . $err['message']; | |
| $lines[] = 'file: ' . ( isset( $err['file'] ) ? $err['file'] : '' ) . ':' . ( isset( $err['line'] ) ? $err['line'] : '' ); | |
| $lines[] = 'memory_limit: ' . ini_get( 'memory_limit' ); | |
| $lines[] = 'peak: ' . self::bytes( memory_get_peak_usage( true ) ); | |
| $lines[] = 'request: ' . ( isset( $_SERVER['REQUEST_METHOD'] ) ? $_SERVER['REQUEST_METHOD'] : '' ) . ' ' . self::clip( isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '', 500 ); | |
| $lines[] = 'script: ' . ( isset( $_SERVER['SCRIPT_NAME'] ) ? $_SERVER['SCRIPT_NAME'] : '' ); | |
| $lines[] = 'post_action: ' . self::scalar_request( 'action' ); | |
| $lines[] = 'editor_post_id: ' . self::scalar_request( 'editor_post_id' ); | |
| $lines[] = 'post_id: ' . self::scalar_request( 'post_id' ); | |
| $lines[] = self::elementor_actions(); | |
| if ( isset( $GLOBALS['wp_current_filter'] ) && is_array( $GLOBALS['wp_current_filter'] ) ) { | |
| $lines[] = 'current_filter: ' . implode( ' > ', $GLOBALS['wp_current_filter'] ); | |
| } | |
| if ( isset( $wpdb ) && is_object( $wpdb ) ) { | |
| $last = isset( $wpdb->last_query ) ? (string) $wpdb->last_query : ''; | |
| $fetched = ( isset( $wpdb->last_result ) && is_array( $wpdb->last_result ) ) ? count( $wpdb->last_result ) : 0; | |
| $lines[] = 'last_query_bytes: ' . strlen( $last ); | |
| $lines[] = 'rows_fetched_before_crash: ' . $fetched; | |
| $lines[] = 'last_query: ' . self::clip( $last, 1500 ); | |
| if ( isset( $wpdb->result ) && $wpdb->result instanceof mysqli_result ) { | |
| $lines[] = 'mysqli_num_rows: ' . mysqli_num_rows( $wpdb->result ); | |
| $fields = mysqli_fetch_fields( $wpdb->result ); | |
| if ( is_array( $fields ) ) { | |
| $parts = array(); | |
| foreach ( $fields as $field ) { | |
| $parts[] = $field->table . '.' . $field->name . ' max_bytes=' . $field->max_length; | |
| } | |
| $lines[] = 'columns: ' . implode( ' | ', $parts ); | |
| } | |
| } | |
| if ( $fetched > 0 ) { | |
| $lines[] = 'first_row: ' . self::row_profile( $wpdb->last_result[0] ); | |
| if ( $fetched > 1 ) { | |
| $lines[] = 'last_stored_row: ' . self::row_profile( $wpdb->last_result[ $fetched - 1 ] ); | |
| } | |
| } | |
| } | |
| $lines[] = 'recent_queries:'; | |
| $i = 1; | |
| foreach ( self::$queries as $query ) { | |
| $lines[] = $i . '. bytes=' . $query['bytes'] . ' mem=' . self::bytes( $query['mem'] ); | |
| $lines[] = ' sql: ' . $query['sql']; | |
| foreach ( $query['callers'] as $caller ) { | |
| $lines[] = ' at ' . $caller; | |
| } | |
| $i++; | |
| } | |
| $lines[] = ''; | |
| $report = implode( "\n", $lines ); | |
| $dir = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR : dirname( __DIR__ ); | |
| file_put_contents( $dir . '/oom-query-trace.log', $report, FILE_APPEND | LOCK_EX ); | |
| error_log( 'OOM trace written to wp-content/oom-query-trace.log; ' . self::clip( str_replace( "\n", ' | ', $report ), 1800 ) ); | |
| } | |
| private static function should_trace_request() { | |
| $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) $_SERVER['REQUEST_URI'] : ''; | |
| $action = isset( $_REQUEST['action'] ) ? (string) $_REQUEST['action'] : ''; | |
| if ( false !== strpos( $action, 'elementor' ) ) { | |
| return true; | |
| } | |
| foreach ( array( 'wp-admin', 'admin-ajax.php', 'wp-json', 'elementor' ) as $needle ) { | |
| if ( false !== strpos( $uri, $needle ) ) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| private static function callers() { | |
| $frames = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 12 ); | |
| $skip = array( | |
| 'capture', | |
| 'callers', | |
| 'apply_filters', | |
| 'apply_filters_ref_array', | |
| 'query', | |
| '_do_query', | |
| 'get_results', | |
| 'get_row', | |
| 'get_var', | |
| 'get_col', | |
| ); | |
| $out = array(); | |
| foreach ( $frames as $frame ) { | |
| $fn = isset( $frame['function'] ) ? $frame['function'] : ''; | |
| if ( in_array( $fn, $skip, true ) ) { | |
| continue; | |
| } | |
| $file = isset( $frame['file'] ) ? $frame['file'] : ''; | |
| if ( defined( 'ABSPATH' ) && $file && 0 === strpos( $file, ABSPATH ) ) { | |
| $file = substr( $file, strlen( ABSPATH ) ); | |
| } | |
| $class = isset( $frame['class'] ) ? $frame['class'] . $frame['type'] : ''; | |
| $out[] = $file . ':' . ( isset( $frame['line'] ) ? $frame['line'] : '' ) . ' ' . $class . $fn; | |
| if ( count( $out ) >= 6 ) { | |
| break; | |
| } | |
| } | |
| return $out; | |
| } | |
| private static function elementor_actions() { | |
| if ( empty( $_POST['actions'] ) || ! is_string( $_POST['actions'] ) ) { | |
| return 'elementor_actions: (none)'; | |
| } | |
| $raw = $_POST['actions']; | |
| preg_match_all( '/"action"\s*:\s*"([A-Za-z0-9_\\\\]+)"/', substr( $raw, 0, 80000 ), $matches ); | |
| $names = array_slice( array_unique( $matches[1] ), 0, 20 ); | |
| return 'elementor_actions_bytes: ' . strlen( $raw ) . ' names: ' . implode( ',', $names ); | |
| } | |
| private static function row_profile( $row ) { | |
| if ( ! is_object( $row ) ) { | |
| return ''; | |
| } | |
| $parts = array(); | |
| $seen = 0; | |
| foreach ( $row as $key => $value ) { | |
| if ( $seen >= 30 ) { | |
| $parts[] = '...'; | |
| break; | |
| } | |
| $parts[] = $key . '=' . ( is_string( $value ) ? strlen( $value ) . 'b' : gettype( $value ) ); | |
| $seen++; | |
| } | |
| return implode( ', ', $parts ); | |
| } | |
| private static function scalar_request( $key ) { | |
| if ( ! isset( $_REQUEST[ $key ] ) || is_array( $_REQUEST[ $key ] ) ) { | |
| return ''; | |
| } | |
| return self::clip( (string) $_REQUEST[ $key ], 80 ); | |
| } | |
| private static function clip( $text, $limit ) { | |
| $text = (string) $text; | |
| if ( strlen( $text ) <= $limit ) { | |
| return $text; | |
| } | |
| return substr( $text, 0, $limit ) . '…[' . strlen( $text ) . ' bytes]'; | |
| } | |
| private static function bytes( $bytes ) { | |
| return round( $bytes / 1048576, 1 ) . 'MB'; | |
| } | |
| } | |
| OOM_Query_Trace::boot(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment