Skip to content
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,string> $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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

reason might also leak absolute paths if it's just raw PHP error text 😅
Since this is log, it should be fine?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, fine for log purposes — reason is the raw probe verdict (PHP fatal/parse-error message). It can carry an absolute path, but everything stays in our internal logstash bucket; no PII, no exposure to the site owner. We accept that as the cost of having an actually useful reason field for triage. The file field stays sanitized to basename only because that one's structured/queried, so worth keeping consistent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Actually went ahead and sanitized in ef3e3ca — added pcg_log_redact_paths() that walks the extra payload before JSON-encoding and rewrites any ABSPATH / WP_CONTENT_DIR prefix to .../. Keeps the relative tail (plugins/foo/bar.php) which is the useful part for triage, drops the install-layout leak. Applied centrally in pcg_log_event() so all events (including the pre-existing Probe transport error) get it for free.

'file' => isset( $result['file'] ) ? basename( (string) $result['file'] ) : '',
'line' => (int) ( $result['line'] ?? 0 ),
'reason' => (string) ( $result['message'] ?? '' ),
)
);
return array( '' => $reason );
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ),
)
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php
/**
* Shared logstash dispatch for the Plugin Conflicts Guardian feature.
*
* @package automattic/jetpack-mu-wpcom
*/

/**
* Emit an event to the `plugin-conflicts-guardian` logstash bucket.
*
* Best-effort: a logging failure must never escalate into a fatal,
* since callers run on activation / install / update request paths.
* No-op outside WordPress.com (no `log2logstash` available).
*
* @param string $message Event message slug (e.g. "Activation blocked").
* @param array $extra Event-specific properties; JSON-encoded into the `extra` field.
* @return void
*/
function pcg_log_event( $message, array $extra ) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since this is best-effort , should we wrap it in a try/catch block? That way we prevent exceptions here blocking the rest of the flows.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 8ceac93 — wrapped the whole dispatcher in try/catch with a fail-open empty handler. The require + json_encode + log2logstash chain now can't escalate into a fatal on the activation / install / update request paths.

try {
if ( ! function_exists( 'log2logstash' ) ) {
$log2logstash_path = WP_CONTENT_DIR . '/lib/log2logstash/log2logstash.php';
if ( ! is_readable( $log2logstash_path ) ) {
return;
}
require_once $log2logstash_path;
}

log2logstash(
array(
'feature' => '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 );
}
Original file line number Diff line number Diff line change
Expand Up @@ -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().
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)?
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading