prefix . 'woocommerce_log'; } /** * WooCommerce's retention in days, or null when it can't be read. * * @since 1.0.0 * @return int|null */ public static function retention_days(): ?int { $util = 'Automattic\WooCommerce\Utilities\LoggingUtil'; if ( ! class_exists( $util ) || ! is_callable( array( $util, 'get_retention_period' ) ) ) { return null; } try { return max( 1, (int) call_user_func( array( $util, 'get_retention_period' ) ) ); } catch ( \Throwable $e ) { unset( $e ); return null; } } /** * Whether WooCommerce writes logs to the database. * * @since 1.0.0 * @return bool */ public static function uses_db(): bool { $util = 'Automattic\WooCommerce\Utilities\LoggingUtil'; if ( ! class_exists( $util ) || ! is_callable( array( $util, 'get_default_handler' ) ) ) { return false; } try { $handler = (string) call_user_func( array( $util, 'get_default_handler' ) ); } catch ( \Throwable $e ) { unset( $e ); return false; } return '' !== $handler && is_a( $handler, 'WC_Log_Handler_DB', true ); } /** * {@inheritDoc} Needs WooCommerce's logger with clear_expired_logs(); the table is only * needed when logs go to the database. */ public function unavailable(): string { if ( is_callable( $this->args['unavailable'] ) ) { $reason = (string) call_user_func( $this->args['unavailable'] ); if ( '' !== $reason ) { return $reason; } } if ( ! function_exists( 'wc_get_logger' ) || ! is_callable( array( wc_get_logger(), 'clear_expired_logs' ) ) || null === self::retention_days() ) { return __( 'This WooCommerce version can’t clear old logs this way. Use WooCommerce → Status → Logs.', 'admin-powerups-for-woocommerce' ); } if ( self::uses_db() && ! self::table_exists( self::table() ) ) { return __( 'Its database table doesn’t exist on this site.', 'admin-powerups-for-woocommerce' ); } return ''; } /** * Cutoff as a MySQL date (UTC, as WooCommerce stores it). * * @param array $state State with a fixed `cutoff`, if any. * @return string */ private static function cutoff( array $state = array() ): string { if ( isset( $state['cutoff'] ) && is_string( $state['cutoff'] ) ) { return $state['cutoff']; } return gmdate( 'Y-m-d H:i:s', time() - (int) self::retention_days() * DAY_IN_SECONDS ); } /** * {@inheritDoc} -1 (not counted) when logs are files. For the database handler: walks the * primary key in ranges (as the orphan checks do), counting old rows in each, so it stays * bounded per step and counts old logs wherever their IDs are. * * @param array $state State from the last call. * @param float $budget Seconds to spend. * @return array{count: int, done: bool, state: array} */ public function count( array $state, float $budget ): array { global $wpdb; if ( ! self::uses_db() ) { return array( 'count' => -1, 'done' => true, 'state' => array(), ); } $table = self::table(); if ( ! isset( $state['cursor'], $state['max'], $state['cutoff'] ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- live bounds of the range walk. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT MIN(log_id) AS lo, MAX(log_id) AS hi FROM %i', $table ) ); $state = array( 'cursor' => is_object( $row ) && null !== $row->lo ? (int) $row->lo - 1 : 0, 'max' => is_object( $row ) && null !== $row->hi ? (int) $row->hi : 0, 'cutoff' => self::cutoff(), 'count' => 0, ); } $start = microtime( true ); $chunk = OrphanRowsCheck::chunk(); while ( (int) $state['cursor'] < (int) $state['max'] ) { $upper = min( (int) $state['cursor'] + $chunk, (int) $state['max'] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- a primary-key range with the age filter. $state['count'] = (int) $state['count'] + (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE log_id > %d AND log_id <= %d AND timestamp < %s', $table, (int) $state['cursor'], $upper, (string) $state['cutoff'] ) ); $state['cursor'] = $upper; if ( $upper < (int) $state['max'] ) { // Jump over gaps in the IDs (one index lookup). // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one primary-key lookup. $next = $wpdb->get_var( $wpdb->prepare( 'SELECT MIN(log_id) FROM %i WHERE log_id > %d', $table, $upper ) ); $state['cursor'] = null === $next ? (int) $state['max'] : max( $upper, (int) $next - 1 ); } if ( ! self::time_left( $start, $budget ) ) { break; } } $done = (int) $state['cursor'] >= (int) $state['max']; return array( 'count' => (int) $state['count'], 'done' => $done, 'state' => $done ? array() : $state, ); } /** * {@inheritDoc} Database handler: bounded deletes in primary-key order * (`timestamp < cutoff ORDER BY log_id LIMIT n`) within the budget, with WooCommerce's * retention, then WooCommerce's logs cache version is bumped as it does itself. Log files: * WooCommerce's own routine (WC_Logger::clear_expired_logs()). * * @param array $state State from the last call. * @param int $batch Rows per statement. * @param float $budget Seconds to spend. * @return array{deleted: int, skipped: int, notes: array, done: bool, state: array} */ public function clean( array $state, int $batch, float $budget ): array { global $wpdb; if ( ! self::uses_db() ) { $out = self::cleaned( $state, true ); $logger = wc_get_logger(); if ( is_callable( array( $logger, 'clear_expired_logs' ) ) ) { call_user_func( array( $logger, 'clear_expired_logs' ) ); } $out['notes']['files'] = 1; return $out; } $state = array( 'cutoff' => self::cutoff( $state ) ); $out = self::cleaned( $state, false ); $start = microtime( true ); do { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bounded delete in primary-key order; the age condition is part of the DELETE; the logs cache version is bumped below. $gone = (int) $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE timestamp < %s ORDER BY log_id LIMIT %d', self::table(), $state['cutoff'], $batch ) ); $out['deleted'] += max( 0, $gone ); if ( $gone < $batch || '' !== $wpdb->last_error ) { $out['done'] = '' === $wpdb->last_error; break; } } while ( self::time_left( $start, $budget ) ); if ( $out['deleted'] && class_exists( '\WC_Cache_Helper' ) ) { \WC_Cache_Helper::get_transient_version( 'logs-db', true ); } return $out; } /** * {@inheritDoc} Date, level and source; never the message (it can hold anything). * * @param int $limit Rows (at most 10). * @return array{columns: array, rows: array>} */ public function sample( int $limit = 10 ): array { global $wpdb; $rows = array(); if ( self::uses_db() ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- a few rows for the sample. foreach ( (array) $wpdb->get_results( $wpdb->prepare( 'SELECT log_id, timestamp, level, source FROM %i WHERE timestamp < %s LIMIT %d', self::table(), self::cutoff(), max( 1, min( 10, $limit ) ) ) ) as $row ) { $rows[] = array( 'id' => self::cell( $row->log_id ), 'date' => self::date_cell( (int) strtotime( $row->timestamp . ' UTC' ) ), 'level' => self::cell( class_exists( '\WC_Log_Levels' ) ? \WC_Log_Levels::get_severity_level( (int) $row->level ) : $row->level ), 'source' => self::cell( $row->source ), ); } } return array( 'columns' => array( 'id' => __( 'Log ID', 'admin-powerups-for-woocommerce' ), 'date' => __( 'Date', 'admin-powerups-for-woocommerce' ), 'level' => __( 'Level', 'admin-powerups-for-woocommerce' ), 'source' => __( 'Source', 'admin-powerups-for-woocommerce' ), ), 'rows' => $rows, ); } }