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

Merged
merged 34 commits into from
Jan 22, 2025

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

'<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

github-actions bot commented Dec 10, 2024

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: felixarntz <[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 266 to 267
$etag = isset( $headers['etag'] ) ? $headers['etag'] : '';
$last_modified = isset( $headers['last-modified'] ) ? $headers['last-modified'] : '';
Copy link
Member

Choose a reason for hiding this comment

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

Minor improvement.

Suggested change
$etag = isset( $headers['etag'] ) ? $headers['etag'] : '';
$last_modified = isset( $headers['last-modified'] ) ? $headers['last-modified'] : '';
$etag = $headers['etag'] ?? '';
$last_modified = $headers['last-modified'] ?? '';

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done in 12d0b71

Comment on lines 123 to 130
$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 ) {
Copy link
Member

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:

Suggested change
$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'] ) {

Copy link
Contributor Author

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;
Copy link
Member

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.

Comment on lines 217 to 225
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 )
),
);
Copy link
Member

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.

Comment on lines 22 to 29
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' );
Copy link
Member

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done in aec6f80

Comment on lines 102 to 121
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;
}
Copy link
Member

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done in aec6f80

@b1ink0 b1ink0 requested a review from westonruter January 20, 2025 19:19
Copy link
Member

@felixarntz felixarntz left a 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',
Copy link
Member

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.

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 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?

Copy link
Member

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 );
Copy link
Member

Choose a reason for hiding this comment

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

Maybe better as:

Suggested change
$threshold = apply_filters( 'perflab_far_future_headers_threshold', YEAR_IN_SECONDS );
$threshold = apply_filters( 'perflab_effective_cache_headers_expiration_threshold', YEAR_IN_SECONDS );

Copy link
Member

Choose a reason for hiding this comment

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

Per above:

Suggested change
$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(
Copy link
Member

Choose a reason for hiding this comment

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

See above:

Suggested change
$tests['direct']['far_future_headers'] = array(
$tests['direct']['effective_cache_headers'] = array(

Copy link
Member

Choose a reason for hiding this comment

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

Per above:

Suggested change
$tests['direct']['far_future_headers'] = array(
$tests['direct']['effective_asset_cache_headers'] = array(

Comment on lines 9 to 11
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
Copy link
Member

Choose a reason for hiding this comment

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

Per #1815:

Suggested change
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
// @codeCoverageIgnoreStart
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
// @codeCoverageIgnoreEnd

Comment on lines 9 to 11
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
Copy link
Member

Choose a reason for hiding this comment

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

Per #1815:

Suggested change
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
// @codeCoverageIgnoreStart
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
// @codeCoverageIgnoreEnd

Copy link
Member

@felixarntz felixarntz left a 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.

Co-authored-by: Felix Arntz <[email protected]>
Copy link
Member

@westonruter westonruter left a 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%

🎉

@westonruter westonruter merged commit d44b4c1 into WordPress:trunk Jan 22, 2025
16 checks passed
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
3 participants