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 check to detect blocked REST API and short-circuit optimization when Inaccessible #1762

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

Conversation

b1ink0
Copy link
Contributor

@b1ink0 b1ink0 commented Dec 19, 2024

Summary

Fixes #1731

Relevant technical choices

  • Adds a site health check to verify the availability and status of the /optimization-detective/v1/url-metrics:store REST API endpoint. The process will short-circuit if the endpoint is inaccessible.
  • Based on the status, a global admin notice on plugin activation and meta row notice on subsequent admin pages will be shown.

Scenarios:

  1. When the health check passes
    Screenshot 2025-01-14 at 11 28 30 PM

  2. When the REST API endpoint returns a forbidden error

    Screenshot 2025-01-14 at 11 30 02 PM
    Screenshot 2025-01-14 at 11 34 15 PM

  3. When the REST API endpoint returns an unauthorized error
    Screenshot 2025-01-14 at 11 31 41 PM
    Screenshot 2025-01-14 at 11 33 19 PM

  4. When other errors are encountered
    Screenshot 2025-01-14 at 11 32 18 PM
    Screenshot 2025-01-14 at 11 32 51 PM

@b1ink0 b1ink0 changed the title Add/site health check for od rest api Optimization Detective can be blocked from working due to sites disabling the REST API Dec 19, 2024
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.

Should there be a plugin activation hook added as well which does add_option() for the new option and then also kicks off (or schedules) a REST API check? Ideally there would be a warning shown immediately after activating the plugin (e.g. on the plugins list table screen) whether the REST API is working so that the user doesn't have to discover it later via Site Health.

Copy link
Member

Choose a reason for hiding this comment

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

Maybe put site-health in the root directory instead of inside includes since there are no other directories in there?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I thought in future if we added something like admin dashboard for managing URL metrics or any other admin dashboard related thing then it would be better to add that feature in includes/admin. But we can just refactor things later when we need it so moving site-health to root makes sence.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, let's put it in the root for now since all other directories are there.

if we added something like admin dashboard for managing URL metrics or any other admin dashboard related thing

Aside: I did put together a rough utility plugin for this: https://github.com/westonruter/od-admin-ui

'<p>%s</p>',
esc_html__( 'The Optimization Detective endpoint could not be reached. This might mean the REST API is disabled or blocked.', 'optimization-detective' )
);
update_option(
Copy link
Member

Choose a reason for hiding this comment

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

This is the first PR that adds an option to to Optimization Detective. We'll need to make sure that the relevant delete_option() calls get added to the plugin's uninstall.php.

Comment on lines 229 to 234
// Disable detection if the REST API is disabled.
$od_rest_api_info = get_option( 'od_rest_api_info' );
if ( is_array( $od_rest_api_info ) && isset( $od_rest_api_info['available'] ) ) {
$needs_detection = (bool) $od_rest_api_info['available'];
}

Copy link
Member

Choose a reason for hiding this comment

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

Actually, this check wouldn't make sense here. It should rather be done in od_maybe_add_template_output_buffer_filter() to short-circuit if the REST API it is not available.

update_option(
'od_rest_api_info',
array(
'status' => 'error',
Copy link
Member

Choose a reason for hiding this comment

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

Should the $error->get_message() and maybe $error->get_code() be stored here?

update_option(
'od_rest_api_info',
array(
'status' => 'forbidden',
Copy link
Member

Choose a reason for hiding this comment

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

Instead of storing the string, what about storing the $status_code instead?

'available' => false,
)
);
}
Copy link
Member

Choose a reason for hiding this comment

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

The else condition should be added as an error result as well. Here especially the $status_code could be used.

&& count( $expected_params ) === count( array_intersect( $data['data']['params'], $expected_params ) )
) {
// The REST API endpoint is available.
update_option(
Copy link
Member

Choose a reason for hiding this comment

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

Instead of having update_option() appearing in multiple places, each condition could populate an $info variable which is then sent into update_option() once at the end of the function.

@westonruter westonruter added [Type] Enhancement A suggestion for improvement of an existing feature [Plugin] Optimization Detective Issues for the Optimization Detective plugin labels Dec 19, 2024
@b1ink0 b1ink0 changed the title Optimization Detective can be blocked from working due to sites disabling the REST API Add site health check to detect blocked REST API and short-circuit optimization when Inaccessible Dec 20, 2024
wp_schedule_event( time(), 'hourly', 'od_rest_api_health_check_event' );
}
}
add_action( 'wp', 'od_schedule_rest_api_health_check' );
Copy link
Member

Choose a reason for hiding this comment

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

Is this a best practice? Should it rather go in admin_init to avoid frontend writes? I'm not sure what others do here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think scheduling on plugin activation hook will be better than admin_init.

Copy link
Member

Choose a reason for hiding this comment

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

The problem is that the plugin activation hook doesn't work when network-activating a plugin in multisite.

Copy link
Member

Choose a reason for hiding this comment

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

In looking at WP_Site_Health, it goes ahead and schedules an event even for frontend requests, since it calls its maybe_create_scheduled_event method in the constructor. And the instance is loaded in wp-settings.php. Nevertheless, since a database write is involved, it is preferable if event scheduling happens via an admin request and not unauthenticated frontend requests.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Please look at this comment regarding which hook should be used.

*/
function od_schedule_rest_api_health_check(): void {
if ( ! (bool) wp_next_scheduled( 'od_rest_api_health_check_event' ) ) {
wp_schedule_event( time(), 'hourly', 'od_rest_api_health_check_event' );
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 hourly is too much. Maybe weekly would make sense.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I currently have it set to run weekly, but that might be too infrequent. If a configuration change disables the REST API, it could take an entire week for user to detect the issue. I believe running it daily would be a better choice.

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 weekly is fine. It's not likely that a user would be changing the availability of the REST API. If we check at the moment that a plugin is activated, and then check weekly thereafter, then this should be good. Note that Site Health's own checks run on a weekly basis via the wp_site_health_scheduled_check action.

@b1ink0 b1ink0 marked this pull request as ready for review December 24, 2024 14:12
@b1ink0 b1ink0 requested a review from felixarntz as a code owner December 24, 2024 14:12
@westonruter
Copy link
Member

westonruter commented Jan 16, 2025

I realized that the Site Health test was getting buried among other recommended tests so I've bumped it to critical. I've also included the raw response so the user can have additional context:

image

@westonruter
Copy link
Member

Example I've seen with Nginx blocking the REST API shows up as this:

image

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.

Looking forward to getting this out there!

add_action(
'admin_notices',
static function (): void {
od_maybe_render_rest_api_health_check_admin_notice( false );
Copy link
Member

Choose a reason for hiding this comment

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

Ah, the rest case should actually run the admin_notices action to increase code coverage here.

Copy link
Member

Choose a reason for hiding this comment

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

Done in 6fee919

||
is_wp_error( $response )
) {
return $response;
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 I neglected to include a test for the cached state.

Copy link
Member

Choose a reason for hiding this comment

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

See 6fee919

$response = od_get_rest_api_health_check_response( false );
$result = od_compose_site_health_result( $response );
$is_unavailable = 'good' !== $result['status'];
update_option( 'od_rest_api_unavailable', $is_unavailable ? '1' : '0' );
Copy link
Member

Choose a reason for hiding this comment

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

This intentionally makes the option autoloaded. Maybe we should pass the parameter to make this explicit.

Copy link
Member

Choose a reason for hiding this comment

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

See 8926f10

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.

I think there are still a few things in here that need to be addressed, given the major additional changes made.

plugins/optimization-detective/site-health.php Outdated Show resolved Hide resolved
plugins/optimization-detective/site-health.php Outdated Show resolved Hide resolved
plugins/optimization-detective/site-health.php Outdated Show resolved Hide resolved
plugins/optimization-detective/site-health.php Outdated Show resolved Hide resolved
plugins/optimization-detective/site-health.php Outdated Show resolved Hide resolved
Comment on lines +190 to +200
$response = wp_remote_post(
$rest_url,
array(
'headers' => array( 'Content-Type' => 'application/json' ),
'sslverify' => false,
)
);

// This transient will be used when showing the admin notice with the plugin on the plugins screen.
// The 1-day expiration allows for fresher content than the weekly check initiated by Site Health.
set_transient( $transient_key, $response, DAY_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.

I'm a bit concerned about trying to store a potential WP_Error object in a transient. Can we parse that into a raw data array instead to make this safer?

Copy link
Member

Choose a reason for hiding this comment

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

Why is it concerning to store a WP_Error in a transient? The object gets serialized.

Copy link
Member

Choose a reason for hiding this comment

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

Hmm, is there prior art for that in WordPress? I suppose it could work, but have you tested this, both using the DB for transients as well as a persistent object cache?

Copy link
Member

Choose a reason for hiding this comment

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

Looking at the WP_Error class, for instance I would expect the $additional_data property to be missing, since it's not public. Granted, that data may not be important for what we're doing here, but it shows the fragility of relying on serialization.

Copy link
Member

Choose a reason for hiding this comment

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

As noted above, the test_od_get_rest_api_health_check_response test confirms that transients return unserialized WP_Error instances.

Copy link
Member

Choose a reason for hiding this comment

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

Taking a step back though, we're probably arguing too much about this detail. I think there is a risk, but feel free to include it. If someone reports this later as a problem, we can still address it.

Copy link
Member

Choose a reason for hiding this comment

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

Take a look at fetch_feed() such as used in the RSS widget. It stores an array that contains objects in it.

FWIW, I've been aware of storing class instances in transients and options since before I can remember. What's the difference with arrays? Both require serialization.

Copy link
Member

Choose a reason for hiding this comment

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

I was googling for other examples, and I found a reference to Site Kit storing objects in transients: https://wpengine.com/resources/guide-to-transients-in-wordpress/

I can't find the current reference to that code in GitHub though.

Here's an example in the Transients documentation about storing a WP_Query instance in a transient: https://developer.wordpress.org/apis/transients/#complete-example

Copy link
Member

Choose a reason for hiding this comment

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

Here's another example where an object is stored in a transient in wp_version_check(): https://github.com/WordPress/wordpress-develop/blob/2f654881e494424634d5821d1ef37c06edb8923a/src/wp-includes/update.php#L36-L73

Copy link
Member

Choose a reason for hiding this comment

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

* @param string $plugin_file Plugin file.
*/
function od_render_rest_api_health_check_admin_notice_in_plugin_row( string $plugin_file ): void {
if ( 'optimization-detective/load.php' !== $plugin_file ) { // TODO: What if a different plugin slug is used?
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 could use plugin_basename( __FILE__ ) to address the TODO here?

Suggested change
if ( 'optimization-detective/load.php' !== $plugin_file ) { // TODO: What if a different plugin slug is used?
if ( plugin_basename( __FILE__ ) !== $plugin_file ) {

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 that doesn't work, at least not in the wp-env test environment. Here plugin_basename( __FILE__ ) is returning performance/plugins/optimization-detective/site-health.php.

Copy link
Member

Choose a reason for hiding this comment

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

Note that Embed Optimizer has similar code running at the after_plugin_row_meta filter:

function embed_optimizer_print_row_meta_install_notice( string $plugin_file ): void {
$od_plugin_slug = 'optimization-detective';
$od_plugin_file = "{$od_plugin_slug}/load.php";
$od_plugin_name = 'Optimization Detective';
if ( 'embed-optimizer/load.php' === $plugin_file && ! is_plugin_active( $od_plugin_file ) ) {

Copy link
Member

Choose a reason for hiding this comment

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

That should only be a concern in a "weird" development setup like wp-env though, for regular sites the plugin basename will be optimization-detective/load.php.

I wonder how WordPress computes the value for $plugin_file if it's not matching plugin_basename( $plugin_root_dir_file ). 🤔

Anyway, what we could potentially do for now is create a function like optimization_detective_get_basename() that calls plugin_basename( __FILE__ ) and uses it unless there is more than one / character in it, in which case it's a "weird setup" situation and we can fall back to the hard-coded one.

Copy link
Member

Choose a reason for hiding this comment

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

I guess we could do this:

Suggested change
if ( 'optimization-detective/load.php' !== $plugin_file ) { // TODO: What if a different plugin slug is used?
if ( basename( __DIR__ ) !== dirname( $plugin_file ) ) {

Copy link
Member

Choose a reason for hiding this comment

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

Maybe even slightly safer as:

Suggested change
if ( 'optimization-detective/load.php' !== $plugin_file ) { // TODO: What if a different plugin slug is used?
if ( basename( __DIR__ ) . '/load.php' !== $plugin_file ) {

Copy link
Member

Choose a reason for hiding this comment

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

Granted, the Embed Optimizer logic really depends on the installed plugin slugs actually being the same ones as on the directory. Since this is just the addition of a notice which will be showing up in Site Health anyway, maybe we should just accept that there's a slight chance a notice won't be shown given it would very unlikely for anything but the official slug to be used:

Suggested change
if ( 'optimization-detective/load.php' !== $plugin_file ) { // TODO: What if a different plugin slug is used?
if ( 'optimization-detective/load.php' !== $plugin_file ) {

Copy link
Member

Choose a reason for hiding this comment

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

I agree it's not critical, but I also think the hard-coding is not ideal. So maybe let's just keep the TODO in place for now and think about this separately later.

*/
function od_register_endpoint(): void {

// The slug and cache_purge_post_id args are further validated via the validate_callback for the 'hmac' parameter,
// they are provided as input with the 'url' argument to create the HMAC by the server.
// The following args are referenced in od_compose_site_health_result().
Copy link
Member

Choose a reason for hiding this comment

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

See my comment above on why that's not a good idea.

Copy link
Member

Choose a reason for hiding this comment

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

Reverted in ddc6164

@westonruter westonruter force-pushed the add/site-health-check-for-od-rest-api branch from 93a51d8 to 02953c7 Compare January 17, 2025 18:41
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.

@westonruter This looks good, only a few minor points now. My only blocking concern still is #1762 (comment) (I don't think we can rely on serialization for sites with a persistent object cache).

plugins/optimization-detective/site-health.php Outdated Show resolved Hide resolved
plugins/optimization-detective/site-health.php Outdated Show resolved Hide resolved
* @param string $plugin_file Plugin file.
*/
function od_render_rest_api_health_check_admin_notice_in_plugin_row( string $plugin_file ): void {
if ( 'optimization-detective/load.php' !== $plugin_file ) { // TODO: What if a different plugin slug is used?
Copy link
Member

Choose a reason for hiding this comment

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

I agree it's not critical, but I also think the hard-coding is not ideal. So maybe let's just keep the TODO in place for now and think about this separately later.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Plugin] Optimization Detective Issues for the Optimization Detective plugin [Type] Enhancement A suggestion for improvement of an existing feature
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Optimization Detective can be blocked from working due to sites disabling the REST API
4 participants