feat: add structured plugin logging system with admin log viewer
Introduces Peptide_News_Logger class with leveled logging (info, warning, error, debug) to a custom DB table, auto-pruning at 2000 rows. Adds a filterable, paginated log viewer to the admin settings page. Instruments the LLM and Fetcher classes with structured log calls for fetch cycles, AI processing, and error tracking. Version bumped to 2.1.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,11 @@ class Peptide_News_Activator {
|
||||
self::create_tables();
|
||||
self::set_default_options();
|
||||
self::schedule_cron();
|
||||
|
||||
// Create the log table (idempotent via dbDelta).
|
||||
if ( class_exists( 'Peptide_News_Logger' ) ) {
|
||||
Peptide_News_Logger::create_table();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,11 +44,13 @@ class Peptide_News_Fetcher {
|
||||
// Prevent overlapping fetch jobs.
|
||||
$lock_key = 'peptide_news_fetch_lock';
|
||||
if ( get_transient( $lock_key ) ) {
|
||||
$this->log_error( 'Fetch already in progress. Skipping this cycle.' );
|
||||
Peptide_News_Logger::warning( 'Fetch already in progress — skipping this cycle.', 'fetch' );
|
||||
return;
|
||||
}
|
||||
set_transient( $lock_key, true, 300 ); // 5-minute lock.
|
||||
|
||||
Peptide_News_Logger::info( 'Fetch cycle started.', 'fetch' );
|
||||
|
||||
$articles = array();
|
||||
|
||||
// Fetch from RSS feeds.
|
||||
@@ -89,6 +91,11 @@ class Peptide_News_Fetcher {
|
||||
'new_stored' => $stored,
|
||||
) );
|
||||
|
||||
Peptide_News_Logger::info( sprintf(
|
||||
'Fetch complete: %d found, %d filtered out, %d new stored.',
|
||||
$pre_filter_count, $filtered_out, $stored
|
||||
), 'fetch' );
|
||||
|
||||
// Run AI analysis on newly fetched articles (keywords + summary).
|
||||
if ( class_exists( 'Peptide_News_LLM' ) && Peptide_News_LLM::is_enabled() ) {
|
||||
$llm_batch_size = absint( get_option( 'peptide_news_llm_max_articles', 10 ) );
|
||||
|
||||
@@ -75,14 +75,15 @@ class Peptide_News_LLM {
|
||||
if ( ! is_wp_error( $keywords_result ) ) {
|
||||
$results['keywords'] = self::sanitize_keywords( $keywords_result );
|
||||
$any_success = true;
|
||||
Peptide_News_Logger::debug( 'Keywords extracted for article #' . $article->id, 'llm' );
|
||||
} else {
|
||||
$err_msg = 'Keywords (' . $keywords_model . '): ' . $keywords_result->get_error_message();
|
||||
$results['errors'][] = $err_msg;
|
||||
self::log_error( 'Keyword extraction failed for article ' . $article->id . ': ' . $keywords_result->get_error_message() );
|
||||
Peptide_News_Logger::error( 'Keyword extraction failed for article #' . $article->id . ': ' . $keywords_result->get_error_message(), 'llm' );
|
||||
}
|
||||
} elseif ( $need_keywords ) {
|
||||
$results['errors'][] = 'Invalid keywords model ID: ' . $keywords_model;
|
||||
self::log_error( 'Invalid keywords model ID: ' . $keywords_model );
|
||||
Peptide_News_Logger::error( 'Invalid keywords model ID: ' . $keywords_model, 'llm' );
|
||||
}
|
||||
|
||||
// --- Summarization ---
|
||||
@@ -97,14 +98,15 @@ class Peptide_News_LLM {
|
||||
// Strip all HTML tags from LLM output to prevent XSS.
|
||||
$results['summary'] = sanitize_textarea_field( wp_strip_all_tags( $summary_result ) );
|
||||
$any_success = true;
|
||||
Peptide_News_Logger::info( 'AI summary generated for article #' . $article->id . ': ' . mb_substr( $article->title, 0, 60 ), 'llm' );
|
||||
} else {
|
||||
$err_msg = 'Summary (' . $summary_model . '): ' . $summary_result->get_error_message();
|
||||
$results['errors'][] = $err_msg;
|
||||
self::log_error( 'Summarization failed for article ' . $article->id . ': ' . $summary_result->get_error_message() );
|
||||
Peptide_News_Logger::error( 'Summarization failed for article #' . $article->id . ': ' . $summary_result->get_error_message(), 'llm' );
|
||||
}
|
||||
} elseif ( $need_summary ) {
|
||||
$results['errors'][] = 'Invalid summary model ID: ' . $summary_model;
|
||||
self::log_error( 'Invalid summary model ID: ' . $summary_model );
|
||||
Peptide_News_Logger::error( 'Invalid summary model ID: ' . $summary_model, 'llm' );
|
||||
}
|
||||
|
||||
$results['success'] = $any_success;
|
||||
@@ -198,6 +200,12 @@ class Peptide_News_LLM {
|
||||
'errors' => $last_errors,
|
||||
) );
|
||||
|
||||
if ( $processed > 0 ) {
|
||||
Peptide_News_Logger::info( sprintf( 'AI batch complete: %d/%d articles processed.', $processed, count( $articles ) ), 'llm' );
|
||||
} elseif ( count( $articles ) > 0 ) {
|
||||
Peptide_News_Logger::warning( sprintf( 'AI batch: 0/%d articles succeeded.', count( $articles ) ), 'llm' );
|
||||
}
|
||||
|
||||
return $processed;
|
||||
}
|
||||
|
||||
@@ -407,6 +415,8 @@ class Peptide_News_LLM {
|
||||
wp_send_json_error( 'AI Analysis is not enabled. Please enable it and set an OpenRouter API key in Peptide News → Settings.' );
|
||||
}
|
||||
|
||||
Peptide_News_Logger::info( 'Bulk AI summary generation triggered by admin.', 'llm' );
|
||||
|
||||
// Auto-correct known broken model names to a working free model.
|
||||
$broken_models = array(
|
||||
'qwen/qwen3.6-plus:free',
|
||||
|
||||
254
includes/class-peptide-news-logger.php
Normal file
254
includes/class-peptide-news-logger.php
Normal file
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Logger.
|
||||
*
|
||||
* Provides structured logging to a custom database table with levels,
|
||||
* contexts, and an admin-facing viewer with AJAX pagination and clear.
|
||||
*
|
||||
* @since 2.1.0
|
||||
*/
|
||||
class Peptide_News_Logger {
|
||||
|
||||
/** Log level constants. */
|
||||
const LEVEL_INFO = 'info';
|
||||
const LEVEL_WARNING = 'warning';
|
||||
const LEVEL_ERROR = 'error';
|
||||
const LEVEL_DEBUG = 'debug';
|
||||
|
||||
/** Maximum rows kept in the log table (auto-pruned on write). */
|
||||
const MAX_ROWS = 2000;
|
||||
|
||||
/**
|
||||
* Return the log table name.
|
||||
*/
|
||||
public static function table() {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'peptide_news_log';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Writing
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* Write a log entry.
|
||||
*
|
||||
* @param string $level One of the LEVEL_* constants.
|
||||
* @param string $message Human-readable message.
|
||||
* @param string $context Category tag (fetch, llm, admin, cron, general).
|
||||
*/
|
||||
public static function log( $level, $message, $context = 'general' ) {
|
||||
global $wpdb;
|
||||
|
||||
$wpdb->insert(
|
||||
self::table(),
|
||||
array(
|
||||
'level' => $level,
|
||||
'context' => $context,
|
||||
'message' => $message,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
),
|
||||
array( '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
|
||||
// Auto-prune old entries.
|
||||
self::maybe_prune();
|
||||
}
|
||||
|
||||
/** Convenience: info level. */
|
||||
public static function info( $message, $context = 'general' ) {
|
||||
self::log( self::LEVEL_INFO, $message, $context );
|
||||
}
|
||||
|
||||
/** Convenience: warning level. */
|
||||
public static function warning( $message, $context = 'general' ) {
|
||||
self::log( self::LEVEL_WARNING, $message, $context );
|
||||
}
|
||||
|
||||
/** Convenience: error level. */
|
||||
public static function error( $message, $context = 'general' ) {
|
||||
self::log( self::LEVEL_ERROR, $message, $context );
|
||||
}
|
||||
|
||||
/** Convenience: debug level (only writes when WP_DEBUG is on). */
|
||||
public static function debug( $message, $context = 'general' ) {
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
self::log( self::LEVEL_DEBUG, $message, $context );
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Reading
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* Retrieve log entries.
|
||||
*
|
||||
* @param array $args {
|
||||
* Optional. Query arguments.
|
||||
*
|
||||
* @type string $level Filter by level.
|
||||
* @type string $context Filter by context.
|
||||
* @type int $limit Number of rows.
|
||||
* @type int $offset Pagination offset.
|
||||
* }
|
||||
* @return array { 'rows' => array, 'total' => int }
|
||||
*/
|
||||
public static function get_logs( $args = array() ) {
|
||||
global $wpdb;
|
||||
|
||||
$defaults = array(
|
||||
'level' => '',
|
||||
'context' => '',
|
||||
'limit' => 50,
|
||||
'offset' => 0,
|
||||
);
|
||||
$args = wp_parse_args( $args, $defaults );
|
||||
|
||||
$table = self::table();
|
||||
$where = array( '1=1' );
|
||||
$vals = array();
|
||||
|
||||
if ( ! empty( $args['level'] ) ) {
|
||||
$where[] = 'level = %s';
|
||||
$vals[] = $args['level'];
|
||||
}
|
||||
if ( ! empty( $args['context'] ) ) {
|
||||
$where[] = 'context = %s';
|
||||
$vals[] = $args['context'];
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where );
|
||||
|
||||
// Total count.
|
||||
$count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
|
||||
$total = empty( $vals )
|
||||
? (int) $wpdb->get_var( $count_sql )
|
||||
: (int) $wpdb->get_var( $wpdb->prepare( $count_sql, $vals ) );
|
||||
|
||||
// Rows.
|
||||
$order_sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY id DESC LIMIT %d OFFSET %d";
|
||||
$vals[] = absint( $args['limit'] );
|
||||
$vals[] = absint( $args['offset'] );
|
||||
$rows = $wpdb->get_results( $wpdb->prepare( $order_sql, $vals ) );
|
||||
|
||||
return array(
|
||||
'rows' => $rows ? $rows : array(),
|
||||
'total' => $total,
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Maintenance
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* Remove rows beyond the MAX_ROWS cap.
|
||||
*/
|
||||
private static function maybe_prune() {
|
||||
global $wpdb;
|
||||
$table = self::table();
|
||||
|
||||
// Only prune every ~50 writes (probabilistic).
|
||||
if ( wp_rand( 1, 50 ) !== 1 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
|
||||
|
||||
if ( $count > self::MAX_ROWS ) {
|
||||
$delete_count = $count - self::MAX_ROWS;
|
||||
$wpdb->query( $wpdb->prepare(
|
||||
"DELETE FROM {$table} ORDER BY id ASC LIMIT %d",
|
||||
$delete_count
|
||||
) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all log entries.
|
||||
*/
|
||||
public static function clear() {
|
||||
global $wpdb;
|
||||
$wpdb->query( "TRUNCATE TABLE " . self::table() );
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* AJAX handlers
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* AJAX: fetch paginated log entries.
|
||||
*/
|
||||
public static function ajax_get_logs() {
|
||||
check_ajax_referer( 'peptide_news_admin', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( 'Unauthorized', 403 );
|
||||
}
|
||||
|
||||
$page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1;
|
||||
$level = isset( $_POST['level'] ) ? sanitize_text_field( $_POST['level'] ) : '';
|
||||
$context = isset( $_POST['context'] ) ? sanitize_text_field( $_POST['context'] ) : '';
|
||||
$per_page = 50;
|
||||
|
||||
$result = self::get_logs( array(
|
||||
'level' => $level,
|
||||
'context' => $context,
|
||||
'limit' => $per_page,
|
||||
'offset' => ( $page - 1 ) * $per_page,
|
||||
) );
|
||||
|
||||
wp_send_json_success( array(
|
||||
'rows' => $result['rows'],
|
||||
'total' => $result['total'],
|
||||
'page' => $page,
|
||||
'total_pages' => ceil( $result['total'] / $per_page ),
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: clear all log entries.
|
||||
*/
|
||||
public static function ajax_clear_logs() {
|
||||
check_ajax_referer( 'peptide_news_admin', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( 'Unauthorized', 403 );
|
||||
}
|
||||
|
||||
self::clear();
|
||||
self::info( 'Log cleared by admin.', 'admin' );
|
||||
|
||||
wp_send_json_success( array( 'message' => 'Log cleared.' ) );
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Schema
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* Create the log table (called from activator / upgrade check).
|
||||
*/
|
||||
public static function create_table() {
|
||||
global $wpdb;
|
||||
|
||||
$table = self::table();
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
$sql = "CREATE TABLE {$table} (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
level VARCHAR(10) NOT NULL DEFAULT 'info',
|
||||
context VARCHAR(30) NOT NULL DEFAULT 'general',
|
||||
message TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_level (level),
|
||||
KEY idx_context (context),
|
||||
KEY idx_created (created_at)
|
||||
) {$charset_collate};";
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
dbDelta( $sql );
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ class Peptide_News {
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-analytics.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-rest-api.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-llm.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-logger.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-content-filter.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-peptide-news-admin.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'public/class-peptide-news-public.php';
|
||||
@@ -82,6 +83,8 @@ class Peptide_News {
|
||||
$this->loader->add_filter( 'cron_schedules', $fetcher, 'add_custom_cron_schedules' );
|
||||
$this->loader->add_action( 'wp_ajax_peptide_news_backfill_sources', $fetcher, 'ajax_backfill_sources' );
|
||||
$this->loader->add_action( 'wp_ajax_peptide_news_generate_summaries', 'Peptide_News_LLM', 'ajax_generate_summaries' );
|
||||
$this->loader->add_action( 'wp_ajax_peptide_news_get_logs', 'Peptide_News_Logger', 'ajax_get_logs' );
|
||||
$this->loader->add_action( 'wp_ajax_peptide_news_clear_logs', 'Peptide_News_Logger', 'ajax_clear_logs' );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user