diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-pcg-log-blocked-activation b/projects/packages/jetpack-mu-wpcom/changelog/add-pcg-log-blocked-activation new file mode 100644 index 000000000000..6c01b367f3f7 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/add-pcg-log-blocked-activation @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Plugin Conflicts Guardian: emit logstash events when the guard refuses or recovers from a bad change — `Activation blocked` (refused activation), `Update blocked` (refused install/update with a parse error), and `Update rolled back` (post-update fatal triggered a rollback). All three share the `plugin-conflicts-guardian` feature bucket so the full PCG-block surface can be measured from one filter. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/activation-guard.php b/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/activation-guard.php index 441ca8f3a6fb..b5ee1bcab46f 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/activation-guard.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/activation-guard.php @@ -114,21 +114,48 @@ function pcg_guard_evaluate_plugins( $plugins ) { $blocked_plugin = pcg_guard_get_blocked_plugin( $result, $paths ); if ( '' !== $blocked_plugin ) { - return array( + $blocked = array( $blocked_plugin => pcg_guard_format_block_reason( $result ), ); + } else { + // Verdict didn't pin a specific plugin (e.g. probe terminated without a + // JSON body, or the captured `file` was outside any candidate's tree). + // Surface a batch-level message so we don't blame an arbitrary plugin. + $reason = sprintf( + /* translators: 1: locale-formatted list of plugin basenames; 2: probe verdict reason. */ + __( 'One of these plugins caused a fatal during the pre-flight check: %1$s. Reason: %2$s', 'jetpack-mu-wpcom' ), + wp_sprintf_l( '%l', array_keys( $paths ) ), + pcg_guard_format_block_reason( $result ) + ); + $blocked = array( '' => $reason ); } - // Verdict didn't pin a specific plugin (e.g. probe terminated without a - // JSON body, or the captured `file` was outside any candidate's tree). - // Surface a batch-level message so we don't blame an arbitrary plugin. - $reason = sprintf( - /* translators: 1: locale-formatted list of plugin basenames; 2: probe verdict reason. */ - __( 'One of these plugins caused a fatal during the pre-flight check: %1$s. Reason: %2$s', 'jetpack-mu-wpcom' ), - wp_sprintf_l( '%l', array_keys( $paths ) ), - pcg_guard_format_block_reason( $result ) + pcg_guard_log_blocked_activation( array_keys( $paths ), $blocked, $result ); + + return $blocked; +} + +/** + * Log an activation block to logstash. Best-effort; no-op off WordPress.com. + * + * @param string[] $checked Probe batch as basenames. + * @param array $blocked Map of basename => admin-notice reason. Empty-string key = batch-level fallback. + * @param array $result Probe verdict from PCG_Load_Tester::test(). + * @return void + */ +function pcg_guard_log_blocked_activation( array $checked, array $blocked, array $result ) { + pcg_log_event( + 'Activation blocked', + array( + 'checked' => $checked, + 'blocked' => array_keys( $blocked ), + 'status' => (string) ( $result['status'] ?? '' ), + // Basename only — absolute paths leak install layout. + 'file' => isset( $result['file'] ) ? basename( (string) $result['file'] ) : '', + 'line' => (int) ( $result['line'] ?? 0 ), + 'reason' => (string) ( $result['message'] ?? '' ), + ) ); - return array( '' => $reason ); } /** diff --git a/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/class-pcg-load-tester.php b/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/class-pcg-load-tester.php index e4c3e8402ef1..128f6de1f6d3 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/class-pcg-load-tester.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/class-pcg-load-tester.php @@ -133,27 +133,13 @@ protected function is_error( $result ) { * @return void */ protected function log_probe_error( $mode, array $plugin_mains, array $front_result, array $admin_result ) { - if ( ! function_exists( 'log2logstash' ) ) { - $log2logstash_path = WP_CONTENT_DIR . '/lib/log2logstash/log2logstash.php'; - if ( ! is_readable( $log2logstash_path ) ) { - return; - } - require_once $log2logstash_path; - } - - log2logstash( + pcg_log_event( + 'Probe transport error', array( - 'feature' => 'plugin-conflicts-guardian', - 'message' => 'Probe transport error', - 'extra' => wp_json_encode( - array( - 'mode' => $mode, - 'plugins' => $this->relative_basenames( $plugin_mains ), - 'front' => $this->probe_error_reason( $front_result ), - 'admin' => $this->probe_error_reason( $admin_result ), - ), - JSON_UNESCAPED_SLASHES - ), + 'mode' => $mode, + 'plugins' => $this->relative_basenames( $plugin_mains ), + 'front' => $this->probe_error_reason( $front_result ), + 'admin' => $this->probe_error_reason( $admin_result ), ) ); } diff --git a/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/pcg-log.php b/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/pcg-log.php new file mode 100644 index 000000000000..be2bcd3be7a8 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/pcg-log.php @@ -0,0 +1,69 @@ + 'plugin-conflicts-guardian', + 'message' => (string) $message, + 'extra' => wp_json_encode( pcg_log_redact_paths( $extra ), JSON_UNESCAPED_SLASHES ), + ) + ); + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- best-effort: a logging failure must not escalate on activation / install / update request paths. + unset( $e ); + } +} + +/** + * Recursively replace `ABSPATH` and `WP_CONTENT_DIR` prefixes inside string + * values with `.../` so log lines don't leak the install layout. Keeps the + * relative tail (`plugins/foo/bar.php`), which is the useful part for triage. + * + * @param mixed $value Scalar or array. + * @return mixed + */ +function pcg_log_redact_paths( $value ) { + if ( is_array( $value ) ) { + return array_map( 'pcg_log_redact_paths', $value ); + } + if ( ! is_string( $value ) || '' === $value ) { + return $value; + } + // WP_CONTENT_DIR first — it's a longer prefix that's typically *under* + // ABSPATH on standard installs, so swapping ABSPATH first would shadow it. + $replacements = array(); + if ( defined( 'WP_CONTENT_DIR' ) && '' !== WP_CONTENT_DIR ) { + $replacements[ rtrim( WP_CONTENT_DIR, '/' ) . '/' ] = '.../'; + } + if ( defined( 'ABSPATH' ) && '' !== ABSPATH ) { + $replacements[ rtrim( ABSPATH, '/' ) . '/' ] = '.../'; + } + if ( empty( $replacements ) ) { + return $value; + } + return strtr( $value, $replacements ); +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/plugin-conflicts-guardian.php b/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/plugin-conflicts-guardian.php index 78e87f85afb1..60efc8ef296d 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/plugin-conflicts-guardian.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/plugin-conflicts-guardian.php @@ -10,6 +10,7 @@ /** * Load dependencies. */ +require_once __DIR__ . '/pcg-log.php'; require_once __DIR__ . '/class-pcg-load-tester.php'; // Probe endpoint must answer front-end requests, so it's not gated on is_admin(). diff --git a/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/update-guard.php b/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/update-guard.php index c1f8ca1c9369..73f73f76304a 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/update-guard.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/update-guard.php @@ -56,6 +56,9 @@ function pcg_update_guard_check( $source, $remote_source, $upgrader, $hook_extra } $first = $scan['errors'][0]; + + pcg_update_guard_log_blocked( $action, $hook_extra, $scan, (string) $source ); + return new WP_Error( 'pcg_update_parse_error', sprintf( @@ -70,6 +73,38 @@ function pcg_update_guard_check( $source, $remote_source, $upgrader, $hook_extra ); } +/** + * Log a refused install/update to logstash. Best-effort; no-op off WordPress.com. + * + * @param string $action `install` or `update`. + * @param array $hook_extra Hook payload from `upgrader_source_selection`. + * @param array $scan Result from `pcg_update_guard_scan_for_parse_errors()`. + * @param string $source Extracted package directory (fallback slug source on installs, + * since `Plugin_Upgrader::install()` doesn't populate `hook_extra['plugin']`). + * @return void + */ +function pcg_update_guard_log_blocked( $action, array $hook_extra, array $scan, $source = '' ) { + $first = $scan['errors'][0]; + + $slug = (string) ( $hook_extra['plugin'] ?? ( $hook_extra['theme'] ?? '' ) ); + if ( '' === $slug && '' !== $source ) { + $slug = basename( untrailingslashit( $source ) ); + } + + pcg_log_event( + 'Update blocked', + array( + 'action' => (string) $action, + 'slug' => $slug, + // Basename only — absolute paths leak install layout. + 'file' => basename( (string) $first['file'] ), + 'line' => (int) $first['line'], + 'reason' => (string) $first['message'], + 'error_count' => count( $scan['errors'] ), + ) + ); +} + /** * Tokenize every `.php` under $dir with TOKEN_PARSE and collect the failures. * Bails out once the wall-clock budget is exceeded. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/update-healthcheck.php b/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/update-healthcheck.php index 2705d9b13009..3c8f1e405c59 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/update-healthcheck.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/update-healthcheck.php @@ -140,6 +140,7 @@ function pcg_healthcheck_after_update( $upgrader, $hook_extra ) { // phpcs:ignor foreach ( $candidates as $candidate ) { $rollback = PCG_Rollback::to_snapshot( $candidate['snapshot'] ); pcg_healthcheck_stash_notice( $candidate['plugin_file'], $result, $rollback, $candidate['plugin_name'], $candidate['new_version'] ); + pcg_healthcheck_log_rollback( $candidate, $result, $rollback ); /** * Fires after a post-update probe fails and rollback has been attempted. @@ -153,6 +154,32 @@ function pcg_healthcheck_after_update( $upgrader, $hook_extra ) { // phpcs:ignor } } +/** + * Log a post-update rollback to logstash. Best-effort; no-op off WordPress.com. + * + * @param array $candidate Per-plugin context built in `pcg_healthcheck_after_update()`. + * @param array $probe Shared probe verdict from `PCG_Load_Tester::test()`. + * @param array $rollback Result from `PCG_Rollback::to_snapshot()`. + * @return void + */ +function pcg_healthcheck_log_rollback( array $candidate, array $probe, array $rollback ) { + pcg_log_event( + 'Update rolled back', + array( + 'plugin' => (string) $candidate['plugin_file'], + 'new_version' => (string) $candidate['new_version'], + 'previous_version' => (string) ( $candidate['snapshot']['version'] ?? '' ), + 'probe_status' => (string) ( $probe['status'] ?? '' ), + // Basename only — absolute paths leak install layout. + 'probe_file' => isset( $probe['file'] ) ? basename( (string) $probe['file'] ) : '', + 'probe_line' => (int) ( $probe['line'] ?? 0 ), + 'probe_reason' => (string) ( $probe['message'] ?? '' ), + 'rollback_status' => (string) ( $rollback['status'] ?? '' ), + 'restored_to' => (string) ( $rollback['restored_to'] ?? '' ), + ) + ); +} + /** * Is this $hook_extra a plugin update (not an install, not a theme)? * diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/plugin-conflicts-guardian/Plugin_Conflicts_Guardian_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/plugin-conflicts-guardian/Plugin_Conflicts_Guardian_Test.php index 4f4a8f8afb1a..d1cef85e4864 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/plugin-conflicts-guardian/Plugin_Conflicts_Guardian_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/plugin-conflicts-guardian/Plugin_Conflicts_Guardian_Test.php @@ -8,6 +8,7 @@ use Automattic\Jetpack\Jetpack_Mu_Wpcom; use PHPUnit\Framework\Attributes\DataProvider; +require_once Jetpack_Mu_Wpcom::PKG_DIR . 'src/features/plugin-conflicts-guardian/pcg-log.php'; require_once Jetpack_Mu_Wpcom::PKG_DIR . 'src/features/plugin-conflicts-guardian/class-pcg-load-tester.php'; require_once Jetpack_Mu_Wpcom::PKG_DIR . 'src/features/plugin-conflicts-guardian/activation-guard.php'; require_once Jetpack_Mu_Wpcom::PKG_DIR . 'src/features/plugin-conflicts-guardian/update-guard.php';