-
Notifications
You must be signed in to change notification settings - Fork 106
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
base: trunk
Are you sure you want to change the base?
Conversation
plugins/performance-lab/includes/site-health/far-future-headers/helper.php
Show resolved
Hide resolved
} | ||
|
||
return $final_status; | ||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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' ) | ||
); | ||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
…ate a report table for missing headers
…and updating the extensions table generation
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 If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.
To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
plugins/performance-lab/includes/site-health/far-future-headers/helper.php
Show resolved
Hide resolved
* @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 ) { |
There was a problem hiding this comment.
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
:
* @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 ) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah yes, good point.
plugins/performance-lab/includes/site-health/far-future-headers/helper.php
Outdated
Show resolved
Hide resolved
} | ||
|
||
$headers = wp_remote_retrieve_headers( $response ); | ||
if ( ! is_object( $headers ) ) { |
There was a problem hiding this comment.
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
:
if ( ! is_object( $headers ) ) { | |
if ( count( $headers ) === 0 ) { |
There was a problem hiding this comment.
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 ) )
There was a problem hiding this comment.
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
.
// 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' ), | ||
); | ||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
For the expires
, should we display the timestamp like above, or should we show$expires_time - time()
seconds?
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.
There was a problem hiding this comment.
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 ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
$filename = isset( $path_info['basename'] ) ? $path_info['basename'] : basename( $asset ); | |
$filename = $path_info['basename'] ?? basename( $asset ); |
$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' ), | ||
); | ||
} |
There was a problem hiding this comment.
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.
$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 ) ) { |
There was a problem hiding this comment.
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."
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 ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 ) { |
There was a problem hiding this comment.
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:
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
.
$cache_control = isset( $headers['cache-control'] ) ? $headers['cache-control'] : ''; | ||
$expires = isset( $headers['expires'] ) ? $headers['expires'] : ''; |
There was a problem hiding this comment.
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
:
$cache_control = isset( $headers['cache-control'] ) ? $headers['cache-control'] : ''; | |
$expires = isset( $headers['expires'] ) ? $headers['expires'] : ''; | |
$cache_control = $headers['cache-control'] ?? ''; | |
$expires = $headers['expires'] ?? ''; |
$controls = is_array( $cache_control ) ? $cache_control : array( $cache_control ); | ||
foreach ( $controls as $control ) { |
There was a problem hiding this comment.
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:
$controls = is_array( $cache_control ) ? $cache_control : array( $cache_control ); | |
foreach ( $controls as $control ) { | |
foreach ( (array) $cache_control as $control ) { |
Codecov ReportAttention: Patch coverage is
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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. |
Summary
Fixes #323
Relevant technical choices
Test are based on the
Cache-Control
withmax-age
or anExpires
to determine if the static assets are served with far future expires. IfCache-Control
andExpires
are unavailable then theETag
andLast-Modified
are used to do a secondary request to the same asset URL withIf-None-Match
andIf-Modified-Since
, respectively. If those return with 304 Not Modified then that could pass the test as well.