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 26 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.
  • A site health check will run weekly and update the status.
  • Based on the status, a notice will be displayed on the plugins page.

Scenarios:

  1. When the health check passes
    Screenshot 2024-12-20 at 7 10 55 PM

  2. When the REST API endpoint returns a forbidden error
    Screenshot 2024-12-20 at 7 12 27 PM
    Screenshot 2024-12-24 at 7 28 55 PM

  3. When the REST API endpoint returns an unauthorized error
    Screenshot 2024-12-20 at 7 11 41 PM
    Screenshot 2024-12-24 at 7 28 23 PM

  4. When other errors are encountered
    Screenshot 2024-12-20 at 7 14 18 PM
    Screenshot 2024-12-24 at 7 29 50 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
Copy link

github-actions bot commented Dec 24, 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: adamsilverstein <[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 December 24, 2024 14:12
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.

Sorry for the delay in reviewing. Just getting back from the holidays.

Comment on lines +54 to +65

register_activation_hook(
__FILE__,
static function () use ( $bootstrap ): void {
/*
* The activation hook is called before the init action, so the plugin is not loaded yet. This
* means that the plugin must be bootstrapped here to run the activation logic.
*/
$bootstrap();
od_rest_api_health_check_plugin_activation();
}
);
Copy link
Member

Choose a reason for hiding this comment

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

See note below about how the activation hook does not run when network-activating a plugin. I think this should switch to use admin_init. For example, add this to hooks.php:

add_action( 'admin_init', 'od_rest_api_health_check_plugin_activation' );

Copy link
Contributor Author

@b1ink0 b1ink0 Jan 8, 2025

Choose a reason for hiding this comment

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

These are the two reasons why I chose the activation hook approach:

  1. Immediate Notice Display:
    With the activation hook approach, when the user activates the plugin, they will see the notice about the REST API during the same page load as the plugin activation.

    • If we use the admin_init hook instead, the user will not see the notice immediately after activating the plugin because the health check is only scheduled during the plugin activation page load. It will execute asynchronously on the next load, and the notice will only appear on the third page load.
    • If a delayed notice is acceptable, we can proceed with the admin_init hook.
  2. Efficient Option Handling:
    As mentioned here, we should add an empty option during plugin activation. If we use the admin_init hook, the code to check whether the option exists and create it if not will run on every admin page load, which is inefficient.

Alternative Approach:
We can also adopt a hybrid approach where the event is scheduled in the admin_init hook, while the logic for adding the option and immediate notice display is handled in the activation hook. This approach would also address the multisite issue where the event may not get scheduled.

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.

2. If we use the admin_init hook, the code to check whether the option exists and create it if not will run on every admin page load, which is inefficient.

Will this be inefficient? WordPress will remembers options which don't exist in notoptions so that no DB query occurs with the next get_option() call (when an external object cache is being used). Nevertheless, if upon activation the plugin has an admin_init action which will immediately call the Site Health logic to see if the REST API is available, then this will set the option so that no such request happens with the next admin page load. Also, if this request is gated behind admin requests in the first place, then this further reduces the performance impact since frontend users would experience nothing.

So I propose something like this:

/**
 * Plugin activation hook for the REST API health check.
 *
 * @since n.e.x.t
 */
function od_rest_api_health_check_plugin_activation(): void {
	// The option already exists, so do nothing.
	if ( false !== get_option( 'od_rest_api_info' ) ) {
		return;
	}

	// This will populate the od_rest_api_info option so that the function won't execute for the next page load.
	od_run_scheduled_rest_api_health_check();
}

and:

add_action( 'admin_init`, 'od_rest_api_health_check_plugin_activation' );

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As discussed in #1762 (comment), we don't want to schedule any cron events at this time. To ensure our check runs for the first time, we can modify the provided function by directly calling od_optimization_detective_rest_api_test instead of od_run_scheduled_rest_api_health_check.

/**
 * Plugin activation hook for the REST API health check.
 *
 * @since n.e.x.t
 */
function od_rest_api_health_check_plugin_activation(): void {
    // If the option already exists, do nothing.
    if ( false !== get_option( 'od_rest_api_info' ) ) {
        return;
    }

    // This will populate the od_rest_api_info option so that the function won't execute on the next page load.
    od_optimization_detective_rest_api_test();
}

add_action( 'admin_init', 'od_rest_api_health_check_plugin_activation' );

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also for the admin notices which needs to be displayed only once after plugin activation can be included in this function.

/**
 * Plugin activation hook for the REST API health check.
 *
 * @since n.e.x.t
 */
function od_rest_api_health_check_plugin_activation(): void {
	// If the option already exists, do nothing.
	$rest_api_info = get_option( 'od_rest_api_info' );
	if ( false !== $rest_api_info ) {
		return;
	}

	// This will populate the od_rest_api_info option so that the function won't execute on the next page load.
	od_optimization_detective_rest_api_test();

	if (
		isset( $od_rest_api_info['available'] ) &&
		! (bool) $od_rest_api_info['available'] &&
		isset( $od_rest_api_info['error_message'] )
	) {
		wp_admin_notice(
			esc_html( $rest_api_info['error_message'] ),
			array(
				'type'               => 'warning',
				'additional_classes' => array( 'notice-alt' ),
			)
		);
	}
}

add_action( 'admin_init', 'od_rest_api_health_check_plugin_activation' );

Comment on lines 29 to 33
add_filter( 'site_status_tests', 'od_optimization_detective_add_rest_api_test' );

// Hook for the scheduled REST API health check.
add_action( 'od_rest_api_health_check_event', 'od_run_scheduled_rest_api_health_check' );
add_action( 'after_plugin_row_meta', 'od_rest_api_health_check_admin_notice', 30 );
Copy link
Member

Choose a reason for hiding this comment

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

What about moving these to a section in the plugin's existing hooks.php?

Comment on lines 13 to 28
/**
* Adds the Optimization Detective REST API check to site health tests.
*
* @since n.e.x.t
*
* @param array{direct: array<string, array{label: string, test: string}>} $tests Site Health Tests.
* @return array{direct: array<string, array{label: string, test: string}>} Amended tests.
*/
function od_optimization_detective_add_rest_api_test( array $tests ): array {
$tests['direct']['optimization_detective_rest_api'] = array(
'label' => __( 'Optimization Detective REST API Endpoint Availability', 'optimization-detective' ),
'test' => 'od_optimization_detective_rest_api_test',
);

return $tests;
}
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 this could be moved to a /site-health.php file.

Copy link
Member

Choose a reason for hiding this comment

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

Since there is only one Site Health test, what about eliminating the load.php file and moving this file to /site-health.php?

@@ -127,5 +139,8 @@ class_alias( OD_URL_Metric_Group_Collection::class, 'OD_URL_Metrics_Group_Collec

// Add hooks for the above requires.
require_once __DIR__ . '/hooks.php';

// Load site health checks.
require_once __DIR__ . '/site-health/load.php';
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 below, I think the directory can be removed in favor of just a simpler site-health.php for now:

Suggested change
require_once __DIR__ . '/site-health/load.php';
require_once __DIR__ . '/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.

I think this file can be removed in favor of moving the /site-health/rest-api/helper.php file below up to /site-health.php.

*/
function od_schedule_rest_api_health_check(): void {
if ( ! (bool) wp_next_scheduled( 'od_rest_api_health_check_event' ) ) {
wp_schedule_event( time(), 'weekly', '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.

Do we really need to check this weekly? That seems excessive as it is very unlikely to change. How about checking when the plugin is activated and when users visit the Site Health screen?

Copy link
Member

Choose a reason for hiding this comment

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

Oh, right, won't this test automatically get run anyway due to wp_site_health_scheduled_check? cf. #1762 (comment)

In that way, we don't need to schedule our own action at all but just re-use Site Health. Our test can persist the result in an option in a way similar to how site health in core stores issue counts in a transient.

if ( ! (bool) get_option( 'od_rest_api_info' ) ) {
add_option( 'od_rest_api_info', array() );
}
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.

just run check directly, caching results?

Copy link

codecov bot commented Jan 8, 2025

Codecov Report

Attention: Patch coverage is 48.73950% with 61 lines in your changes missing coverage. Please review.

Please upload report for BASE (trunk@dde008b). Learn more about missing BASE report.

Files with missing lines Patch % Lines
plugins/optimization-detective/site-health.php 50.98% 50 Missing ⚠️
plugins/optimization-detective/load.php 0.00% 8 Missing ⚠️
plugins/optimization-detective/hooks.php 0.00% 3 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             trunk    #1762   +/-   ##
========================================
  Coverage         ?   57.21%           
========================================
  Files            ?       85           
  Lines            ?     6622           
  Branches         ?        0           
========================================
  Hits             ?     3789           
  Misses           ?     2833           
  Partials         ?        0           
Flag Coverage Δ
multisite 57.21% <48.73%> (?)
single 34.62% <45.37%> (?)

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.

Comment on lines +158 to +164
wp_admin_notice(
esc_html( $od_rest_api_info['error_message'] ),
array(
'type' => 'warning',
'additional_classes' => array( 'inline', 'notice-alt' ),
)
);
Copy link
Member

Choose a reason for hiding this comment

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

In wp-env, which doesn't support loopback requests, it's expected for there an error message to display. And I am indeed getting one, but it's not telling me what impact the error has:

image

In other words, this error is happening, but it should explain that Optimization Detective will not work because of the error. In other words, the same strings shown on the Site Health screen should be shown here as well:

$result['status'] = 'recommended';
$result['label'] = __( 'Error accessing the REST API endpoint', 'optimization-detective' );
$result['description'] = sprintf(
'<p>%s</p>',
esc_html__( 'There was an issue reaching the REST API endpoint. This might be due to server settings or the REST API being disabled.', 'optimization-detective' )
);

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, I am displaying $result['label'] in the notices, but would using the more descriptive $result['description'] make more sense for the inline and also for the one time notice at top?

Copy link
Member

Choose a reason for hiding this comment

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

Sorry, where are you displaying $result['label'] in the notices? I only see it in od_optimization_detective_rest_api_test(). I think the same notice can be displayed inline as is what is shown not-inline in admin_notices.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Screenshot 2024-12-24 at 7 28 55 PM

Like this my question is should we display more detailed notice everywhere or this is fine?

Copy link
Member

Choose a reason for hiding this comment

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

Show the more detailed notice. In fact, perhaps only show the notice in admin_notices once (after activating the plugin) but then thereafter only show it inline. This would avoid the notice from appearing duplicated.

@@ -15,3 +15,6 @@
OD_URL_Metrics_Post_Type::add_hooks();
add_action( 'wp', 'od_maybe_add_template_output_buffer_filter' );
add_action( 'wp_head', 'od_render_generator_meta_tag' );
add_filter( 'site_status_tests', 'od_optimization_detective_add_rest_api_test' );
add_action( 'od_rest_api_health_check_event', 'od_run_scheduled_rest_api_health_check' );
add_action( 'after_plugin_row_meta', 'od_rest_api_health_check_admin_notice', 30 );
Copy link
Member

Choose a reason for hiding this comment

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

Question: Why use the after_plugin_row_meta action? If it so happens that the user has many plugins active, which is common, then Optimization Detective may not appear on the initial page and so the error will never be seen. I think the admin_notices action is 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.

Or rather there could be an inline notice with the plugin row which displays always, and then there could also be a notice shown at admin_notices for the first time that the logic in https://github.com/WordPress/performance/pull/1762/files#r1907965242 runs. In this way, a user should see the notice after activating the plugin on the plugin list table screen, but then it will go away after reloading to stop nagging them forever. But they'll still be able to see the notice if they scroll down to Optimization Detective as well as when they open Site Health.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

A potential solution could be to display a one-time notice upon plugin activation, as suggested in this comment #1762(comment). Additionally, a persistent error message can be shown inline using the after_plugin_row_meta hook.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, a one-time notice upon activation, and then otherwise if this one-time notice is not displayed at admin_notices to then instead show it as an inline notice at after_plugin_row_meta.

esc_html( $od_rest_api_info['error_message'] ),
array(
'type' => 'warning',
'additional_classes' => array( 'inline', 'notice-alt' ),
Copy link
Member

Choose a reason for hiding this comment

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

Related to https://github.com/WordPress/performance/pull/1762/files#r1907962460, I think this should not use inline since a user may not scroll down to see the error message with the activated plugin row.

Suggested change
'additional_classes' => array( 'inline', 'notice-alt' ),
'additional_classes' => array( 'notice-alt' ),

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
3 participants