Files
2meet-data-optimizer/includes/class-tmdo-logger.php
T
wpdev ae99820bbc fix(logger): 補回 TMDO_Logger::trace_id()(實機驗證抓到的 fatal)
TMDO_Audit_Logger::write_row() 第 160 行呼叫 TMDO_Logger::trace_id(),
但 B 提煉時漏掉了這個方法(A 有,class-wpdo-logger.php:35-47)。
先前 Audit_Logger::init() 從未被註冊,所以這個 fatal 一直藏著;
A12 把它掛上之後,任何一次受管 meta 寫入都會炸。

PHPUnit 沒有覆蓋 audit 寫入路徑,是在 dev30 實機跑
WPDO_API::set_entity() 時才暴露的。

trace_id() 產生 request-scoped UUIDv4,讓同一次請求寫出的所有 audit
row 共用同一個關聯 id。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 08:29:08 +08:00

192 lines
5.9 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; 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 {
error_log( sprintf( '[WPDO][INFO][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) );
}
/**
* Log a NOTICE-level event — alias of info() for callers that want a stronger
* level intent. Same routing (error_log only).
*
* @param string $event Event name.
* @param array $context Structured context.
* @return void
*/
public static function notice( string $event, array $context = array() ): void {
self::info( $event, $context );
}
/**
* 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::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
}
}