File: //wordpress/plugins/wp-cloud-client/beta/src/Support/ErrorReporter.php
<?php
declare(strict_types=1);
namespace VPlugins\WPCloudClient\Support;
/**
* Detects plugin-level PHP fatal errors and reports them to wp-cloud-manager
* so Datadog alerts fire without waiting for a customer report.
*
* Uses register_shutdown_function() + error_get_last() — the only guaranteed
* execution point after a PHP fatal. All credentials are read from PHP
* constants because WordPress functions are unreliable after a fatal.
* The HTTP call uses raw curl (not wp_remote_post()) for the same reason.
*
* Only errors originating from within this plugin's directory are reported,
* so a fatal in another plugin or WordPress core never triggers an alert.
*/
final class ErrorReporter {
/** Fatal error types that indicate the site is down. */
private const FATAL_TYPES = [ E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR ];
/** Dedup window in seconds — same error won't re-alert within this period. */
private const DEDUP_TTL = 300;
/** Max milliseconds for the curl report call — must not block the error page. */
private const CURL_TIMEOUT_MS = 1000;
/** Plugin directory name used to scope error filtering. */
private const PLUGIN_DIR = 'wp-cloud-client';
/**
* Singleton instance.
*
* @var self|null
*/
private static ?self $instance = null;
/**
* Manager base URL cached at register() time.
*
* @var string
*/
private string $managerUrl = '';
/**
* WP Cloud atomic site ID cached at register() time.
*
* @var string
*/
private string $siteId = '';
/**
* Per-site API key cached at register() time.
*
* @var string
*/
private string $apiKey = '';
/**
* Site URL cached at register() time.
*
* @var string
*/
private string $siteUrl = '';
/**
* Private constructor — use getInstance().
*/
private function __construct() {}
/**
* Return the singleton instance.
*
* @return self
*/
public static function getInstance(): self {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Register the shutdown handler. Call once as early as possible in plugin
* boot so even a fatal during init is caught.
*
* Credentials are cached now while WordPress is still healthy.
*/
public function register(): void {
$this->managerUrl = $this->readManagerUrl();
$this->siteId = $this->readSiteId();
$this->apiKey = $this->readApiKey();
$this->siteUrl = get_site_url() ?: '';
register_shutdown_function( [ $this, 'handleShutdown' ] );
}
/**
* Shutdown handler — hooked via register_shutdown_function().
*
* @internal Called by PHP, not directly.
*/
public function handleShutdown(): void {
$error = error_get_last();
if ( null === $error ) {
return;
}
if ( ! in_array( $error['type'], self::FATAL_TYPES, true ) ) {
return;
}
if ( strpos( $error['file'], self::PLUGIN_DIR ) === false ) {
return;
}
$this->report( $error['type'], $error['message'], $error['file'], $error['line'] );
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* Deduplicate and dispatch the error report to the manager.
*
* @param int $type PHP error type constant.
* @param string $message Error message.
* @param string $file File where the error occurred.
* @param int $line Line number.
*/
private function report( int $type, string $message, string $file, int $line ): void {
if ( empty( $this->managerUrl ) || empty( $this->siteId ) || empty( $this->apiKey ) ) {
return;
}
if ( ! $this->acquireDedup( $file, $line, $message ) ) {
return;
}
$url = rtrim( $this->managerUrl, '/' ) . '/wordpress/v1/site/' . rawurlencode( $this->siteId ) . '/report-plugin-error';
$payload = (string) wp_json_encode(
[
'error_type' => $type,
'message' => $message,
'file' => $file,
'line' => $line,
'site_url' => $this->siteUrl,
'php_version' => PHP_VERSION,
]
);
$this->curlPost( $url, $payload );
}
/**
* Attempt to acquire a deduplication lock for the given error.
*
* Uses APCu if available (in-memory, no DB needed after a fatal), falling
* back to a temp file so it always works.
*
* @param string $file File where the error occurred.
* @param int $line Line number.
* @param string $message Error message.
*
* @return bool True if the lock was acquired (first report); false if already reported.
*/
private function acquireDedup( string $file, int $line, string $message ): bool {
$key = 'wpcc_fatal_' . md5( $file . ':' . $line . ':' . $message );
if ( function_exists( 'apcu_fetch' ) ) {
if ( apcu_fetch( $key ) ) {
return false;
}
apcu_store( $key, true, self::DEDUP_TTL );
return true;
}
$lockFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $key;
if ( file_exists( $lockFile ) && ( time() - (int) filemtime( $lockFile ) ) < self::DEDUP_TTL ) {
return false;
}
touch( $lockFile ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- WP_Filesystem unavailable after a fatal
return true;
}
/**
* Fire-and-forget curl POST to the manager.
*
* Cannot use wp_remote_post() here — WordPress may be broken after a
* fatal. Raw curl is the only reliable option.
*
* @param string $url Full endpoint URL.
* @param string $payload JSON-encoded request body.
*/
private function curlPost( string $url, string $payload ): void {
if ( ! function_exists( 'curl_init' ) ) {
return;
}
$ch = curl_init(); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_init
curl_setopt_array( // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt_array
$ch,
[
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-WPCC-Site-ID: ' . $this->siteId,
'X-WPCC-API-Key: ' . $this->apiKey,
],
CURLOPT_TIMEOUT_MS => self::CURL_TIMEOUT_MS,
CURLOPT_CONNECTTIMEOUT_MS => self::CURL_TIMEOUT_MS,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => true,
]
);
curl_exec( $ch ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_exec
curl_close( $ch ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_close,Generic.PHP.DeprecatedFunctions.Deprecated
}
// -------------------------------------------------------------------------
// Credential readers — use constants only, no WordPress functions
// -------------------------------------------------------------------------
/**
* Resolve the manager base URL via WPCC_MANAGER_URL constant then wpcc_manager_url filter.
*
* @return string
*/
private function readManagerUrl(): string {
if ( defined( 'WPCC_MANAGER_URL' ) && is_scalar( WPCC_MANAGER_URL ) ) {
return trim( (string) WPCC_MANAGER_URL );
}
return trim( (string) apply_filters( 'wpcc_manager_url', '' ) );
}
/**
* Resolve the WP Cloud atomic site ID.
*
* @return string
*/
private function readSiteId(): string {
if ( defined( 'ATOMIC_SITE_ID' ) && is_scalar( ATOMIC_SITE_ID ) ) {
return trim( (string) ATOMIC_SITE_ID );
}
try {
$p = new \Atomic_Persistent_Data();
$v = trim( (string) ( $p->ATOMIC_SITE_ID ?? '' ) );
if ( '' !== $v ) {
return $v;
}
} catch ( \Throwable $e ) {
unset( $e ); // Atomic_Persistent_Data class unavailable — credential stays empty.
}
return '';
}
/**
* Resolve the per-site API key.
*
* @return string
*/
private function readApiKey(): string {
if ( defined( 'WSP_API_KEY' ) && is_scalar( WSP_API_KEY ) ) {
return trim( (string) WSP_API_KEY );
}
try {
$p = new \Atomic_Persistent_Data();
$v = trim( (string) ( $p->WSP_API_KEY ?? '' ) );
if ( '' !== $v ) {
return $v;
}
} catch ( \Throwable $e ) {
unset( $e ); // Atomic_Persistent_Data class unavailable — credential stays empty.
}
return '';
}
}