*/ public static function frequencies() { return array( 'weekly' => __( 'Weekly (recommended)', 'maintained-log' ), 'monthly' => __( 'Monthly', 'maintained-log' ), ); } /** * Device strategies with labels. * * @return array */ public static function strategies() { return array( 'mobile' => __( 'Mobile', 'maintained-log' ), 'desktop' => __( 'Desktop', 'maintained-log' ), ); } /** * Rating for a 0–100 score. * * @param int $score Score. * @return array{key:string,label:string,color:string} */ public static function rating( $score ) { /** * Filters the lowest score of each rating. * * @param array $thresholds Default ['excellent' => 90, 'good' => 50], matching Google's color bands. */ $thresholds = apply_filters( 'maintained_log_performance_thresholds', array( 'excellent' => 90, 'good' => 50, ) ); if ( $score >= $thresholds['excellent'] ) { return array( 'key' => 'excellent', 'label' => __( 'Excellent', 'maintained-log' ), 'color' => '#0a7d32', ); } if ( $score >= $thresholds['good'] ) { return array( 'key' => 'good', 'label' => __( 'Good', 'maintained-log' ), 'color' => '#2271b1', ); } return array( 'key' => 'improvable', 'label' => __( 'Could be improved', 'maintained-log' ), 'color' => '#b26200', ); } /** * Configured page URLs. * * @return string[] */ public static function urls() { $urls = array(); foreach ( preg_split( '/\r\n|\r|\n/', (string) Settings::get( 'perf_urls' ) ) as $line ) { $line = trim( $line ); if ( '' !== $line ) { $urls[] = $line; } } return $urls ? $urls : array( home_url( '/' ) ); } /** * Short label for a URL: "Home page" or its path. * * @param string $url URL. * @return string */ public static function url_label( $url ) { $path = (string) wp_parse_url( $url, PHP_URL_PATH ); $query = (string) wp_parse_url( $url, PHP_URL_QUERY ); $home = (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ); if ( '' === $query && untrailingslashit( $path ) === untrailingslashit( $home ) ) { return __( 'Home page', 'maintained-log' ); } return $path . ( '' !== $query ? '?' . $query : '' ); } /** * Starts a measurement round if one is due. * * @return bool Whether a round was queued. */ public static function maybe_schedule() { if ( ! self::is_enabled() ) { return false; } $interval = 'monthly' === Settings::get( 'perf_frequency' ) ? 30 * DAY_IN_SECONDS : WEEK_IN_SECONDS; $last = (int) get_option( self::LAST_RUN_OPTION, 0 ); // Margin so a twice-daily cron does not drift a full cycle. if ( time() - $last < $interval - 6 * HOUR_IN_SECONDS ) { return false; } return self::queue_round(); } /** * Queues one measurement per page and device. * * @return bool */ public static function queue_round() { if ( ! self::is_enabled() ) { return false; } $queue = array(); $strategies = array_intersect( (array) Settings::get( 'perf_strategies' ), array_keys( self::strategies() ) ); foreach ( self::urls() as $url ) { foreach ( $strategies as $strategy ) { $queue[] = array( 'url' => $url, 'strategy' => $strategy, ); } } if ( ! $queue ) { return false; } update_option( self::QUEUE_OPTION, $queue, false ); update_option( self::LAST_RUN_OPTION, time(), false ); self::schedule_tick( 0 ); return true; } /** * Measures the next queued page. * * @param bool $schedule_next Schedule the following one in cron (false when the settings screen drives it). * @return array|null The item measured with its result, or null if the queue was empty. */ public static function tick( $schedule_next = true ) { $queue = get_option( self::QUEUE_OPTION, array() ); if ( ! is_array( $queue ) || ! $queue ) { return null; } $item = array_shift( $queue ); update_option( self::QUEUE_OPTION, $queue, false ); $item['result'] = self::measure( $item['url'], $item['strategy'] ); $item['remaining'] = count( $queue ); if ( $queue && $schedule_next ) { self::schedule_tick( 15 ); } return $item; } /** * Measures one page and logs the result. * * @param string $url Page URL. * @param string $strategy mobile|desktop. * @return array|\WP_Error Parsed result. */ public static function measure( $url, $strategy ) { $result = self::fetch( $url, $strategy ); if ( is_wp_error( $result ) ) { Log::add( array( 'type' => self::TYPE, 'category' => $strategy, 'status' => Log::STATUS_WARNING, /* translators: 1: page, 2: device, 3: error message. */ 'title' => sprintf( __( 'Performance of %1$s (%2$s) could not be measured: %3$s', 'maintained-log' ), self::url_label( $url ), self::strategy_label( $strategy ), $result->get_error_message() ), 'details' => array( 'url' => $url, 'strategy' => $strategy, 'error' => $result->get_error_message(), ), 'source' => Log::SOURCE_SYSTEM, 'user_id' => 0, ) ); return $result; } $previous = self::latest( $url, $strategy ); $rating = self::rating( $result['score'] ); $statuses = array( 'excellent' => Log::STATUS_SUCCESS, 'good' => Log::STATUS_INFO, 'improvable' => Log::STATUS_WARNING, ); Log::add( array( 'type' => self::TYPE, 'category' => $strategy, 'status' => $statuses[ $rating['key'] ], /* translators: 1: page, 2: device, 3: rating, 4: score. */ 'title' => sprintf( __( 'Performance of %1$s (%2$s): %3$s (%4$d/100)', 'maintained-log' ), self::url_label( $url ), self::strategy_label( $strategy ), $rating['label'], $result['score'] ), 'details' => $result, 'source' => Log::SOURCE_SYSTEM, 'user_id' => 0, ) ); if ( $previous && Settings::get( 'perf_alert' ) ) { $before = self::rating( (int) $previous->details['score'] ); $order = array( 'improvable', 'good', 'excellent' ); if ( array_search( $rating['key'], $order, true ) < array_search( $before['key'], $order, true ) ) { Alerts::send_performance_drop( $url, $strategy, $before, $rating, $result['score'] ); } } return $result; } /** * Calls the API and extracts what the plugin stores. * * @param string $url Page URL. * @param string $strategy mobile|desktop. * @return array|\WP_Error */ public static function fetch( $url, $strategy ) { $request = add_query_arg( array( 'url' => rawurlencode( $url ), 'strategy' => $strategy, 'category' => 'performance', 'key' => rawurlencode( trim( (string) Settings::get( 'perf_api_key' ) ) ), ), self::API_URL ); $response = wp_remote_get( $request, array( 'timeout' => 90 ) ); if ( is_wp_error( $response ) ) { return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); $body = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( 200 !== $code || ! is_array( $body ) || ! isset( $body['lighthouseResult']['categories']['performance']['score'] ) ) { $message = isset( $body['error']['message'] ) ? $body['error']['message'] : sprintf( 'HTTP %d', $code ); if ( 429 === $code ) { $message = __( 'Google quota exceeded. Check that the API key is valid and has the PageSpeed Insights API enabled.', 'maintained-log' ); } return new \WP_Error( 'psi_error', $message ); } return self::parse( $body, $url, $strategy ); } /** * Extracts the score, key metrics, field data and improvement themes. * * @param array $body Decoded API response. * @param string $url Page URL. * @param string $strategy mobile|desktop. * @return array */ public static function parse( array $body, $url, $strategy ) { $lh = $body['lighthouseResult']; $audits = isset( $lh['audits'] ) ? $lh['audits'] : array(); $metrics = array(); $ids = array( 'lcp' => 'largest-contentful-paint', 'fcp' => 'first-contentful-paint', 'tbt' => 'total-blocking-time', 'cls' => 'cumulative-layout-shift', 'si' => 'speed-index', ); foreach ( $ids as $key => $id ) { if ( isset( $audits[ $id ]['numericValue'] ) ) { $metrics[ $key ] = round( (float) $audits[ $id ]['numericValue'], 'cls' === $key ? 3 : 0 ); } } $field = null; $le = isset( $body['loadingExperience'] ) ? $body['loadingExperience'] : array(); if ( ! empty( $le['overall_category'] ) && 'NONE' !== $le['overall_category'] ) { $field = array( 'category' => $le['overall_category'], 'origin' => ! empty( $le['origin_fallback'] ), 'metrics' => array(), ); foreach ( (array) ( isset( $le['metrics'] ) ? $le['metrics'] : array() ) as $name => $data ) { if ( isset( $data['category'] ) ) { $field['metrics'][ $name ] = $data['category']; } } } return array( 'url' => $url, 'strategy' => $strategy, 'score' => (int) round( (float) $lh['categories']['performance']['score'] * 100 ), 'metrics' => $metrics, 'field' => $field, 'themes' => self::improvement_themes( $audits ), 'lighthouse' => isset( $lh['lighthouseVersion'] ) ? $lh['lighthouseVersion'] : '', ); } /** * Plain-language explanations of what can be improved, by theme. * * @return array */ public static function theme_texts() { return array( 'images' => __( 'Some images are heavier than needed or load before they are visible. Optimizing them would speed up the page.', 'maintained-log' ), 'blocking' => __( 'Some styles or scripts delay the moment the page first appears on screen.', 'maintained-log' ), 'unused-code' => __( 'The page loads code that it does not use, mostly from plugins or the theme.', 'maintained-log' ), 'server' => __( 'The server takes a while to start sending the page. Caching or better hosting would help.', 'maintained-log' ), 'caching' => __( 'Visitors\' browsers could keep some files longer, so returning visits would be faster.', 'maintained-log' ), 'compression' => __( 'Some files are sent without compression and take longer to download.', 'maintained-log' ), 'third-party' => __( 'External services (chats, videos, maps, tracking) slow the page down.', 'maintained-log' ), 'main-image' => __( 'The main image of the page is discovered or loaded too late.', 'maintained-log' ), 'fonts' => __( 'Text can stay invisible for a moment while the fonts load.', 'maintained-log' ), 'layout' => __( 'Some elements move while the page is loading, which is annoying for visitors.', 'maintained-log' ), 'processing' => __( 'The browser needs a lot of processing before the page responds to the visitor.', 'maintained-log' ), 'page-weight' => __( 'The page is heavy or very complex, which makes it slower on mobile connections.', 'maintained-log' ), ); } /** * Failing audits grouped into themes, most impactful first. * * Covers both classic Lighthouse audits and the newer "insight" audits. * * @param array $audits Lighthouse audits. * @return string[] Theme keys. */ private static function improvement_themes( array $audits ) { $map = array( 'images' => array( 'uses-optimized-images', 'modern-image-formats', 'uses-responsive-images', 'offscreen-images', 'efficient-animated-content', 'unsized-images', 'image-delivery-insight' ), 'blocking' => array( 'render-blocking-resources', 'render-blocking-insight', 'critical-request-chains', 'network-dependency-tree-insight' ), 'unused-code' => array( 'unused-javascript', 'unused-css-rules', 'legacy-javascript', 'duplicated-javascript', 'legacy-javascript-insight', 'duplicated-javascript-insight', 'unminified-css', 'unminified-javascript' ), 'server' => array( 'server-response-time', 'redirects', 'document-latency-insight' ), 'caching' => array( 'uses-long-cache-ttl', 'cache-insight' ), 'compression' => array( 'uses-text-compression', 'modern-http-insight' ), 'third-party' => array( 'third-party-summary', 'third-party-facades', 'third-parties-insight' ), 'main-image' => array( 'prioritize-lcp-image', 'lcp-lazy-loaded', 'lcp-discovery-insight', 'lcp-phases-insight' ), 'fonts' => array( 'font-display', 'font-display-insight' ), 'layout' => array( 'layout-shifts', 'cls-culprits-insight', 'non-composited-animations' ), 'processing' => array( 'bootup-time', 'mainthread-work-breakdown', 'long-tasks', 'forced-reflow-insight', 'inp-breakdown-insight' ), 'page-weight' => array( 'total-byte-weight', 'dom-size', 'dom-size-insight' ), ); $weights = array(); foreach ( $map as $theme => $ids ) { foreach ( $ids as $id ) { if ( ! isset( $audits[ $id ] ) ) { continue; } $audit = $audits[ $id ]; if ( ! isset( $audit['score'] ) || null === $audit['score'] || (float) $audit['score'] >= 0.9 ) { continue; } if ( isset( $audit['scoreDisplayMode'] ) && in_array( $audit['scoreDisplayMode'], array( 'notApplicable', 'manual', 'informative', 'error' ), true ) ) { continue; } $saving = 0; if ( isset( $audit['details']['overallSavingsMs'] ) ) { $saving = (float) $audit['details']['overallSavingsMs']; } if ( isset( $audit['metricSavings'] ) && is_array( $audit['metricSavings'] ) ) { $saving = max( $saving, (float) ( isset( $audit['metricSavings']['LCP'] ) ? $audit['metricSavings']['LCP'] : 0 ), (float) ( isset( $audit['metricSavings']['FCP'] ) ? $audit['metricSavings']['FCP'] : 0 ) ); } // Failing audits without a time estimate still count, just below measured ones. $weight = $saving + ( 1 - (float) $audit['score'] ); $weights[ $theme ] = max( isset( $weights[ $theme ] ) ? $weights[ $theme ] : 0, $weight ); } } arsort( $weights ); return array_keys( $weights ); } /** * Latest successful measurement for a page and device. * * @param string $url Page URL. * @param string $strategy mobile|desktop. * @param string|null $before UTC upper bound, exclusive. * @return object|null */ public static function latest( $url, $strategy, $before = null ) { $entries = self::measurements( null, $before, 0 ); foreach ( $entries as $entry ) { if ( $entry->details['url'] === $url && $entry->details['strategy'] === $strategy ) { return $entry; } } return null; } /** * Successful measurements in a range, newest first. * * @param string|null $from UTC lower bound. * @param string|null $to UTC upper bound, exclusive. * @param int $per_page Limit, 0 for all. * @return object[] */ public static function measurements( $from = null, $to = null, $per_page = 0 ) { return array_values( array_filter( Log::query( array( 'type' => self::TYPE, 'from' => $from, 'to' => $to, 'per_page' => $per_page, ) ), static function ( $entry ) { return isset( $entry->details['score'] ); } ) ); } /** * Report summary for a period: per page and device, the median score, * its rating, the previous period's rating and the improvement themes. * * @param string $from UTC start. * @param string $to UTC end, exclusive. * @return array[] */ public static function summary( $from, $to ) { $current = self::group( self::measurements( $from, $to ) ); if ( ! $current ) { return array(); } $length = strtotime( $to . ' UTC' ) - strtotime( $from . ' UTC' ); $prev_end = $from; $prev = self::group( self::measurements( gmdate( 'Y-m-d H:i:s', strtotime( $from . ' UTC' ) - $length ), $prev_end ) ); $rows = array(); foreach ( $current as $key => $entries ) { $median = self::median( wp_list_pluck( wp_list_pluck( $entries, 'details' ), 'score' ) ); $before = isset( $prev[ $key ] ) ? self::median( wp_list_pluck( wp_list_pluck( $prev[ $key ], 'details' ), 'score' ) ) : null; $latest = $entries[0]->details; $trend = ''; if ( null !== $before ) { $diff = $median - $before; $trend = $diff >= 5 ? 'up' : ( $diff <= -5 ? 'down' : 'stable' ); } $rows[] = array( 'url' => $latest['url'], 'label' => self::url_label( $latest['url'] ), 'strategy' => $latest['strategy'], 'strategy_name' => self::strategy_label( $latest['strategy'] ), 'score' => $median, 'samples' => count( $entries ), 'rating' => self::rating( $median ), 'before' => null === $before ? null : self::rating( $before ), 'before_score' => $before, 'trend' => $trend, 'field' => $latest['field'], 'themes' => $latest['themes'], ); } // Mobile first: it is what Google uses for ranking and usually the weaker one. usort( $rows, static function ( $a, $b ) { return strcmp( $a['url'], $b['url'] ) ?: strcmp( $b['strategy'], $a['strategy'] ); } ); return $rows; } /** * Up to $limit improvement texts across rows, mobile first, without duplicates. * * @param array[] $rows Rows from summary(). * @param int $limit Maximum number of texts. * @return string[] */ public static function improvement_texts( array $rows, $limit = 3 ) { usort( $rows, static function ( $a, $b ) { return strcmp( $b['strategy'], $a['strategy'] ); } ); $texts = self::theme_texts(); $themes = array(); foreach ( $rows as $row ) { foreach ( $row['themes'] as $theme ) { if ( isset( $texts[ $theme ] ) && ! in_array( $theme, $themes, true ) ) { $themes[] = $theme; } } } return array_map( static function ( $theme ) use ( $texts ) { return $texts[ $theme ]; }, array_slice( $themes, 0, $limit ) ); } /** * Label of a device strategy. * * @param string $strategy mobile|desktop. * @return string */ public static function strategy_label( $strategy ) { $strategies = self::strategies(); return isset( $strategies[ $strategy ] ) ? $strategies[ $strategy ] : $strategy; } /** * Groups measurements by page and device, keeping newest-first order. * * @param object[] $entries Measurements. * @return array */ private static function group( array $entries ) { $groups = array(); foreach ( $entries as $entry ) { $groups[ $entry->details['url'] . '|' . $entry->details['strategy'] ][] = $entry; } return $groups; } /** * Median of a list of numbers, rounded. * * @param array $values Numbers. * @return int */ private static function median( array $values ) { sort( $values ); $count = count( $values ); $mid = intdiv( $count, 2 ); return (int) round( $count % 2 ? $values[ $mid ] : ( $values[ $mid - 1 ] + $values[ $mid ] ) / 2 ); } /** * Schedules the next queue tick. * * @param int $delay Seconds. */ private static function schedule_tick( $delay ) { if ( ! wp_next_scheduled( self::TICK_HOOK ) ) { wp_schedule_single_event( time() + $delay, self::TICK_HOOK ); } } }