Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Site Health test to verify that static assets are served with far-future expires #1727

Open
wants to merge 22 commits into
base: trunk
Choose a base branch
from

Conversation

b1ink0
Copy link
Contributor

@b1ink0 b1ink0 commented Dec 9, 2024

Summary

Fixes #323

Relevant technical choices

Test are based on the Cache-Control with max-age or an Expires to determine if the static assets are served with far future expires. If Cache-Control and Expires are unavailable then the ETag and Last-Modified are used to do a secondary request to the same asset URL with If-None-Match and If-Modified-Since, respectively. If those return with 304 Not Modified then that could pass the test as well.

Screenshot 2024-12-12 at 5 34 41 PM

}

return $final_status;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently even if one asset fails the header check then this test will be show in the site health. Would it be better to show a table with mime type which will specifically tell for which mime type needs to add the Cache-Control headers?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A table makes sense to me.

'<p>%s</p>',
esc_html__( 'Far-future Cache-Control or Expires headers can be added or adjusted with a small configuration change by your hosting provider.', 'performance-lab' )
);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two different checks which are getting performed one with Cache-Control, Expires and other with Etag, Last-Modified should there be different messages shown based on the checks?

Copy link
Contributor Author

@b1ink0 b1ink0 Dec 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the new 3db5886 commit a table is used to display reason for different failure cases.

@b1ink0 b1ink0 marked this pull request as ready for review December 10, 2024 12:19
Copy link

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: b1ink0 <[email protected]>
Co-authored-by: westonruter <[email protected]>
Co-authored-by: manuelRod <[email protected]>
Co-authored-by: joemcgill <[email protected]>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@b1ink0 b1ink0 requested a review from westonruter January 6, 2025 18:10
Comment on lines 165 to 168
* @param WpOrg\Requests\Utility\CaseInsensitiveDictionary $headers Response headers.
* @return array{passed: bool, reason: string}|false Detailed result. If passed=false, reason explains why it failed and false if no headers found.
*/
function perflab_ffh_check_headers( WpOrg\Requests\Utility\CaseInsensitiveDictionary $headers ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wp_remote_retrieve_headers() function can return a CaseInsensitiveDictionary object or an array:

Suggested change
* @param WpOrg\Requests\Utility\CaseInsensitiveDictionary $headers Response headers.
* @return array{passed: bool, reason: string}|false Detailed result. If passed=false, reason explains why it failed and false if no headers found.
*/
function perflab_ffh_check_headers( WpOrg\Requests\Utility\CaseInsensitiveDictionary $headers ) {
* @param WpOrg\Requests\Utility\CaseInsensitiveDictionary|array $headers Response headers.
* @return array{passed: bool, reason: string}|false Detailed result. If passed=false, reason explains why it failed and false if no headers found.
*/
function perflab_ffh_check_headers( $headers ) {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would raise a PHPStan error; it seems we also need to provide the type of array. Below is what I used to solve it:
@param WpOrg\Requests\Utility\CaseInsensitiveDictionary|array<string, string|array<string>>
The array<string> is needed because if multiple same keyed headers are present, it is placed into the array.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes, good point.

}

$headers = wp_remote_retrieve_headers( $response );
if ( ! is_object( $headers ) ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since wp_remote_retrieve_headers() can return an array:

Suggested change
if ( ! is_object( $headers ) ) {
if ( count( $headers ) === 0 ) {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fatal error: Uncaught Error: count(): Argument # 1 ($value) must be of type Countable|array, WpOrg\Requests\Utility\CaseInsensitiveDictionary given

This error is encountered when using count, below condition seems to solve this error.
if ( ! is_object( $headers ) && 0 === count( $headers ) )

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's surprising that CaseInsensitiveDictionary is not implemented as a Countable.

@westonruter westonruter added this to the performance-lab n.e.x.t milestone Jan 8, 2025
@westonruter westonruter added [Type] Enhancement A suggestion for improvement of an existing feature [Plugin] Performance Lab Issue relates to work in the Performance Lab Plugin only labels Jan 8, 2025
Comment on lines 214 to 236
// Expires header exists but not far enough in the future.
if ( $max_age > 0 && $max_age < $threshold ) {
return array(
'passed' => false,
'reason' => __( 'max-age below threshold', 'performance-lab' ),
);
}
return array(
'passed' => false,
'reason' => __( 'expires below threshold', 'performance-lab' ),
);
}

// No max-age or expires found at all or max-age < threshold and no expires.
if ( 0 === $max_age ) {
return false;
} else {
// max-age was present but below threshold and no expires.
return array(
'passed' => false,
'reason' => __( 'max-age below threshold', 'performance-lab' ),
);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would seem helpful to indicate the max-age and expires values in the error message so you can see what the actual values are. Likewise, shouldn't the threshold be added to the error message so the user knows the minimum TTL that they should configure the expires and max-age to be?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sense I have added the max-age, expires and minimum threshold.
Screenshot 2025-01-09 at 9 37 52 PM
For the expires , should we display the timestamp like above, or should we show$expires_time - time() seconds?
Screenshot 2025-01-09 at 9 38 08 PM
Additionally, should we convert the remaining time into a human-readable format, such as 1 month or 1 year? Since our threshold filter uses seconds as a parameter, displaying it in seconds might be more appropriate.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think converting the expires to seconds makes sense. I don't think we need to worry about converting to anything other than seconds, however. Just pass the number of seconds through number_format_i18n() to get some better formatting.


// Extract filename from the URL.
$path_info = pathinfo( (string) wp_parse_url( $asset, PHP_URL_PATH ) );
$filename = isset( $path_info['basename'] ) ? $path_info['basename'] : basename( $asset );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
$filename = isset( $path_info['basename'] ) ? $path_info['basename'] : basename( $asset );
$filename = $path_info['basename'] ?? basename( $asset );

Comment on lines 131 to 144
$conditional_pass = perflab_ffh_try_conditional_request( $asset, $headers );
if ( ! $conditional_pass ) {
$final_status = 'recommended';
$fail_details[] = array(
'filename' => $filename,
'reason' => __( 'No far-future headers and no conditional caching', 'performance-lab' ),
);
} else {
$final_status = 'recommended';
$fail_details[] = array(
'filename' => $filename,
'reason' => __( 'No far-future headers but conditionally cached', 'performance-lab' ),
);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Factoring $final_status out of the if statement.

Suggested change
$conditional_pass = perflab_ffh_try_conditional_request( $asset, $headers );
if ( ! $conditional_pass ) {
$final_status = 'recommended';
$fail_details[] = array(
'filename' => $filename,
'reason' => __( 'No far-future headers and no conditional caching', 'performance-lab' ),
);
} else {
$final_status = 'recommended';
$fail_details[] = array(
'filename' => $filename,
'reason' => __( 'No far-future headers but conditionally cached', 'performance-lab' ),
);
}
$conditional_pass = perflab_ffh_try_conditional_request( $asset, $headers );
$final_status = 'recommended';
if ( ! $conditional_pass ) {
$fail_details[] = array(
'filename' => $filename,
'reason' => __( 'No far-future headers and no conditional caching', 'performance-lab' ),
);
} else {
$fail_details[] = array(
'filename' => $filename,
'reason' => __( 'No far-future headers but conditionally cached', 'performance-lab' ),
);
}

// There can be multiple cache-control headers, we only care about max-age.
$controls = is_array( $cache_control ) ? $cache_control : array( $cache_control );
foreach ( $controls as $control ) {
if ( (bool) preg_match( '/max-age\s*=\s*(\d+)/', $control, $matches ) ) {
Copy link
Member

@westonruter westonruter Jan 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because PhpStorm complains about the unnecessary cast, but then without it PHPStan complains about "Only booleans are allowed in an if condition, int|false given."

Suggested change
if ( (bool) preg_match( '/max-age\s*=\s*(\d+)/', $control, $matches ) ) {
if ( 1 === preg_match( '/max-age\s*=\s*(\d+)/', $control, $matches ) ) {

// If max-age is too low or not present, check Expires.
if ( is_string( $expires ) && '' !== $expires ) {
$expires_time = strtotime( $expires );
if ( (bool) $expires_time && ( $expires_time - time() ) >= $threshold ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if ( (bool) $expires_time && ( $expires_time - time() ) >= $threshold ) {
if ( is_int( $expires_time ) && ( $expires_time - time() ) >= $threshold ) {

}

// Expires header exists but not far enough in the future.
if ( $max_age > 0 && $max_age < $threshold ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This condition doesn't seem to be necessary:

Suggested change
if ( $max_age > 0 && $max_age < $threshold ) {
if ( $max_age > 0 ) {

Because above we're already doing:

	if ( $max_age >= $threshold ) {
		return array(
			'passed' => true,
			'reason' => '',
		);
	}

So we already know that $max_age is less than the $threshold.

Comment on lines 179 to 180
$cache_control = isset( $headers['cache-control'] ) ? $headers['cache-control'] : '';
$expires = isset( $headers['expires'] ) ? $headers['expires'] : '';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this works even when $headers is a CaseInsensitiveDictionary:

Suggested change
$cache_control = isset( $headers['cache-control'] ) ? $headers['cache-control'] : '';
$expires = isset( $headers['expires'] ) ? $headers['expires'] : '';
$cache_control = $headers['cache-control'] ?? '';
$expires = $headers['expires'] ?? '';

Comment on lines 186 to 187
$controls = is_array( $cache_control ) ? $cache_control : array( $cache_control );
foreach ( $controls as $control ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems this could be simplified a bit:

Suggested change
$controls = is_array( $cache_control ) ? $cache_control : array( $cache_control );
foreach ( $controls as $control ) {
foreach ( (array) $cache_control as $control ) {

Copy link

codecov bot commented Jan 9, 2025

Codecov Report

Attention: Patch coverage is 78.65169% with 38 lines in your changes missing coverage. Please review.

Project coverage is 58.05%. Comparing base (b6e5c5a) to head (ed28196).

Files with missing lines Patch % Lines
...includes/site-health/far-future-headers/helper.php 83.33% 28 Missing ⚠️
.../includes/site-health/far-future-headers/hooks.php 0.00% 8 Missing ⚠️
...gins/performance-lab/includes/site-health/load.php 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##            trunk    #1727      +/-   ##
==========================================
+ Coverage   57.48%   58.05%   +0.56%     
==========================================
  Files          84       86       +2     
  Lines        6516     6694     +178     
==========================================
+ Hits         3746     3886     +140     
- Misses       2770     2808      +38     
Flag Coverage Δ
multisite 58.05% <78.65%> (+0.56%) ⬆️
single 35.68% <78.65%> (+1.17%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Plugin] Performance Lab Issue relates to work in the Performance Lab Plugin only [Type] Enhancement A suggestion for improvement of an existing feature
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add Site Health test to verify that static assets are served with far-future expires
2 participants