*/ private static array $seen = array(); /** * Attach an action that cannot take the page down. * * @param string $hook Hook name. * @param callable $callback The callback. * @param int $priority Priority. * @param int $args Accepted argument count. */ public static function action( string $hook, callable $callback, int $priority = 10, int $args = 1 ): void { add_action( $hook, self::wrap( $hook, $callback, false ), $priority, $args ); } /** * Attach a filter that cannot take the page down. * * On failure the first argument is returned untouched — the value the * filter was given. A filter that fails silently leaves the page exactly as * it would have been without this plugin, which is the only safe answer. * * @param string $hook Hook name. * @param callable $callback The callback. * @param int $priority Priority. * @param int $args Accepted argument count. */ public static function filter( string $hook, callable $callback, int $priority = 10, int $args = 1 ): void { add_filter( $hook, self::wrap( $hook, $callback, true ), $priority, $args ); } /** * Run something once, safely, outside a hook. * * @param string $label What to call this if it fails. * @param callable $callback The work. * @param mixed $fallback Returned if it throws. * @return mixed */ public static function run( string $label, callable $callback, $fallback = null ) { try { return $callback(); } catch ( Throwable $e ) { self::record( $label, $e ); return $fallback; } } /** * Build the wrapper closure. * * @param string $hook Hook name, used as the label. * @param callable $callback The callback. * @param bool $is_filter Whether the first argument must be returned. * @return callable */ private static function wrap( string $hook, callable $callback, bool $is_filter ): callable { return static function ( ...$passed ) use ( $hook, $callback, $is_filter ) { try { return $callback( ...$passed ); } catch ( Throwable $e ) { self::record( $hook . ' → ' . self::name( $callback ), $e ); // A filter must hand back something of the right shape; the // input it was given is the only value guaranteed to be that. return $is_filter ? ( $passed[0] ?? null ) : null; } }; } /** * Write a failure down, once per callback per request. * * @param string $label Where it happened. * @param Throwable $e What happened. */ private static function record( string $label, Throwable $e ): void { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log sprintf( 'AI Rank Suite: %s threw %s: %s in %s:%d', $label, get_class( $e ), $e->getMessage(), $e->getFile(), $e->getLine() ) ); } if ( isset( self::$seen[ $label ] ) ) { return; } self::$seen[ $label ] = true; $log = get_option( self::OPTION, array() ); if ( ! is_array( $log ) ) { $log = array(); } $existing = isset( $log[ $label ] ) && is_array( $log[ $label ] ) ? $log[ $label ] : array(); $log[ $label ] = array( 'message' => $e->getMessage(), 'class' => get_class( $e ), 'file' => str_replace( ABSPATH, '', $e->getFile() ), 'line' => $e->getLine(), 'count' => (int) ( $existing['count'] ?? 0 ) + 1, 'first' => (int) ( $existing['first'] ?? time() ), 'last' => time(), ); // Twenty distinct failing callbacks is already a broken install; past // that the option is just growing, so the oldest go. if ( count( $log ) > 20 ) { uasort( $log, static fn( array $a, array $b ): int => ( $a['last'] ?? 0 ) <=> ( $b['last'] ?? 0 ) ); $log = array_slice( $log, -20, null, true ); } update_option( self::OPTION, $log, false ); } /** * Everything that has failed, newest first. Read by the admin screens. * * @return array> */ public static function failures(): array { $log = get_option( self::OPTION, array() ); if ( ! is_array( $log ) ) { return array(); } $rows = array(); foreach ( $log as $label => $row ) { if ( ! is_array( $row ) ) { continue; } $row['label'] = (string) $label; $rows[] = $row; } usort( $rows, static fn( array $a, array $b ): int => ( $b['last'] ?? 0 ) <=> ( $a['last'] ?? 0 ) ); return $rows; } /** Forget everything, for after a fix. */ public static function clear(): void { delete_option( self::OPTION ); self::$seen = array(); } /** * A readable name for whatever was passed as the callback. * * @param callable $callback The callback. */ private static function name( callable $callback ): string { if ( is_string( $callback ) ) { return $callback; } if ( is_array( $callback ) && 2 === count( $callback ) ) { $class = is_object( $callback[0] ) ? get_class( $callback[0] ) : (string) $callback[0]; return $class . '::' . (string) $callback[1]; } if ( is_object( $callback ) ) { return get_class( $callback ); } return 'closure'; } }