ctx = $ctx; $this->start_ns = defined( 'WP_START_TIMESTAMP' ) ? (float) WP_START_TIMESTAMP : microtime( true ); // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- core/caching-plugin constants set on purpose for this one profiled request. if ( ! defined( 'SAVEQUERIES' ) ) { define( 'SAVEQUERIES', true ); } if ( ! defined( 'DONOTCACHEPAGE' ) ) { define( 'DONOTCACHEPAGE', true ); } if ( ! defined( 'DONOTCACHEOBJECT' ) ) { define( 'DONOTCACHEOBJECT', true ); } // phpcs:enable if ( ! empty( $ctx['exclude'] ) && 0 === validate_file( (string) $ctx['exclude'] ) ) { define( 'BLOATWRIGHT_PROFILING_EXCLUDE', (string) $ctx['exclude'] ); add_filter( 'option_active_plugins', array( $this, 'filter_active_plugins' ), 0 ); add_filter( 'site_option_active_sitewide_plugins', array( $this, 'filter_sitewide_plugins' ), 0 ); // The filtered lists must never be saved (validate_active_plugins() on plugins.php, for // one, would really deactivate the excluded plugin): no write to them in this request. add_filter( 'pre_update_option_active_plugins', array( $this, 'keep_plugin_list' ), PHP_INT_MAX, 2 ); add_filter( 'pre_update_option', array( $this, 'keep_active_plugins' ), PHP_INT_MAX, 3 ); add_filter( 'pre_update_site_option_active_sitewide_plugins', array( $this, 'keep_plugin_list' ), PHP_INT_MAX, 2 ); } $overrides = (array) get_option( 'bloatwright_overrides', array() ); $this->owner = new Owner( $overrides ); $this->hooks = new HookTimer( $this->owner ); $this->includes = new IncludeTimer( $this->owner ); $this->assets = new AssetCollector( $this->owner ); $this->includes->start(); $this->hooks->start(); $this->assets->start(); add_filter( 'nocache_headers', array( $this, 'nocache' ) ); add_action( 'wp_footer', array( $this, 'marker' ), PHP_INT_MAX ); add_action( 'admin_footer', array( $this, 'marker' ), PHP_INT_MAX ); // Hand every hook its original callback back before other shutdown work runs; counters survive. add_action( 'shutdown', array( $this->hooks, 'detach' ), PHP_INT_MIN ); add_action( 'shutdown', array( $this, 'finish' ), PHP_INT_MAX ); // Every profiled response is sent with no-cache headers, logged in or not: send_headers on the // front end (after WordPress chose its own headers), admin_init in the admin. add_action( 'send_headers', array( $this, 'send_headers' ), PHP_INT_MAX ); add_action( 'admin_init', array( $this, 'send_headers' ), PHP_INT_MIN ); } /** * Remove the excluded plugin for this request only. * * @param mixed $plugins Active plugins. * @return mixed */ public function filter_active_plugins( $plugins ) { if ( ! is_array( $plugins ) ) { return $plugins; } $exclude = (string) $this->ctx['exclude']; return array_values( array_filter( $plugins, static function ( $p ) use ( $exclude ) { return $p !== $exclude; } ) ); } /** * Network plugins variant. * * @param mixed $plugins Plugins. * @return mixed */ public function filter_sitewide_plugins( $plugins ) { if ( is_array( $plugins ) ) { unset( $plugins[ (string) $this->ctx['exclude'] ] ); } return $plugins; } /** * Hand update_option() / update_site_option() the value it already has, so the call is a no-op. * Their old value comes through get_option() / get_site_option(), so it is the filtered list, * which is exactly what an update would otherwise save. * * @param mixed $value New value. * @param mixed $old_value Current (filtered) value. * @return mixed */ public function keep_plugin_list( $value, $old_value ) { return $old_value; } /** * The same for active_plugins on the generic pre_update_option filter, which runs after the * option-specific one. * * @param mixed $value New value. * @param string $option Option name. * @param mixed $old_value Current (filtered) value. * @return mixed */ public function keep_active_plugins( $value, $option, $old_value ) { return 'active_plugins' === $option ? $old_value : $value; } /** * Force no-cache headers. * * @param array $headers Headers. */ public function nocache( $headers ): array { $headers = (array) $headers; $headers['Cache-Control'] = 'no-cache, must-revalidate, max-age=0, no-store'; return $headers; } /** * No-cache headers for this profiled response, plus a marker header so the loopback can tell the * page was really profiled (not served from a cache). */ public function send_headers(): void { if ( headers_sent() ) { return; } nocache_headers(); // Runs through the nocache_headers filter above. header( 'X-BLOATWRIGHT-Profiled: 1' ); } /** * HTML marker. */ public function marker(): void { echo "\n\n"; } /** * Context. */ public function context(): array { return $this->ctx; } /** * Owner resolver. */ public function owner(): Owner { return $this->owner; } /** * Build the per-owner record (without writing). * * The request's own figures (total time, peak memory, its queries) are taken first: nothing after * that, including the optional HEAD requests for remote asset sizes and the transients they * write, is part of the measured request. */ public function collect(): array { global $wpdb; $total_ms = round( ( microtime( true ) - $this->start_ns ) * 1000, 2 ); $peak_kb = (int) round( memory_get_peak_usage( true ) / 1024 ); $queries = is_array( $wpdb->queries ) ? $wpdb->queries : array(); $this->hooks->stop(); $this->assets->collect(); $this->assets->resolve_remote_sizes(); // HEAD requests (opt-in): after the timings above. $hook_r = $this->hooks->results( 50 ); $inc_r = $this->includes->results(); $asset_r = $this->assets->results(); $query_r = ( new QueryAttributor( $this->owner ) )->attribute( $queries, 10 ); $keys = array_diff( array_unique( array_merge( array_keys( $hook_r ), array_keys( $inc_r ), array_keys( $asset_r['owners'] ), array_keys( $query_r['owners'] ) ) ), $this->owner->self_keys() ); $owners = array(); foreach ( $keys as $key ) { $h = $hook_r[ $key ] ?? array( 'incl_ms' => 0.0, 'self_ms' => 0.0, 'calls' => 0, 'hooks' => array(), ); $a = $asset_r['owners'][ $key ] ?? array( 'bytes' => 0, 'inline' => 0, 'count' => 0, 'unknown' => 0, 'blocking' => 0, 'blocking_bytes' => 0, 'assets' => array(), ); $q = $query_r['owners'][ $key ] ?? array( 'count' => 0, 'ms' => 0.0, 'slowest' => array(), ); $owners[ $key ] = array( 'php_ms_incl' => $h['incl_ms'], 'php_ms_self' => round( $h['self_ms'] + ( $inc_r[ $key ] ?? 0.0 ), 3 ), 'includes_ms' => $inc_r[ $key ] ?? 0.0, 'calls' => $h['calls'], 'hooks' => $h['hooks'], 'queries' => $q, 'assets' => array_map( static function ( $x ) { unset( $x['owner'] ); return $x; }, $a['assets'] ), 'asset_bytes' => $a['bytes'] + $a['inline'], 'assets_unknown' => $a['unknown'], 'blocking' => $a['blocking'], 'blocking_bytes' => $a['blocking_bytes'], ); } $calibration = $this->calibrate(); return array( 'owners' => $owners, 'descriptors' => array_intersect_key( $this->owner->all(), array_flip( $keys ) ), 'duplicates' => $asset_r['duplicates'], 'meta' => array( 'url' => (string) ( $this->ctx['url'] ?? '' ), 'pass' => (int) ( $this->ctx['pass'] ?? 0 ), 'is_admin' => is_admin(), 'total_ms' => $total_ms, 'peak_mem_kb' => $peak_kb, 'query_count' => (int) $query_r['total'], 'query_ms' => $query_r['total_ms'], 'hooks' => $this->hooks->hook_count(), 'overhead_ms' => $calibration, 'object_cache' => (bool) wp_using_ext_object_cache(), 'savequeries' => defined( 'SAVEQUERIES' ) && constant( 'SAVEQUERIES' ), // False when wp-config.php turned it off: no queries recorded. 'exclude' => (string) ( $this->ctx['exclude'] ?? '' ), 'php' => PHP_VERSION, ), ); } /** * Estimate wrapper overhead: time an empty wrapped callback N times. */ private function calibrate(): float { $n = 2000; $start = hrtime( true ); for ( $i = 0; $i < $n; $i++ ) { $this->hooks->push( 'core' ); $this->hooks->pop( 'core', '__bloatwright_calibration', 0.0 ); } $per_call = ( hrtime( true ) - $start ) / $n; // ns. $calls = 0; foreach ( $this->hooks->results( 1 ) as $r ) { $calls += (int) $r['calls']; } return round( ( $per_call * max( 0, $calls - $n ) ) / 1e6, 3 ); } /** * Shutdown: flush output, then record. */ public function finish(): void { if ( $this->recorded ) { return; } $this->recorded = true; if ( function_exists( 'fastcgi_finish_request' ) && ! ( defined( 'BLOATWRIGHT_TESTS' ) && constant( 'BLOATWRIGHT_TESTS' ) ) ) { fastcgi_finish_request(); } $record = $this->collect(); unset( $record['owners']['__bloatwright_calibration'] ); if ( ! empty( $this->ctx['session'] ) && $this->recordable() ) { ( new Recorder() )->store( (int) $this->ctx['session'], Recorder::clean_url( (string) ( $this->ctx['url'] ?? '' ) ), (int) ( $this->ctx['pass'] ?? 0 ), $record['meta'], $record ); } do_action( 'bloatwright_record', $record, $this->ctx ); } /** * Warm-up passes, redirect hops and server errors are measured but never stored. */ private function recordable(): bool { if ( (int) ( $this->ctx['pass'] ?? 0 ) < 1 ) { return false; } $code = http_response_code(); if ( ! is_int( $code ) || 0 === $code ) { return true; // CLI/tests. } return $code < 300 || ( $code >= 400 && $code < 500 ); } /** * Reset (tests). */ public static function reset(): void { if ( self::$instance ) { self::$instance->hooks->stop(); self::$instance->hooks->unwrap_all(); self::$instance->includes->stop(); remove_action( 'shutdown', array( self::$instance->hooks, 'detach' ), PHP_INT_MIN ); remove_action( 'shutdown', array( self::$instance, 'finish' ), PHP_INT_MAX ); remove_action( 'send_headers', array( self::$instance, 'send_headers' ), PHP_INT_MAX ); remove_action( 'admin_init', array( self::$instance, 'send_headers' ), PHP_INT_MIN ); remove_filter( 'nocache_headers', array( self::$instance, 'nocache' ) ); remove_filter( 'option_active_plugins', array( self::$instance, 'filter_active_plugins' ), 0 ); remove_filter( 'site_option_active_sitewide_plugins', array( self::$instance, 'filter_sitewide_plugins' ), 0 ); remove_filter( 'pre_update_option_active_plugins', array( self::$instance, 'keep_plugin_list' ), PHP_INT_MAX ); remove_filter( 'pre_update_option', array( self::$instance, 'keep_active_plugins' ), PHP_INT_MAX ); remove_filter( 'pre_update_site_option_active_sitewide_plugins', array( self::$instance, 'keep_plugin_list' ), PHP_INT_MAX ); } self::$instance = null; } /** * Timers (tests). */ public function hook_timer(): HookTimer { return $this->hooks; } /** * Include timer (tests). */ public function include_timer(): IncludeTimer { return $this->includes; } /** * Asset collector (tests). */ public function asset_collector(): AssetCollector { return $this->assets; } }