HEX
Server: nginx
System: Linux pool195-106-36.bur.atomicsites.net 6.12.57+deb12-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1~bpo12+1 (2025-11-17) x86_64
User: (0)
PHP: 8.3.32
Disabled: pcntl_fork
Upload Files
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 '';
	}
}