-
Notifications
You must be signed in to change notification settings - Fork 107
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
Add Site Health test to verify that static assets are served with far-future expires #1727
Conversation
plugins/performance-lab/includes/site-health/far-future-headers/helper.php
Outdated
Show resolved
Hide resolved
plugins/performance-lab/includes/site-health/far-future-headers/helper.php
Outdated
Show resolved
Hide resolved
'<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
Outdated
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 ); |
$etag = isset( $headers['etag'] ) ? $headers['etag'] : ''; | ||
$last_modified = isset( $headers['last-modified'] ) ? $headers['last-modified'] : ''; |
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.
Minor improvement.
$etag = isset( $headers['etag'] ) ? $headers['etag'] : ''; | |
$last_modified = isset( $headers['last-modified'] ) ? $headers['last-modified'] : ''; | |
$etag = $headers['etag'] ?? ''; | |
$last_modified = $headers['last-modified'] ?? ''; |
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.
Done in 12d0b71
$check = perflab_ffh_check_headers( $headers ); | ||
if ( isset( $check['passed'] ) && $check['passed'] ) { | ||
// This asset passed far-future headers test, no action needed. | ||
continue; | ||
} | ||
|
||
// If not passed, decide whether to try conditional request. | ||
if ( false === $check ) { |
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 conditions here seem a bit confusing to me, related to how perflab_ffh_check_headers()
returns either an array
or false
. I think it would be preferable if perflab_ffh_check_headers
always returned an array. Maybe it could always return an array key for missing_max_age
which is a boolean. And then this could be changed to:
$check = perflab_ffh_check_headers( $headers ); | |
if ( isset( $check['passed'] ) && $check['passed'] ) { | |
// This asset passed far-future headers test, no action needed. | |
continue; | |
} | |
// If not passed, decide whether to try conditional request. | |
if ( false === $check ) { | |
$check = perflab_ffh_check_headers( $headers ); | |
if ( $check['passed'] ) { | |
// This asset passed far-future headers test, no action needed. | |
continue; | |
} | |
// If not passed, decide whether to try conditional request. | |
if ( $check['missing_max_age'] ) { |
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.
Done in 12d0b71
$conditional_headers['If-None-Match'] = $etag; | ||
} | ||
if ( '' !== $last_modified ) { | ||
$conditional_headers['If-Modified-Since'] = $last_modified; |
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.
Looks like test coverage for this line is warranted.
return array( | ||
'passed' => false, | ||
'reason' => sprintf( | ||
/* translators: 1: actual max-age value in seconds, 2: threshold in seconds */ | ||
__( 'max-age below threshold (actual: %1$s seconds, threshold: %2$s seconds)', 'performance-lab' ), | ||
number_format_i18n( $max_age ), | ||
number_format_i18n( $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.
Looks like a test is missing for the max-age
being present but it not being high enough.
function perflab_ffh_add_test( array $tests ): array { | ||
$tests['direct']['far_future_headers'] = array( | ||
'label' => __( 'Effective Caching Headers', 'performance-lab' ), | ||
'test' => 'perflab_ffh_assets_test', | ||
); | ||
return $tests; | ||
} | ||
add_filter( 'site_status_tests', 'perflab_ffh_add_test' ); |
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 trivial function to add a test for, but it would ensure test coverage.
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.
Done in aec6f80
plugins/performance-lab/includes/site-health/far-future-headers/helper.php
Outdated
Show resolved
Hide resolved
if ( is_wp_error( $response ) ) { | ||
// Can't determine headers if request failed, consider it a fail. | ||
$final_status = 'recommended'; | ||
$fail_details[] = array( | ||
'filename' => $filename, | ||
'reason' => __( 'Could not retrieve headers', 'performance-lab' ), | ||
); | ||
continue; | ||
} | ||
|
||
$headers = wp_remote_retrieve_headers( $response ); | ||
if ( ! is_object( $headers ) && 0 === count( $headers ) ) { | ||
// No valid headers retrieved. | ||
$final_status = 'recommended'; | ||
$fail_details[] = array( | ||
'filename' => $filename, | ||
'reason' => __( 'No valid headers retrieved', 'performance-lab' ), | ||
); | ||
continue; | ||
} |
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.
These two if
statements lack tests, but I don't think they are critical.
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.
Done in aec6f80
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.
@b1ink0 Technically LGTM, though I would suggest we rename the check to something more purpose-driven and less technical.
) | ||
), | ||
'actions' => '', | ||
'test' => 'is_far_future_headers_enabled', |
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.
Can we maybe use effective_cache_headers
for the name / identifier of this entire functionality? I just find this far_future_headers
oddly specific, and it sounds strange to me. The purpose for this Site Health is to have "good" caching headers, and I think we should frame the feature as such, rather than the more technical and IMO too specific "far future" terminology.
This applies to all the identifiers using far_future_headers
, like this function, the Site Health check itself, the directory name etc. I think we should use effective_cache_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.
I think we should use asset
somewhere in the name because this should be differentiated from the effective Cache-Control
header for page responses so as to not break bfcache (#1807).
So maybe effective_asset_cache_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.
@westonruter That works for me!
* | ||
* @param int $threshold Threshold in seconds. | ||
*/ | ||
$threshold = apply_filters( 'perflab_far_future_headers_threshold', YEAR_IN_SECONDS ); |
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.
Maybe better as:
$threshold = apply_filters( 'perflab_far_future_headers_threshold', YEAR_IN_SECONDS ); | |
$threshold = apply_filters( 'perflab_effective_cache_headers_expiration_threshold', YEAR_IN_SECONDS ); |
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.
Per above:
$threshold = apply_filters( 'perflab_far_future_headers_threshold', YEAR_IN_SECONDS ); | |
$threshold = apply_filters( 'perflab_effective_asset_cache_headers_expiration_threshold', YEAR_IN_SECONDS ); |
* @return array{direct: array<string, array{label: string, test: string}>} Amended tests. | ||
*/ | ||
function perflab_ffh_add_test( array $tests ): array { | ||
$tests['direct']['far_future_headers'] = 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.
See above:
$tests['direct']['far_future_headers'] = array( | |
$tests['direct']['effective_cache_headers'] = 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.
Per above:
$tests['direct']['far_future_headers'] = array( | |
$tests['direct']['effective_asset_cache_headers'] = array( |
if ( ! defined( 'ABSPATH' ) ) { | ||
exit; // Exit if accessed directly. | ||
} |
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.
Per #1815:
if ( ! defined( 'ABSPATH' ) ) { | |
exit; // Exit if accessed directly. | |
} | |
// @codeCoverageIgnoreStart | |
if ( ! defined( 'ABSPATH' ) ) { | |
exit; // Exit if accessed directly. | |
} | |
// @codeCoverageIgnoreEnd |
if ( ! defined( 'ABSPATH' ) ) { | ||
exit; // Exit if accessed directly. | ||
} |
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.
Per #1815:
if ( ! defined( 'ABSPATH' ) ) { | |
exit; // Exit if accessed directly. | |
} | |
// @codeCoverageIgnoreStart | |
if ( ! defined( 'ABSPATH' ) ) { | |
exit; // Exit if accessed directly. | |
} | |
// @codeCoverageIgnoreEnd |
…asset-cache-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.
@b1ink0 Thank you for the updates, LGTM!
Just one minor suggestion to fix the last remaining reference to the old names.
...ts/includes/site-health/effective-asset-cache-headers/test-effective-asset-cache-headers.php
Outdated
Show resolved
Hide resolved
Co-authored-by: Felix Arntz <[email protected]>
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.
Attention: Patch coverage is 98.36066%
🎉
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.