Files
WooList/woolist-phplist/includes/class-woolist-logger.php
Malin f4c9e39493 feat: add structured file-based logging with admin log viewer
- New WooList_Logger class writes to wp-content/uploads/woolist-logs/woolist.log
  - INFO level: subscription events, test connection results (always recorded)
  - ERROR level: API failures, config problems (always recorded + php error_log fallback)
  - DEBUG level: full request URLs (password redacted), raw responses, step-by-step
    flow (only when "Enable debug logging" is checked in settings)
  - Auto-rotates at 1 MB; log directory protected by .htaccess
- API class: logs every request URL (redacted) and raw response body at DEBUG,
  errors at ERROR; subscribe_email_to_list logs each step (lookup/create/add)
- Hooks class: logs hook fire, skip reasons, and sync intent at DEBUG/INFO/ERROR
- Shortcode class: logs AJAX submissions, coupon generation, and failures
- Admin: new Logging section with "Enable debug logging" checkbox;
  log viewer textarea (last 300 lines, dark theme) + Clear Log button
  both visible at bottom of WooCommerce → Settings → phpList tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:25:33 +01:00

142 lines
4.5 KiB
PHP

<?php
/**
* Simple file-based logger for WooList.
*
* Writes to wp-content/uploads/woolist-logs/woolist.log.
* The directory is protected from direct HTTP access via .htaccess.
*
* Levels:
* INFO — always written (subscription events, connection test results)
* ERROR — always written (API failures, config problems)
* DEBUG — written only when "Enable debug logging" is on (full request/response)
*
* @package WooList
*/
defined( 'ABSPATH' ) || exit;
class WooList_Logger {
/** Absolute path to the log file. */
private static string $log_file = '';
/** Whether verbose debug lines are recorded. */
private static bool $debug_enabled = false;
/** Maximum log file size in bytes before rotation (1 MB). */
private const MAX_SIZE = 1048576;
/**
* Initialise the logger: create the log directory and read the debug setting.
* Must be called after WordPress options are available.
*/
public static function init(): void {
$upload = wp_upload_dir();
$log_dir = $upload['basedir'] . '/woolist-logs';
if ( ! is_dir( $log_dir ) ) {
wp_mkdir_p( $log_dir );
}
// Block direct HTTP access and directory listing.
$htaccess = $log_dir . '/.htaccess';
if ( ! file_exists( $htaccess ) ) {
file_put_contents( $htaccess, "Require all denied\ndeny from all\n" );
}
$index = $log_dir . '/index.php';
if ( ! file_exists( $index ) ) {
file_put_contents( $index, "<?php // Silence is golden.\n" );
}
self::$log_file = $log_dir . '/woolist.log';
self::$debug_enabled = ( get_option( 'woolist_enable_debug_log' ) === 'yes' );
}
// ── Public logging methods ───────────────────────────────────────────────
/** Always logged. Use for normal subscription / connection events. */
public static function info( string $message ): void {
self::write( 'INFO', $message );
}
/** Always logged. Also echoes to php error_log as a fallback. */
public static function error( string $message ): void {
self::write( 'ERROR', $message );
error_log( '[WooList ERROR] ' . $message );
}
/**
* Logged only when debug mode is enabled.
* Use for full request URLs, raw response bodies, step-by-step flow.
*/
public static function debug( string $message ): void {
if ( self::$debug_enabled ) {
self::write( 'DEBUG', $message );
}
}
// ── Utility ─────────────────────────────────────────────────────────────
/**
* Strip the password parameter from a phpList URL before logging it.
*
* @param string $url Full API URL.
* @return string URL with password value replaced by ***.
*/
public static function redact_url( string $url ): string {
return preg_replace( '/(\bpassword=)[^&]+/', '$1***', $url );
}
/**
* Return the last $lines lines of the log as a string.
*
* @param int $lines Number of lines to return.
* @return string Log tail, or an empty string when the log doesn't exist yet.
*/
public static function read_recent( int $lines = 300 ): string {
if ( empty( self::$log_file ) || ! file_exists( self::$log_file ) ) {
return '';
}
$content = file_get_contents( self::$log_file );
if ( $content === false || $content === '' ) {
return '';
}
$all = explode( "\n", rtrim( $content ) );
$recent = array_slice( $all, -$lines );
return implode( "\n", $recent );
}
/** Truncate the log file without deleting it. */
public static function clear(): void {
if ( ! empty( self::$log_file ) ) {
file_put_contents( self::$log_file, '' );
}
}
public static function get_log_path(): string {
return self::$log_file;
}
public static function is_debug_enabled(): bool {
return self::$debug_enabled;
}
// ── Internal ─────────────────────────────────────────────────────────────
private static function write( string $level, string $message ): void {
if ( empty( self::$log_file ) ) {
return;
}
// Rotate if the file exceeds MAX_SIZE.
if ( file_exists( self::$log_file ) && filesize( self::$log_file ) >= self::MAX_SIZE ) {
rename( self::$log_file, self::$log_file . '.old' );
}
$line = '[' . gmdate( 'Y-m-d H:i:s' ) . ' UTC] [' . $level . '] ' . $message . "\n";
file_put_contents( self::$log_file, $line, FILE_APPEND | LOCK_EX );
}
}