e26aadc4ec
- TMDO_Logger::notice() is always written to the error log (it had no callers); bridge_mode_changed and snapshot_deleted use it. Since PR-095 info() is silent without WP_DEBUG_LOG, which left no record of who switched a mode or deleted a snapshot (security review L1) - Verify: LoggerTest (notice always logged, info gated), unit 703, integration 585, phpcs, stan - PR: PR-113 (2.3 acceptance security review)
209 lines
6.7 KiB
PHP
209 lines
6.7 KiB
PHP
<?php
|
|
/**
|
|
* Error logging for WP Data Optimizer.
|
|
*
|
|
* @package WP_Data_Optimizer
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Lightweight error logger — writes to wpdo_errors table and PHP error_log.
|
|
*/
|
|
class TMDO_Logger {
|
|
|
|
/**
|
|
* Request-scoped correlation id, lazily generated.
|
|
*
|
|
* @var string|null
|
|
*/
|
|
private static ?string $trace_id = null;
|
|
|
|
/**
|
|
* Request-scoped UUIDv4 correlation id.
|
|
*
|
|
* Every audit row written during one request shares this value so a single
|
|
* update_*_meta() call can be traced across entity groups.
|
|
*
|
|
* @return string UUIDv4.
|
|
*/
|
|
public static function trace_id(): string {
|
|
if ( null === self::$trace_id ) {
|
|
try {
|
|
$bytes = random_bytes( 16 );
|
|
} catch ( \Throwable $e ) {
|
|
$bytes = pack( 'H*', md5( (string) microtime( true ) . wp_generate_password( 16, false ) ) );
|
|
}
|
|
$bytes[6] = chr( ( ord( $bytes[6] ) & 0x0f ) | 0x40 );
|
|
$bytes[8] = chr( ( ord( $bytes[8] ) & 0x3f ) | 0x80 );
|
|
self::$trace_id = vsprintf( '%s%s-%s-%s-%s-%s%s%s', str_split( bin2hex( $bytes ), 4 ) );
|
|
}
|
|
return self::$trace_id;
|
|
}
|
|
|
|
/**
|
|
* Log an INFO-level event (event-based signature, used by v2.0.0 engine code).
|
|
*
|
|
* Writes only to PHP error_log, and only when debug logging is on; does NOT touch wpdo_errors (which is reserved
|
|
* for actual errors). Mode changes / cache flushes / audit prunes call here
|
|
* many times per request — keeping them out of the DB error table.
|
|
*
|
|
* @param string $event Event name (e.g. 'bridge_mode_changed').
|
|
* @param array $context Structured context.
|
|
* @return void
|
|
*/
|
|
public static function info( string $event, array $context = array() ): void {
|
|
// Routine events: only with WP_DEBUG_LOG (filter `tmdo_log_info`), or every
|
|
// CLI run and production request would flood the log.
|
|
if ( ! apply_filters( 'tmdo_log_info', defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) ) {
|
|
return;
|
|
}
|
|
self::write_info( $event, $context );
|
|
}
|
|
|
|
/**
|
|
* Write an INFO line to the PHP error log.
|
|
*
|
|
* @param string $event Event name.
|
|
* @param array<string, mixed> $context Structured context.
|
|
* @return void
|
|
*/
|
|
private static function write_info( string $event, array $context ): void {
|
|
error_log( sprintf( '[WPDO][INFO][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) );
|
|
}
|
|
|
|
/**
|
|
* Log a NOTICE-level event: always written to the PHP error log (unlike
|
|
* info()), for the audit trail of operator actions such as mode changes
|
|
* and snapshot deletions.
|
|
*
|
|
* @param string $event Event name.
|
|
* @param array<string, mixed> $context Structured context.
|
|
* @return void
|
|
*/
|
|
public static function notice( string $event, array $context = array() ): void {
|
|
error_log( sprintf( '[WPDO][NOTICE][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) );
|
|
}
|
|
|
|
/**
|
|
* Log a WARNING-level event — error_log + wpdo_errors row (for admin visibility).
|
|
*
|
|
* Use for "anomaly worth attention but not breaking" (e.g. invalid filter
|
|
* return, unexpected fallback path). The event_name becomes the `hook` column;
|
|
* message gets a `[WARN]` prefix to distinguish from hard errors.
|
|
*
|
|
* @param string $event Event name (e.g. 'wpdo_route_decision_invalid_return').
|
|
* @param array $context Structured context.
|
|
* @return void
|
|
*/
|
|
public static function warning( string $event, array $context = array() ): void {
|
|
error_log( sprintf( '[WPDO][WARN][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) );
|
|
self::error( 'engine', $event, '[WARN] ' . $event, $context );
|
|
}
|
|
|
|
/**
|
|
* Log a DEBUG-level event — only when WP_DEBUG is true; routes to error_log.
|
|
*
|
|
* @param string $event Event name.
|
|
* @param array $context Structured context.
|
|
* @return void
|
|
*/
|
|
public static function debug( string $event, array $context = array() ): void {
|
|
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
|
self::write_info( $event, $context );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log a module error.
|
|
*
|
|
* @param string $module Module name (e.g. 'reviews', 'hot_hp_listing').
|
|
* @param string $hook The hook or method where the error occurred.
|
|
* @param string $message Human-readable error message.
|
|
* @param array $context Optional extra context (will be JSON-encoded).
|
|
* @param string $zone Optional zone identifier (hot/warm/cold/archive).
|
|
*/
|
|
public static function error( string $module, string $hook, string $message, array $context = array(), string $zone = '' ): void {
|
|
global $wpdb;
|
|
|
|
error_log( sprintf( '[WPDO][%s][%s] %s', $module, $hook, $message ) );
|
|
|
|
$table = TMDO_DB::table( 'wpdo_errors' );
|
|
$wpdb->insert(
|
|
$table,
|
|
array(
|
|
'module' => sanitize_key( $module ),
|
|
'zone' => sanitize_key( $zone ),
|
|
'hook' => substr( sanitize_text_field( $hook ), 0, 255 ),
|
|
'message' => $message,
|
|
'context' => $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : null,
|
|
'created_at' => current_time( 'mysql', true ),
|
|
),
|
|
array( '%s', '%s', '%s', '%s', '%s', '%s' )
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Return recent errors, optionally filtered by module.
|
|
*
|
|
* @param string $module Module name (empty = all modules).
|
|
* @param int $limit Max rows to return.
|
|
* @return array
|
|
*/
|
|
public static function get_recent( string $module = '', int $limit = 100 ): array {
|
|
global $wpdb;
|
|
$table = TMDO_DB::table( 'wpdo_errors' );
|
|
|
|
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_DB::table().
|
|
if ( $module ) {
|
|
return $wpdb->get_results(
|
|
$wpdb->prepare(
|
|
"SELECT * FROM `{$table}` WHERE module = %s ORDER BY id DESC LIMIT %d",
|
|
$module,
|
|
$limit
|
|
),
|
|
ARRAY_A
|
|
) ?: array();
|
|
}
|
|
|
|
return $wpdb->get_results(
|
|
$wpdb->prepare( "SELECT * FROM `{$table}` ORDER BY id DESC LIMIT %d", $limit ),
|
|
ARRAY_A
|
|
) ?: array();
|
|
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
}
|
|
|
|
/**
|
|
* Delete errors older than a given number of days.
|
|
*
|
|
* @param int $days Keep errors from the last N days.
|
|
* @return int Number of rows deleted.
|
|
*/
|
|
public static function purge( int $days = 30 ): int {
|
|
global $wpdb;
|
|
$table = TMDO_DB::table( 'wpdo_errors' );
|
|
|
|
$now = current_time( 'mysql', true );
|
|
|
|
if ( TMDO_IS_SQLITE ) {
|
|
$sql = $wpdb->prepare(
|
|
"DELETE FROM `{$table}` WHERE created_at < datetime(%s, %s)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
$now,
|
|
"-{$days} days"
|
|
);
|
|
} else {
|
|
$sql = $wpdb->prepare(
|
|
"DELETE FROM `{$table}` WHERE created_at < DATE_SUB(%s, INTERVAL %d DAY)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
$now,
|
|
$days
|
|
);
|
|
}
|
|
|
|
return (int) $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
|
}
|
|
}
|