self::LEVEL_ERROR, 'warning' => self::LEVEL_WARNING, 'info' => self::LEVEL_INFO, 'debug' => self::LEVEL_DEBUG, ]; private const LEVEL_LABELS = [ self::LEVEL_ERROR => 'error', self::LEVEL_WARNING => 'warning', self::LEVEL_INFO => 'info', self::LEVEL_DEBUG => 'debug', ]; /** Singleton instance. */ private static ?self $instance = null; /** Cached numeric threshold from settings. */ private int $threshold; private function __construct() { $setting = get_option( 'autoblogger_log_level', 'info' ); $this->threshold = self::LEVEL_MAP[ $setting ] ?? self::LEVEL_INFO; } /** Get the singleton logger instance. */ public static function instance(): self { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Log an error (always captured unless logging is completely off). * * @param string $message Human-readable description. * @param string $context Origin class or pipeline stage. * @param array $meta Optional structured data. */ public function error( string $message, string $context = '', array $meta = [] ): void { $this->log( self::LEVEL_ERROR, $message, $context, $meta ); } /** * Log a warning (budget approaching, retries, degraded behavior). * * @param string $message * @param string $context * @param array $meta */ public function warning( string $message, string $context = '', array $meta = [] ): void { $this->log( self::LEVEL_WARNING, $message, $context, $meta ); } /** * Log an informational event (pipeline start/stop, articles generated). * * @param string $message * @param string $context * @param array $meta */ public function info( string $message, string $context = '', array $meta = [] ): void { $this->log( self::LEVEL_INFO, $message, $context, $meta ); } /** * Log debug detail (API token counts, timing, intermediate values). * * @param string $message * @param string $context * @param array $meta */ public function debug( string $message, string $context = '', array $meta = [] ): void { $this->log( self::LEVEL_DEBUG, $message, $context, $meta ); } /** * Core logging method. Writes to DB if level meets threshold. * * Side effects: database INSERT, optionally PHP error_log(). * * @param int $level Numeric level constant. * @param string $message Human-readable message. * @param string $context Origin (class/stage). * @param array $meta Structured data. */ private function log( int $level, string $message, string $context, array $meta ): void { if ( $level > $this->threshold ) { return; } $label = self::LEVEL_LABELS[ $level ] ?? 'info'; // Always forward errors to PHP error_log for server-level monitoring. if ( $level <= self::LEVEL_WARNING ) { error_log( sprintf( '[AutoBlogger][%s] %s — %s', strtoupper( $label ), $context, $message ) ); } global $wpdb; $table = $wpdb->prefix . 'autoblogger_event_log'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->insert( $table, [ 'level' => $label, 'context' => substr( $context, 0, 100 ), 'message' => substr( $message, 0, 2000 ), 'meta_json' => ! empty( $meta ) ? wp_json_encode( $meta ) : null, 'created_at' => current_time( 'mysql' ), ] ); } /** * Query log entries for the admin viewer. * * @param string $level_filter Filter by level: 'all', 'error', 'warning', 'info', 'debug'. * @param string $search Search within message/context. * @param int $page Page number (1-based). * @param int $per_page Results per page. * * @return array{rows: array, total: int} */ public static function query( string $level_filter = 'all', string $search = '', int $page = 1, int $per_page = 50 ): array { global $wpdb; $table = $wpdb->prefix . 'autoblogger_event_log'; $where = []; $params = []; if ( 'all' !== $level_filter && isset( self::LEVEL_MAP[ $level_filter ] ) ) { $where[] = 'level = %s'; $params[] = $level_filter; } if ( '' !== $search ) { $like = '%' . $wpdb->esc_like( $search ) . '%'; $where[] = '(message LIKE %s OR context LIKE %s)'; $params[] = $like; $params[] = $like; } $where_sql = ! empty( $where ) ? 'WHERE ' . implode( ' AND ', $where ) : ''; $offset = ( $page - 1 ) * $per_page; // Total count. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $total = (int) $wpdb->get_var( empty( $params ) ? "SELECT COUNT(*) FROM {$table} {$where_sql}" : $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params ) ); // Rows. $query_params = $params; $query_params[] = $per_page; $query_params[] = $offset; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d", ...$query_params ), ARRAY_A ); return [ 'rows' => $rows ?: [], 'total' => $total ]; } /** * Prune log entries older than a given number of days. * * @param int $days Keep entries from the last N days. * @return int Number of rows deleted. */ public static function prune( int $days = 30 ): int { global $wpdb; $table = $wpdb->prefix . 'autoblogger_event_log'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery return (int) $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE created_at < %s", gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) ) ) ); } }