Aftercare metric. */ private const AUDIT_MAP = array( 'largest-contentful-paint' => 'LCP', 'cumulative-layout-shift' => 'CLS', 'server-response-time' => 'TTFB', 'total-blocking-time' => 'TBT', ); /** Gap between queued URL checks, so they never pile into one cron request. */ private const STAGGER_SECONDS = 2 * MINUTE_IN_SECONDS; /** HTTP status of the last PageSpeed Insights response; 0 when no response arrived. */ private int $last_status = 0; public function __construct( private SampleRepository $samples ) {} public function register(): void { add_action( self::URL_HOOK, array( $this, 'run_url' ), 10, 3 ); } public static function enabled(): bool { return (bool) Options::get( 'lab_enabled' ) && '' !== (string) Options::get( 'api_key' ); } /** * URLs a lab check covers: the homepage first, then tracked URLs, capped * so one check stays well inside the API quota and cron time. * * @return string[] */ public static function urls(): array { /** * Filter how many URLs one lab check tests. * * @param int $max Maximum URLs, homepage included. */ $max = max( 1, (int) apply_filters( 'aftercare_lab_max_urls', 6 ) ); return array_slice( Options::tracked_urls(), 0, $max ); } /** * Daily pipeline step: queue one action per URL not yet tested today. */ public function queue_daily(): void { if ( ! self::enabled() ) { return; } $today = gmdate( 'Y-m-d' ); $when = time(); foreach ( self::urls() as $url ) { if ( $this->samples->has_sample_for_day( $url, 'LCP', SampleRepository::SOURCE_LAB, $today ) ) { continue; } Cron::schedule_single( $when, self::URL_HOOK, array( $url ) ); $when += self::STAGGER_SECONDS; } } /** * The last failed lab check, if the most recent check of that URL failed. * * @return array{url: string, message: string, status: int, at: string}|null */ public static function last_error(): ?array { $error = get_option( self::ERROR_OPT ); if ( ! is_array( $error ) || empty( $error['message'] ) ) { return null; } return array( 'url' => (string) ( $error['url'] ?? '' ), 'message' => (string) $error['message'], 'status' => (int) ( $error['status'] ?? 0 ), 'at' => (string) ( $error['at'] ?? '' ), ); } /** * Scheduled action: test one URL, store the median of each metric and * judge it against the lab baseline. * * @param string $url URL to test. * @param int[] $trigger_ids Ledger events that prompted a change check; * empty for the daily check. * @param int $runs Runs to take; 0 for the filtered default. */ public function run_url( $url, $trigger_ids = array(), $runs = 0 ): void { $url = esc_url_raw( (string) $url ); if ( '' === $url || ! self::enabled() ) { return; } /** * Filter how many Lighthouse runs make up one lab reading. The median * of the runs is stored. * * @param int $runs Runs per URL. */ $runs = (int) $runs > 0 ? (int) $runs : (int) apply_filters( 'aftercare_lab_runs', 3 ); $runs = max( 1, min( 5, $runs ) ); $api_key = (string) Options::get( 'api_key' ); $values = array(); $error = ''; for ( $i = 0; $i < $runs; $i++ ) { $result = $this->fetch( $url, $api_key ); if ( is_string( $result ) ) { $error = $result; break; // An API error repeats on every run; do not burn quota on it. } foreach ( $result as $metric => $value ) { $values[ $metric ][] = $value; } } if ( empty( $values ) ) { update_option( self::ERROR_OPT, array( 'url' => $url, 'message' => $error, 'status' => $this->last_status, 'at' => Util::now(), ), false ); return; } delete_option( self::ERROR_OPT ); update_option( self::SUCCESS_OPT, time(), false ); $now = Util::now(); foreach ( $values as $metric => $readings ) { $this->samples->insert( $url, $metric, Util::percentile( $readings, 50.0 ), SampleRepository::SOURCE_LAB, $now ); } ( new BreachDetector( $this->samples ) )->run_lab( $url, array_map( 'intval', (array) $trigger_ids ) ); Status::flush_cache(); } /** * One Lighthouse run. * * @return array|string Metric => value, or an error message. */ private function fetch( string $url, string $api_key ) { $request = add_query_arg( array( 'url' => rawurlencode( $url ), 'strategy' => 'mobile', 'category' => 'performance', 'key' => rawurlencode( $api_key ), ), self::ENDPOINT ); $this->last_status = 0; $response = wp_remote_get( $request, array( 'timeout' => 60 ) ); if ( is_wp_error( $response ) ) { return $response->get_error_message(); } $data = json_decode( wp_remote_retrieve_body( $response ), true ); $code = (int) wp_remote_retrieve_response_code( $response ); $this->last_status = $code; if ( 200 !== $code ) { $message = is_array( $data ) ? (string) ( $data['error']['message'] ?? '' ) : ''; /* translators: %d: HTTP status code */ return '' !== $message ? $message : sprintf( __( 'PageSpeed Insights returned HTTP %d.', 'aftercare' ), $code ); } // Lighthouse reports a failed load (no paint, page unreachable from // Google) as a 200 with a runtime error and empty audits. if ( is_array( $data ) && ! empty( $data['lighthouseResult']['runtimeError']['message'] ) ) { return (string) $data['lighthouseResult']['runtimeError']['message']; } if ( ! is_array( $data ) || empty( $data['lighthouseResult']['audits'] ) ) { return __( 'PageSpeed Insights returned no Lighthouse result.', 'aftercare' ); } $out = array(); foreach ( self::AUDIT_MAP as $audit => $metric ) { $value = $data['lighthouseResult']['audits'][ $audit ]['numericValue'] ?? null; if ( is_numeric( $value ) ) { $out[ $metric ] = (float) $value; } } return empty( $out ) ? __( 'PageSpeed Insights returned no metric values.', 'aftercare' ) : $out; } }