diff --git a/load-application.php b/load-application.php index 4f290eff..74a88ded 100644 --- a/load-application.php +++ b/load-application.php @@ -2,7 +2,6 @@ use Symfony\Component\Console\Application; use Symfony\Component\Console\Input\InputOption; -use function Team51\Helper\is_quiet_mode; define( 'TEAM51_CLI_ROOT_DIR', __DIR__ ); if ( getenv( 'TEAM51_CONTRACTOR' ) ) { // Add the contractor flag automatically if set through the environment. @@ -31,8 +30,8 @@ $application->add( new Team51\Command\Pressable_Generate_OAuth_Token() ); $application->add( new Team51\Command\Pressable_Site_Add_Domain() ); $application->add( new Team51\Command\Pressable_Site_Create_Collaborator() ); +$application->add( new Team51\Command\Pressable_Site_List_PHP_Errors() ); $application->add( new Team51\Command\Pressable_Site_Open_Shell() ); -$application->add( new Team51\Command\Pressable_Site_PHP_Errors() ); $application->add( new Team51\Command\Pressable_Site_Rotate_Passwords() ); $application->add( new Team51\Command\Pressable_Site_Rotate_SFTP_User_Password() ); $application->add( new Team51\Command\Pressable_Site_Rotate_WP_User_Password() ); diff --git a/src/commands/pressable-site-list-php-errors.php b/src/commands/pressable-site-list-php-errors.php new file mode 100644 index 00000000..0fa372ec --- /dev/null +++ b/src/commands/pressable-site-list-php-errors.php @@ -0,0 +1,414 @@ +setDescription( 'Displays the most recent PHP errors for a given Pressable site.' ) + ->setHelp( 'This command allows you to figure out what is preventing a website from loading.' ); + + $this->addArgument( 'site', InputArgument::REQUIRED, 'ID or URL of the site to display the errors for.' ) + ->addOption( 'limit', null, InputOption::VALUE_REQUIRED, 'The number of distinct PHP fatal errors to return. Default is 5.', 5 ) + ->addOption( 'format', null, InputOption::VALUE_REQUIRED, 'The format to output the logs in. Accepts either "list", "table" or "raw". Default "list".', 'list' ) + ->addOption( 'severity', null, InputOption::VALUE_REQUIRED, 'The error severity to filter by. Valid values are "User", "Warning", "Deprecated", and "Fatal error". Default all.' ) + ->addOption( 'source', null, InputOption::VALUE_REQUIRED, 'Where to retrieve the PHP errors from. Accepts either "file", "api", or "auto". Default "auto".', 'auto' ); + } + + /** + * {@inheritDoc} + */ + protected function initialize( InputInterface $input, OutputInterface $output ): void { + maybe_define_console_verbosity( $output->getVerbosity() ); + + // Retrieve and validate the modifier options. + $this->limit = max( 1, (int) $input->getOption( 'limit' ) ); + $this->format = get_enum_input( $input, $output, 'format', array( 'list', 'table', 'raw' ) ); + $this->severity = get_enum_input( $input, $output, 'severity', array( 'User', 'Warning', 'Deprecated', 'Fatal error' ) ); + $this->source = get_enum_input( $input, $output, 'source', array( 'file', 'api', 'auto' ) ); + + // Retrieve and validate the site. + $this->pressable_site = get_pressable_site_from_input( $input, $output, fn() => $this->prompt_site_input( $input, $output ) ); + if ( \is_null( $this->pressable_site ) ) { + exit( 1 ); // Exit if the site does not exist. + } + + // Store the ID of the site in the argument field. + $input->setArgument( 'site', $this->pressable_site->id ); + } + + /** + * {@inheritDoc} + */ + protected function execute( InputInterface $input, OutputInterface $output ): int { + if ( 'raw' === $this->format ) { + $output->writeln( "Listing the raw PHP errors on {$this->pressable_site->displayName} (ID {$this->pressable_site->id}, URL {$this->pressable_site->url}) from $this->source." ); + } else { + $output->writeln( "Listing the last $this->limit distinct PHP errors on {$this->pressable_site->displayName} (ID {$this->pressable_site->id}, URL {$this->pressable_site->url}) from $this->source." ); + } + + $php_errors = $this->get_php_errors( $output ); + if ( \is_null( $php_errors ) ) { + $output->writeln( 'Could not retrieve the PHP errors.' ); + return 1; + } + if ( 0 === \count( $php_errors ) ) { + $output->writeln( 'The PHP error log appears to be empty. Go make some errors and try again!' ); + return 0; + } + + // Output the raw log if requested in said format. + if ( 'raw' === $this->format ) { + $this->output_raw_error_log( $php_errors, $output ); + return 0; + } + + // Analyze the log entries and output the results. + $stats_table = $this->analyze_log_entries( $php_errors ); + $stats_table = \array_slice( $stats_table, 0, $this->limit ); + + if ( 'table' === $this->format ) { + $this->output_table_error_log( $stats_table, $output ); + } elseif ( 'list' === $this->format ) { + $this->output_list_error_log( $stats_table, $output ); + } + + return 0; + } + + // endregion + + // region HELPERS + + /** + * Prompts the user for a site if in interactive mode. + * + * @param InputInterface $input The input object. + * @param OutputInterface $output The output object. + * + * @return string|null + */ + private function prompt_site_input( InputInterface $input, OutputInterface $output ): ?string { + if ( $input->isInteractive() ) { + $question = new Question( 'Enter the site ID or URL to display the errors for: ' ); + $question->setAutocompleterValues( \array_map( static fn( object $site ) => $site->url, get_pressable_sites() ?? array() ) ); + + $site = $this->getHelper( 'question' )->ask( $input, $output, $question ); + } + + return $site ?? null; + } + + /** + * Searches for the PHP error log file and returns its path. For example, if WP_DEBUG is turned on, the path will be + * /srv/htdocs/wp-content/debug.log. If WP_DEBUG is turned off, the path will be /tmp/php-errors. If there is an error + * monitoring plugin active on the site, the path can be something completely different. + * + * @param OutputInterface $output The output object. + * @param SSH2|null $ssh_connection The SSH connection object. + * + * @return string + */ + private function get_php_error_log_path( OutputInterface $output, ?SSH2 &$ssh_connection ): string { + $output->writeln( 'Finding the PHP error log location.', OutputInterface::VERBOSITY_VERBOSE ); + $return_path = self::DEFAULT_PHP_ERROR_LOG_PATH; + + $ssh_connection = Pressable_Connection_Helper::get_ssh_connection( $this->pressable_site->id ); + if ( ! \is_null( $ssh_connection ) ) { + $path_separator = \uniqid( 'team51', false ); // If the WP site has warnings or notices, this will help us separate the path from the rest of that messy output. + $error_log_path = $ssh_connection->exec( "wp eval 'echo \"$path_separator\" . ini_get(\"error_log\");'" ); + if ( ! empty( $error_log_path ) && false !== \strpos( $error_log_path, $path_separator ) ) { + $return_path = \explode( $path_separator, $error_log_path )[1]; + } else { + $output->writeln( 'Failed to find the PHP error log location. Using default PHP error log location.', OutputInterface::VERBOSITY_VERBOSE ); + } + } else { + $output->writeln( 'Could not connect to the site via SSH. Using default PHP error log location.', OutputInterface::VERBOSITY_VERBOSE ); + } + + return $return_path; + } + + /** + * Returns a standardized array of PHP errors either from the API or from the error log file. + * + * @param OutputInterface $output The output object. + * + * @return object[]|null + */ + private function get_php_errors( OutputInterface $output ): ?array { + $error_log_path = $this->get_php_error_log_path( $output, $ssh_connection ); + $output->writeln( "Using the PHP error log location: $error_log_path" ); + + if ( 'api' === $this->source || ( 'auto' === $this->source && self::DEFAULT_PHP_ERROR_LOG_PATH === $error_log_path ) ) { + $output->writeln( 'Retrieving the PHP error log contents via the API.', OutputInterface::VERBOSITY_VERY_VERBOSE ); + + $error_log = get_pressable_site_php_logs( $this->pressable_site->id, $this->severity, 2000 ); + if ( \is_null( $error_log ) ) { + $output->writeln( 'Failed to retrieve the PHP error log contents via the API. Aborting!', OutputInterface::VERBOSITY_VERBOSE ); + } + } else { + $output->writeln( 'Downloading the last 100k lines of the PHP error log.', OutputInterface::VERBOSITY_VERY_VERBOSE ); + + $error_log = $ssh_connection->exec( "tail -n 100000 $error_log_path" ); + if ( false === $error_log ) { + $output->writeln( 'Failed to download the PHP error log. Aborting!', OutputInterface::VERBOSITY_VERBOSE ); + $error_log = null; + } else { + $output->writeln( 'Parsing the error log file contents into something usable.', OutputInterface::VERBOSITY_VERY_VERBOSE ); + $error_log = $this->parse_error_log( $error_log, $output ); + } + } + + return $error_log; + } + + /** + * Parses a given error log string into its constituent error entries. + * + * @param string $error_log The raw string content of the error log. + * @param OutputInterface $output The output object. + * + * @return object[] + */ + private function parse_error_log( string $error_log, OutputInterface $output ): array { + $parsed_php_errors = array(); + + $php_errors = \explode( "\n", $error_log ); // Pressable sites run on Linux, so the separator is always \n. PHP_EOL could be \r\n on Windows. + foreach ( $php_errors as $php_error ) { + $php_error = \trim( $php_error ); + + // Ignore stack traces and other non-error log lines. + if ( empty( $php_error ) || '[' !== $php_error[0] ) { + continue; + } + + // Separate the error into its constituent parts. + $php_error = \explode( ']', $php_error, 2 ); + $php_error[0] = \substr( $php_error[0], 1 ); // Remove the leading [. + + $php_error_datetime = \strtotime( $php_error[0] ); + if ( $php_error_datetime + 7 * 24 * 60 * 60 < \time() ) { // If the error is more than a week old, ignore it. + continue; + } + + $php_error_datetime = \gmdate( 'c', $php_error_datetime ); // Make sure the date is in ISO 8601 format. + $php_error_message = \trim( $php_error[1] ); + + $php_error_severity = \explode( ':', $php_error_message, 2 )[0]; + $php_error_severity = \trim( \str_replace( 'PHP', '', $php_error_severity ) ); + if ( ! empty( $this->severity ) && $php_error_severity !== $this->severity ) { // If the error severity doesn't match the requested status, ignore it. + continue; + } + + \preg_match_all( '/.* in (.+)(?: on line |:)(\d+)/', $php_error_message, $php_error_file_and_line, PREG_SET_ORDER ); + if ( 0 !== \count( $php_error_file_and_line ) ) { + // Some error messages contain both formats ("on line" and ":") so we need to pick the last one. + $php_error_file_and_line = \end( $php_error_file_and_line ); + + $php_error_file = $php_error_file_and_line[1]; + $php_error_line = (int) $php_error_file_and_line[2]; + } else { + $output->writeln( "Failed to parse the PHP error file and line from the error message: $php_error_message", OutputInterface::VERBOSITY_DEBUG ); + $php_error_file = ''; + $php_error_line = 0; + } + + // Put everything together in the same format as the API. + $parsed_php_errors[] = (object) array( + 'message' => $php_error_message, + 'severity' => $php_error_severity, + 'kind' => '', // TODO + 'name' => '', // TODO + 'file' => $php_error_file, + 'line' => $php_error_line, + 'timestamp' => $php_error_datetime, + 'atomic_site_id' => '', // TODO + ); + } + + return \array_reverse( $parsed_php_errors ); + } + + /** + * Sorts the distinct error log entries by when they last happened. + * + * @param object[] $php_errors The error log entries as parsed by the @parse_error_log method or as returned by the API. + * + * @return object[] + */ + private function analyze_log_entries( array $php_errors ): array { + $stats_table = array(); + + // Count each distinct error and keep track of its most recent occurrence. + foreach ( $php_errors as $php_error ) { + $error_hash = \hash( 'md5', $php_error->message ); + if ( isset( $stats_table[ $error_hash ] ) ) { + ++$stats_table[ $error_hash ]->count; + + if ( \strtotime( $php_error->timestamp ) > \strtotime( $stats_table[ $error_hash ]->timestamp ) ) { + $stats_table[ $error_hash ]->timestamp = $php_error->timestamp; + } + } else { + $stats_table[ $error_hash ] = (object) array( + 'message' => $php_error->message, + 'severity' => $php_error->severity, + 'timestamp' => $php_error->timestamp, + 'count' => 1, + ); + } + } + + // Sort fatal errors by timestamp. + \usort( + $stats_table, + static fn ( object $a, object $b ) => \strtotime( $b->timestamp ) <=> \strtotime( $a->timestamp ) + ); + + return $stats_table; + } + + /** + * Outputs the raw error log to the console. + * + * @param object[] $error_log The error log as received by the API or as parsed by the @parse_error_log method. + * @param OutputInterface $output The output object. + * + * @return void + */ + private function output_raw_error_log( array $error_log, OutputInterface $output ): void { + \passthru( 'clear' ); + $output->write( \print_r( $error_log, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions + } + + /** + * Outputs the error log as a formatted table. + * + * @param object[] $stats_table The sorted log entries table. + * @param OutputInterface $output The output object. + * + * @return void + */ + private function output_table_error_log( array $stats_table, OutputInterface $output ): void { + $table = new Table( $output ); + + $table->setHeaderTitle( "The $this->limit most recent PHP Errors" ); + $table->setHeaders( array( '' ) ); + + foreach ( $stats_table as $key => $table_row ) { + $table->addRow( array( new TableCell( "Timestamp: $table_row->timestamp" ) ) ); + $table->addRow( array( new TableCell( "Severity: $table_row->severity" ) ) ); + $table->addRow( array( new TableCell( "Count: $table_row->count" ) ) ); + $table->addRow( array( new TableCell( "$table_row->message" ) ) ); + + if ( \array_key_last( $stats_table ) !== $key ) { + $table->addRow( new TableSeparator() ); + } + } + + $table->setColumnMaxWidth( 0, 128 ); + $table->setStyle( 'box-double' ); + $table->render(); + } + + /** + * Outputs the error log entry by entry. + * + * @param object[] $stats_table The sorted log entries table. + * @param OutputInterface $output The output object. + * + * @return void + */ + private function output_list_error_log( array $stats_table, OutputInterface $output ): void { + $output->writeln( '' ); + $output->writeln( "-- The $this->limit most recent PHP Errors --" ); + $output->writeln( '' ); + + foreach ( $stats_table as $table_row ) { + $output->writeln( "Timestamp: $table_row->timestamp" ); + $output->writeln( "Severity: $table_row->severity" ); + $output->writeln( "Count: $table_row->count" ); + $output->writeln( "$table_row->message" ); + + /* @noinspection DisconnectedForeachInstructionInspection */ + $output->writeln( '' ); + } + } + + // endregion +} diff --git a/src/commands/pressable-site-php-errors.php b/src/commands/pressable-site-php-errors.php deleted file mode 100644 index 74c6dfb6..00000000 --- a/src/commands/pressable-site-php-errors.php +++ /dev/null @@ -1,333 +0,0 @@ -setDescription( 'Pulls the 3 most recent distinct fatal errors from the site\'s PHP error log.' ) - ->setHelp( 'Ex: team51 php-errors asia.si.edu --format raw --limit 10' ); - - $this->addArgument( 'site', InputArgument::REQUIRED, 'ID or URL of the site to retrieve the error log from.' ) - ->addOption( 'format', null, InputOption::VALUE_REQUIRED, 'The alternative format to output the logs in. Accepts either "table" or "raw".' ) - ->addOption( 'limit', null, InputOption::VALUE_REQUIRED, 'The number of distinct PHP fatal errors to return. Default is 3.', 3 ) - ->addOption( 'lines', null, InputOption::VALUE_REQUIRED, 'The number of PHP error lines to retrieve. Default is 100k.', 100000 ); - } - - /** - * {@inheritDoc} - */ - protected function initialize( InputInterface $input, OutputInterface $output ): void { - maybe_define_console_verbosity( $output->getVerbosity() ); - - // Retrieve and validate the modifier options. - $this->format = get_enum_input( $input, $output, 'format', array( 'raw', 'table' ) ); - $this->limit = max( 1, (int) $input->getOption( 'limit' ) ); - $this->lines = max( $this->limit, (int) $input->getOption( 'lines' ) ); - - // Retrieve and validate the site. - $this->pressable_site = get_pressable_site_from_input( $input, $output, fn() => $this->prompt_site_input( $input, $output ) ); - if ( \is_null( $this->pressable_site ) ) { - exit( 1 ); // Exit if the site does not exist. - } - - // Store the ID of the site in the argument field. - $input->setArgument( 'site', $this->pressable_site->id ); - } - - /** - * {@inheritDoc} - */ - protected function execute( InputInterface $input, OutputInterface $output ): int { - if ( 'raw' === $this->format ) { - $output->writeln( "Retrieving the raw PHP errors log for {$this->pressable_site->displayName} (ID {$this->pressable_site->id}, URL {$this->pressable_site->url})." ); - } else { - $output->writeln( "Retrieving the last $this->limit distinct PHP fatal errors for {$this->pressable_site->displayName} (ID {$this->pressable_site->id}, URL {$this->pressable_site->url})." ); - } - - $ssh_connection = Pressable_Connection_Helper::get_ssh_connection( $this->pressable_site->id ); - if ( \is_null( $ssh_connection ) ) { - $output->writeln( "Failed to connect via SSH for {$this->pressable_site->url}. Aborting!" ); - return 1; - } - - $output->writeln( 'Finding the PHP error log location.', OutputInterface::VERBOSITY_VERBOSE ); - - // The default Pressable PHP error log location is /tmp/php-errors but this can be changed by plugins or even WP Core itself when debugging is active. - $error_log_path = $ssh_connection->exec( 'wp eval "echo ini_get(\'error_log\');"' ); - if ( empty( $error_log_path ) ) { - $output->writeln( "Failed to find the PHP error log location for {$this->pressable_site->url}. Aborting!" ); - return 1; - } - - $output->writeln( "Found the PHP error log location: $error_log_path", OutputInterface::VERBOSITY_DEBUG ); - - // Retrieve the error log file. - $output->writeln( "Downloading the last $this->lines lines of the PHP error log.", OutputInterface::VERBOSITY_VERBOSE ); - - $error_log = $ssh_connection->exec( "tail -n $this->lines $error_log_path" ); - if ( empty( $error_log ) ) { - if ( false === $error_log ) { - $output->writeln( "Failed to download the PHP error log for {$this->pressable_site->url}. Aborting!" ); - } else { - $output->writeln( "The PHP error log for {$this->pressable_site->url} appears to be empty. Go make some errors and try again!" ); - } - - return 1; - } - - // Output the raw log if requested in said format. - if ( 'raw' === $this->format ) { - $this->output_raw_error_log( $error_log, $output ); - return 0; - } - - // Parse the error log for the most recent fatal errors. - $output->writeln( 'Parsing the error log into something usable.', OutputInterface::VERBOSITY_VERBOSE ); - - $php_errors = $this->parse_error_log( $error_log ); - if ( 0 === count( $php_errors ) ) { - $output->writeln( "The PHP error log for {$this->pressable_site->url} appears to be empty. Go make some errors and try again!" ); - return 0; - } - - $stats_table = $this->analyze_error_entries( $php_errors ); - $stats_table = \array_slice( $stats_table, 0, $this->limit ); - - // Output the log based on requested format. - switch ( $this->format ) { - case 'table': - $this->output_table_error_log( $stats_table, $output ); - break; - default: - $this->output_default_error_log( $stats_table, $output ); - break; - } - - return 0; - } - - // endregion - - // region HELPERS - - /** - * Prompts the user for a site if in interactive mode. - * - * @param InputInterface $input The input object. - * @param OutputInterface $output The output object. - * - * @return string|null - */ - private function prompt_site_input( InputInterface $input, OutputInterface $output ): ?string { - if ( $input->isInteractive() ) { - $question = new Question( 'Enter the site ID or URL to retrieve the error log for: ' ); - $question->setAutocompleterValues( \array_map( static fn( object $site ) => $site->url, get_pressable_sites() ?? array() ) ); - - $site = $this->getHelper( 'question' )->ask( $input, $output, $question ); - } - - return $site ?? null; - } - - /** - * Parses a given error log string into its constituent error entries. - * - * @param string $error_log The raw string content of the error log. - * - * @return array[] - */ - private function parse_error_log( string $error_log ): array { - $parsed_php_errors = array(); - - $php_errors = \explode( "\n", $error_log ); // Pressable sites run on Linux, so the separator is always \n. PHP_EOL could be \r\n on Windows. - foreach ( $php_errors as $php_error ) { - // Ignore non-fatal entries. - if ( false === \stripos( $php_error, 'php fatal' ) ) { - continue; - } - - // Extract individual components of the error entry. - \preg_match( '/\[(.*)].*(PHP .*?):(.*)/', $php_error, $matches ); - $matches = \array_map( 'trim', $matches ); - - if ( empty( $matches[1] ) || empty( $matches[2] ) || empty( $matches[3] ) ) { - continue; - } - - $parsed_php_errors[] = array( - 'timestamp' => $matches[1], - 'error_level' => $matches[2], - 'error_message' => $matches[3], - ); - } - - return $parsed_php_errors; - } - - /** - * Sorts the distinct error log entries by when they last happened. - * - * @param array $parsed_php_errors The error log entries as parsed by the @parse_error_log method. - * - * @return array - */ - private function analyze_error_entries( array $parsed_php_errors ): array { - $stats_table = array(); - - // Count each distinct error and keep track of its most recent occurrence. - foreach ( $parsed_php_errors as $parsed_php_error ) { - $error_hash = \hash( 'md5', $parsed_php_error['error_message'] ); - if ( isset( $stats_table[ $error_hash ] ) ) { - $stats_table[ $error_hash ]['error_count']++; - - if ( \strtotime( $parsed_php_error['timestamp'] ) > \strtotime( $stats_table[ $error_hash ]['timestamp'] ) ) { - $stats_table[ $error_hash ]['timestamp'] = $parsed_php_error['timestamp']; - } - } else { - $stats_table[ $error_hash ] = array( - 'timestamp' => $parsed_php_error['timestamp'], - 'error_level' => $parsed_php_error['error_level'], - 'error_message' => $parsed_php_error['error_message'], - 'error_count' => 1, - ); - } - } - - // Sort fatal errors by timestamp. - \usort( - $stats_table, - static fn ( $a, $b ) => \strtotime( $b['timestamp'] ) <=> \strtotime( $a['timestamp'] ) - ); - - return $stats_table; - } - - /** - * Outputs the raw error log to the console. - * - * @param string $error_log The error log as downloaded via SFTP. - * @param OutputInterface $output The output object. - * - * @return void - */ - private function output_raw_error_log( string $error_log, OutputInterface $output ): void { - \passthru( 'clear' ); - $output->write( $error_log ); - } - - /** - * Outputs the error log as a formatted table. - * - * @param array $stats_table The sorted log entries table. - * @param OutputInterface $output The output object. - * - * @return void - */ - private function output_table_error_log( array $stats_table, OutputInterface $output ): void { - $table = new Table( $output ); - - $table->setHeaderTitle( 'The 3 most recent PHP Fatal Errors' ); - $table->setHeaders( array( '' ) ); - - foreach ( $stats_table as $key => $table_row ) { - $table->addRow( array( new TableCell( "Timestamp: {$table_row['timestamp']}" ) ) ); - $table->addRow( array( new TableCell( "Error Level: {$table_row['error_level']}" ) ) ); - $table->addRow( array( new TableCell( "Error Count: {$table_row['error_count']}" ) ) ); - $table->addRow( array( new TableCell( "{$table_row['error_message']}" ) ) ); - - if ( \array_key_last( $stats_table ) !== $key ) { - $table->addRow( new TableSeparator() ); - } - } - - $table->setColumnMaxWidth( 0, 128 ); - $table->setStyle( 'box-double' ); - $table->render(); - } - - /** - * Outputs the error log entry by entry. - * - * @param array $stats_table The sorted log entries table. - * @param OutputInterface $output The output object. - * - * @return void - */ - private function output_default_error_log( array $stats_table, OutputInterface $output ): void { - $output->writeln( '' ); - $output->writeln( '-- The 3 most recent PHP Fatal Errors --' ); - $output->writeln( '' ); - - foreach ( $stats_table as $table_row ) { - $output->writeln( "Timestamp: {$table_row['timestamp']}" ); - $output->writeln( "Error Level: {$table_row['error_level']}" ); - $output->writeln( "Error Count: {$table_row['error_count']}" ); - $output->writeln( "{$table_row['error_message']}" ); - - /* @noinspection DisconnectedForeachInstructionInspection */ - $output->writeln( '' ); - } - } - - // endregion -} diff --git a/src/helpers/pressable-functions.php b/src/helpers/pressable-functions.php index 3641b971..73872e7e 100644 --- a/src/helpers/pressable-functions.php +++ b/src/helpers/pressable-functions.php @@ -535,6 +535,42 @@ function set_pressable_site_primary_domain( string $site_id, string $domain_id ) return $result->data; } +/** + * Get a list of a site's PHP error logs. The logs are available for the past 7 days. + * + * @param string $site_id The site ID. + * @param string|null $status Filter by log status. Valid values are 'User', 'Warning', 'Deprecated', and 'Fatal error'. + * @param int $max_entries The maximum number of entries to return. The default is 200 or one page. + * + * @link https://my.pressable.com/documentation/api/v1#get-php-logs + * + * @return object[]|null + */ +function get_pressable_site_php_logs( string $site_id, ?string $status = null, int $max_entries = 200 ): ?array { + $logs = array(); + + do { + $page = Pressable_API_Helper::call_api( + "sites/$site_id/logs/php", + 'GET', + array_filter( + array( + 'scroll_id' => $page->scroll_id ?? null, + 'status' => $status, + ) + ) + ); + if ( is_null( $page ) ) { + return null; + } + + $max_entries -= 200; // There are 200 entries per page. + $logs[] = $page->logs; + } while ( ! is_null( $page->scroll_id ) && $max_entries > 0 ); + + return array_merge( ...$logs ); +} + // endregion // region WRAPPERS @@ -581,7 +617,7 @@ function run_pressable_site_wp_cli_command( Application $application, string $si */ function output_related_pressable_sites( OutputInterface $output, array $sites, ?array $headers = null, ?callable $row_generator = null ): void { $row_generator = \is_callable( $row_generator ) ? $row_generator - : static fn( $node, $level ) => array( $node->id, $node->name, $node->url, $level, $node->clonedFromId ?: '--' ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + : static fn( $node, $level ) => array( $node->id, $node->name, $node->url, $level, $node->clonedFromId ?: '--' ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase, Universal.Operators.DisallowShortTernary.Found $table = new Table( $output );