wpdb = $wpdb ?: $GLOBALS['wpdb']; $this->table = $this->wpdb->prefix . 'ao_system_logs'; } /** * Creates or updates the table schema. Safe to call for every write. */ public function ensure_table(): bool { if ( get_option( self::SCHEMA_VERSION_OPTION ) === self::SCHEMA_VERSION ) { return true; } if ( ! function_exists( 'dbDelta' ) ) { $upgrade = defined( 'ABSPATH' ) ? ABSPATH . 'wp-admin/includes/upgrade.php' : ''; if ( ! $upgrade || ! is_readable( $upgrade ) ) { return false; } require_once $upgrade; } Table_Rename_Helper::maybe_rename( $this->wpdb->prefix . 'adminoptim_system_logs', $this->table ); $charset_collate = $this->wpdb->get_charset_collate(); $sql = "CREATE TABLE {$this->table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, created_at DATETIME NOT NULL, level VARCHAR(20) NOT NULL, channel VARCHAR(100) NOT NULL, event VARCHAR(150) NOT NULL, message TEXT NOT NULL, context LONGTEXT NULL, request_id VARCHAR(32) NULL, user_id BIGINT UNSIGNED NULL, url VARCHAR(500) NULL, ao_version VARCHAR(32) NOT NULL, fingerprint CHAR(64) NOT NULL, occurrence_count BIGINT UNSIGNED NOT NULL DEFAULT 1, first_seen_at DATETIME NOT NULL, last_seen_at DATETIME NOT NULL, PRIMARY KEY (id), KEY level_last_seen (level, last_seen_at), KEY channel_last_seen (channel, last_seen_at), KEY event_last_seen (event, last_seen_at), KEY fingerprint_first_seen (fingerprint, first_seen_at), KEY request_id (request_id), KEY user_id (user_id), KEY last_seen_at (last_seen_at) ) {$charset_collate};"; dbDelta( $sql ); $exists = $this->table_exists(); if ( $exists ) { update_option( self::SCHEMA_VERSION_OPTION, self::SCHEMA_VERSION ); } return $exists; } /** * Stores or deduplicates a prepared record. * * @param array $record Prepared record. */ public function write( array $record ): bool { if ( ! $this->ensure_table() ) { return false; } $window = max( 1, (int) apply_filters( 'adminoptim_logger_deduplication_window', 5 * MINUTE_IN_SECONDS ) ); $now_ts = strtotime( (string) $record['created_at'] ); $start = gmdate( 'Y-m-d H:i:s', $now_ts - $window ); // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is an internal fixed identifier ($wpdb->prefix . 'ao_system_logs'), not user input; all values are passed through prepare(). $row_id = $this->wpdb->get_var( $this->wpdb->prepare( "SELECT id FROM {$this->table} WHERE fingerprint = %s AND first_seen_at >= %s ORDER BY first_seen_at DESC LIMIT 1", $record['fingerprint'], $start ) ); // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared if ( $row_id ) { // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is an internal fixed identifier ($wpdb->prefix . 'ao_system_logs'), not user input; all values are passed through prepare(). $result = $this->wpdb->query( $this->wpdb->prepare( "UPDATE {$this->table} SET occurrence_count = occurrence_count + 1, last_seen_at = %s WHERE id = %d", $record['last_seen_at'], $row_id ) ); // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared return false !== $result; } // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is an internal fixed identifier ($wpdb->prefix . 'ao_system_logs'), not user input; all values are passed through prepare(). $recent_rows = (int) $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(*) FROM {$this->table} WHERE level = %s AND channel = %s AND event = %s AND created_at >= %s", $record['level'], $record['channel'], $record['event'], gmdate( 'Y-m-d H:i:s', $now_ts - HOUR_IN_SECONDS ) ) ); // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared if ( $recent_rows >= self::RATE_LIMIT_ROWS_PER_HOUR ) { return true; } return false !== $this->wpdb->insert( $this->table, $record, [ '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%d', '%s', '%s' ] ); } /** * Prunes expired rows and enforces the row cap. */ public function prune(): int { if ( ! $this->table_exists() ) { return 0; } $retention = max( 1, (int) apply_filters( 'adminoptim_logger_retention_days', 30 ) ); // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is an internal fixed identifier ($wpdb->prefix . 'ao_system_logs'), not user input; all values are passed through prepare(). $deleted = (int) $this->wpdb->query( $this->wpdb->prepare( "DELETE FROM {$this->table} WHERE last_seen_at < %s", gmdate( 'Y-m-d H:i:s', time() - ( $retention * DAY_IN_SECONDS ) ) ) ); // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $max_rows = max( 1, (int) apply_filters( 'adminoptim_logger_max_rows', 10000 ) ); $count = (int) $this->wpdb->get_var( "SELECT COUNT(*) FROM {$this->table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is an internal fixed identifier ($wpdb->prefix . 'ao_system_logs'), not user input; no user values in this query. $excess = max( 0, $count - $max_rows ); if ( $excess ) { // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is an internal fixed identifier ($wpdb->prefix . 'ao_system_logs'), not user input; all values are passed through prepare(). $deleted += (int) $this->wpdb->query( $this->wpdb->prepare( "DELETE FROM {$this->table} ORDER BY last_seen_at ASC, id ASC LIMIT %d", $excess ) ); // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared } if ( $deleted > 0 ) { do_action( 'adminoptim_log_pruned', $deleted ); } return $deleted; } /** * Check table exists function * * @return boolean */ private function table_exists(): bool { $found = $this->wpdb->get_var( $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $this->wpdb->esc_like( $this->table ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is fully prepared; the LIKE value is escaped via esc_like() and passed as a %s placeholder. return $this->table === $found; } }