migration_id = $migration_id; } /** * Returns a copy that stamps the given trace onto every entry, so retry * warnings raised deep in the transport helper carry it without every call * site passing it down. A copy, so a shared logger does not relabel unrelated stages. */ public function with_trace( TransferTrace $trace ): self { $logger = clone $this; $logger->trace = $trace; return $logger; } /** * Logs a DEBUG message. Debug entries are the chatty per-chunk detail and are * only written when diagnostics are enabled; info and up always write. * * @param array $context Additional context data. */ public function debug( string $message, array $context = [] ): void { if ( ! ShiftoraDebug::enabled() ) { return; } if ( $this->debug_cap_reached() ) { return; } ++$this->debug_rows; $this->write( LogLevel::Debug, $message, $context ); } /** Whether this migration has written as much debug detail as allowed. The count is read once per logger, so it costs one query per request, not per chunk. */ private function debug_cap_reached(): bool { if ( null === $this->debug_rows ) { $this->debug_rows = $this->count_debug_rows(); } if ( $this->debug_rows < self::MAX_DEBUG_ROWS ) { return false; } if ( ! $this->cap_reported ) { $this->cap_reported = true; // Logged so a capped log is not mistaken for a transfer that went quiet. $this->write( LogLevel::Warning, sprintf( 'Detailed logging stopped for this migration: it reached the limit of %d debug entries. Failures are still recorded.', self::MAX_DEBUG_ROWS ), [ 'debug_rows' => self::MAX_DEBUG_ROWS ] ); } return true; } /** How many debug rows this migration already holds. */ private function count_debug_rows(): int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $count = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE migration_id = %d AND level = %s', $wpdb->prefix . 'shiftora_logs', $this->migration_id, LogLevel::Debug->value ) ); // An unreadable count is treated as empty; assuming "full" would silence logging on a site whose database is struggling. return is_numeric( $count ) ? (int) $count : 0; } /** Logs an INFO message. */ public function info( string $message, array $context = [] ): void { $this->write( LogLevel::Info, $message, $context ); } /** Logs a WARNING message. */ public function warning( string $message, array $context = [] ): void { $this->write( LogLevel::Warning, $message, $context ); } /** Logs an ERROR message. */ public function error( string $message, array $context = [] ): void { $this->write( LogLevel::Error, $message, $context ); } /** Logs a CRITICAL message. */ public function critical( string $message, array $context = [] ): void { $this->write( LogLevel::Critical, $message, $context ); } /** * Retrieves log entries for the current migration, newest first. * * @param LogLevel|null $min_level Minimum level to include; null = all. * @return array */ public function get_entries( int $limit = 200, ?LogLevel $min_level = null ): array { global $wpdb; if ( null !== $min_level ) { $levels = array_filter( LogLevel::cases(), fn( LogLevel $l ) => $l->severity() >= $min_level->severity() ); $level_values = array_map( fn( LogLevel $l ) => $l->value, $levels ); $values = array_merge( [ $wpdb->prefix . 'shiftora_logs', $this->migration_id ], $level_values, [ $limit ] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required return $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE migration_id = %d AND level IN (' . implode( ', ', array_fill( 0, count( $level_values ), '%s' ) ) . ') ORDER BY id DESC LIMIT %d', $values ) ) ?: []; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required return $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE migration_id = %d ORDER BY id DESC LIMIT %d', $wpdb->prefix . 'shiftora_logs', $this->migration_id, $limit ) ) ?: []; } /** * Writes a single log entry. Message and context are clipped on the way in: * both can come from remote responses, so without a ceiling a verbose error * is an unbounded write. */ private function write( LogLevel $level, string $message, array $context ): void { global $wpdb; if ( null !== $this->trace ) { // The trace wins on conflicts: helpers pass their own idea of the stage ("chunk", a route, a file key), which would make the column mean different things per row. $context = array_merge( $context, $this->trace->context() ); } $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $wpdb->prefix . 'shiftora_logs', [ 'migration_id' => $this->migration_id, 'level' => $level->value, 'message' => TransferTrace::message( $message ), 'context' => ! empty( $context ) ? TransferTrace::encode( $context ) : null, 'created_at' => current_time( 'mysql', true ), ], [ '%d', '%s', '%s', '%s', '%s' ] ); } /** * Prunes log entries older than $days days, up to 1000 rows per pass and 5 * passes, so a huge backlog cannot hold the cron event. Run daily by shiftora_prune_logs. */ public static function prune( int $days = 30 ): void { global $wpdb; for ( $pass = 0; $pass < 5; $pass++ ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $deleted = $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE created_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY) LIMIT 1000', $wpdb->prefix . 'shiftora_logs', $days ) ); if ( ! is_int( $deleted ) || $deleted < 1000 ) { return; } } } }