table_name = $wpdb->prefix . 'dsz_sync_logs'; } /** * Log a message * * @param string $level Log level * @param string $message Log message * @param array $context Additional context data * @return bool Success */ public function log($level, $message, $context = []) { global $wpdb; // Validate level $valid_levels = [self::LEVEL_INFO, self::LEVEL_WARNING, self::LEVEL_ERROR, self::LEVEL_DEBUG]; if (!in_array($level, $valid_levels)) { $level = self::LEVEL_INFO; } // Prepare context for storage $context_json = !empty($context) ? wp_json_encode($context) : null; // Insert log entry // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix and is not user input; all values are passed through prepare(). These are plugin-owned tables, so no core caching API applies. $result = $wpdb->insert( $this->table_name, [ 'level' => $level, 'message' => sanitize_text_field($message), 'context' => $context_json, 'created_at' => current_time('mysql'), ], ['%s', '%s', '%s', '%s'] ); // Cleanup old logs periodically $this->maybe_cleanup(); return $result !== false; } /** * Log info message * * @param string $message Log message * @param array $context Additional context */ public function info($message, $context = []) { $this->log(self::LEVEL_INFO, $message, $context); } /** * Log warning message * * @param string $message Log message * @param array $context Additional context */ public function warning($message, $context = []) { $this->log(self::LEVEL_WARNING, $message, $context); } /** * Log error message * * @param string $message Log message * @param array $context Additional context */ public function error($message, $context = []) { $this->log(self::LEVEL_ERROR, $message, $context); } /** * Log debug message * * @param string $message Log message * @param array $context Additional context */ public function debug($message, $context = []) { if (defined('WP_DEBUG') && WP_DEBUG) { $this->log(self::LEVEL_DEBUG, $message, $context); } } /** * Get log entries * * @param array $args Query arguments * @return array Log entries */ public function get_logs($args = []) { global $wpdb; $defaults = [ 'level' => '', 'search' => '', 'limit' => 50, 'offset' => 0, 'orderby' => 'created_at', 'order' => 'DESC', ]; $args = wp_parse_args($args, $defaults); // Build query $where = []; $values = []; if (!empty($args['level'])) { $where[] = 'level = %s'; $values[] = $args['level']; } if (!empty($args['search'])) { $where[] = 'message LIKE %s'; $values[] = '%' . $wpdb->esc_like($args['search']) . '%'; } $where_clause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; // Sanitize orderby $allowed_orderby = ['id', 'level', 'message', 'created_at']; $orderby = in_array($args['orderby'], $allowed_orderby) ? $args['orderby'] : 'created_at'; $order = strtoupper($args['order']) === 'ASC' ? 'ASC' : 'DESC'; // Escaping for safe interpolation $orderby = esc_sql($orderby); $order = esc_sql($order); // Build SQL query $sql = "SELECT * FROM {$this->table_name}"; if (!empty($where)) { $sql .= " WHERE " . implode(' AND ', $where); } $sql .= " ORDER BY {$orderby} {$order} LIMIT %d OFFSET %d"; $values[] = intval($args['limit']); $values[] = intval($args['offset']); // Prepare the query if (!empty($values)) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix and is not user input; all values are passed through prepare(). These are plugin-owned tables, so no core caching API applies. $query = $wpdb->prepare($sql, $values); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix and is not user input; all values are passed through prepare(). These are plugin-owned tables, so no core caching API applies. $results = $wpdb->get_results($query, ARRAY_A); } else { // Fallback (unlikely) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix and is not user input; all values are passed through prepare(). These are plugin-owned tables, so no core caching API applies. $results = $wpdb->get_results($sql, ARRAY_A); } // Decode context for each result if ($results) { foreach ($results as &$row) { if (!empty($row['context'])) { $row['context'] = json_decode($row['context'], true); } } } return $results ?: []; } /** * Get total log count * * @param string $level Filter by level (optional) * @return int Total count */ public function get_count($level = '') { global $wpdb; if (!empty($level)) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix and is not user input; all values are passed through prepare(). These are plugin-owned tables, so no core caching API applies. $count = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE level = %s", $this->table_name, $level )); } else { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix and is not user input; all values are passed through prepare(). These are plugin-owned tables, so no core caching API applies. $count = $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM %i", $this->table_name)); } return intval($count); } /** * Clear all logs * * @return bool Success */ public function clear_logs() { global $wpdb; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix and is not user input; all values are passed through prepare(). These are plugin-owned tables, so no core caching API applies. return $wpdb->query($wpdb->prepare("TRUNCATE TABLE %i", $this->table_name)) !== false; } /** * Clear logs by level * * @param string $level Log level to clear * @return int|false Number of rows deleted or false */ public function clear_by_level($level) { global $wpdb; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix and is not user input; all values are passed through prepare(). These are plugin-owned tables, so no core caching API applies. return $wpdb->delete($this->table_name, ['level' => $level], ['%s']); } /** * Maybe cleanup old logs */ private function maybe_cleanup() { // Only cleanup 1% of the time to reduce overhead if (wp_rand(1, 100) !== 1) { return; } $this->cleanup_old_logs(); } /** * Delete logs older than the given number of days * * @param int $days Retention window in days * @return int|false Rows deleted or false on error */ public function cleanup_older_than($days) { global $wpdb; $days = max(1, intval($days)); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix and is not user input; all values are passed through prepare(). These are plugin-owned tables, so no core caching API applies. return $wpdb->query($wpdb->prepare( "DELETE FROM %i WHERE created_at < DATE_SUB(NOW(), INTERVAL %d DAY)", $this->table_name, $days )); } /** * Cleanup old logs to maintain max limit */ public function cleanup_old_logs() { global $wpdb; $count = $this->get_count(); if ($count > $this->max_logs) { $delete_count = $count - $this->max_logs; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix and is not user input; all values are passed through prepare(). These are plugin-owned tables, so no core caching API applies. $wpdb->query($wpdb->prepare( "DELETE FROM %i ORDER BY created_at ASC LIMIT %d", $this->table_name, $delete_count )); } } /** * Get log level badge HTML * * @param string $level Log level * @return string HTML badge */ public static function get_level_badge($level) { $badges = [ self::LEVEL_INFO => 'Info', self::LEVEL_WARNING => 'Warning', self::LEVEL_ERROR => 'Error', self::LEVEL_DEBUG => 'Debug', ]; return isset($badges[$level]) ? $badges[$level] : '' . esc_html($level) . ''; } /** * Export logs to CSV * * @param array $args Query arguments for filtering * @return string CSV content */ public function export_csv($args = []) { $args['limit'] = 10000; // Max export $logs = $this->get_logs($args); // CSV content $csv = "ID,Level,Message,Context,Created At\n"; foreach ($logs as $log) { $context = is_array($log['context']) ? wp_json_encode($log['context']) : $log['context']; $row = [ $log['id'], $log['level'], $log['message'], $context, $log['created_at'], ]; // Escape CSV fields $row = array_map(function($field) { return '"' . str_replace('"', '""', $field) . '"'; }, $row); $csv .= implode(',', $row) . "\n"; } return $csv; } }