76c01e44df
對齊 A v3.2.0。型別強制會把隱式轉換變成 TypeError,所以一次全檔加入 並跑完整測試(unit 451 / integration 398 全綠,無迴歸)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
291 lines
9.2 KiB
PHP
291 lines
9.2 KiB
PHP
<?php
|
|
/**
|
|
* TMDO_Health_Cron — Daily health probe (v2.3.0 M6).
|
|
*
|
|
* Runs daily at 03:30 UTC. Aggregates results from:
|
|
* - TMDO_Site_Health 7 tests (schema_drift, error_budget, hook_conflicts,
|
|
* autoload_bloat, postmeta_explosion, orphan_zone_rows, missing_snapshot)
|
|
* - TMDO_Conflict_Monitor::get_summary()
|
|
* - shadow_diffs ratio per module in `verify` state
|
|
* - autoload size measurement
|
|
*
|
|
* Outputs:
|
|
* 1. Single audit log entry: op='health_check_daily' with full payload
|
|
* (so admin can read history via Logs tab + `wp wpdo audit` future CLI).
|
|
* 2. wpdo_health_alert option set when any critical found (existing
|
|
* TMDO_Core::render_health_alert_notice consumes this).
|
|
* 3. wpdo_health_last_run option for SOP runbook "is health green?" question.
|
|
* 4. action 'wpdo/health_alert_critical' fired on critical (v2.4.0 email
|
|
* notifier subscribes here; consumers get the full result array).
|
|
*
|
|
* @package WP_Data_Optimizer
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Daily probe — stateless static API.
|
|
*/
|
|
class TMDO_Health_Cron {
|
|
|
|
/** Option key for the most recent run summary. */
|
|
public const OPTION_LAST_RUN = 'wpdo_health_last_run';
|
|
|
|
/** Option key for active critical alert (consumed by Core notice). */
|
|
public const OPTION_ALERT = 'wpdo_health_alert';
|
|
|
|
/**
|
|
* Run the daily probe. Idempotent — safe to invoke ad-hoc.
|
|
*
|
|
* @return array {ok:bool, summary:array, critical_count:int, recommended_count:int, ts:string}
|
|
*/
|
|
public static function run(): array {
|
|
$started_at = microtime( true );
|
|
$results = self::run_site_health_tests();
|
|
$conflict = self::summarize_conflicts();
|
|
$shadow = self::summarize_shadow_diffs();
|
|
$autoload = self::measure_autoload_size();
|
|
|
|
$critical_count = 0;
|
|
$recommended_count = 0;
|
|
foreach ( $results as $r ) {
|
|
$status = (string) ( $r['status'] ?? 'good' );
|
|
if ( 'critical' === $status ) {
|
|
++$critical_count;
|
|
} elseif ( 'recommended' === $status ) {
|
|
++$recommended_count;
|
|
}
|
|
}
|
|
|
|
$summary = array(
|
|
'tests' => $results,
|
|
'critical_count' => $critical_count,
|
|
'recommended_count' => $recommended_count,
|
|
'conflicts' => $conflict,
|
|
'shadow_diffs' => $shadow,
|
|
'autoload_bytes' => $autoload,
|
|
'ran_at' => gmdate( 'Y-m-d H:i:s' ),
|
|
'duration_ms' => (int) round( ( microtime( true ) - $started_at ) * 1000 ),
|
|
);
|
|
|
|
// 1. Persist last-run snapshot (autoload=no, lightweight).
|
|
update_option( self::OPTION_LAST_RUN, $summary, false );
|
|
|
|
// 2. Set / clear alert flag.
|
|
if ( $critical_count > 0 ) {
|
|
$first_critical = self::first_critical_test( $results );
|
|
update_option(
|
|
self::OPTION_ALERT,
|
|
array(
|
|
'level' => 'critical',
|
|
'count' => $critical_count,
|
|
'first' => $first_critical,
|
|
'ran_at' => $summary['ran_at'],
|
|
),
|
|
false
|
|
);
|
|
} else {
|
|
delete_option( self::OPTION_ALERT );
|
|
}
|
|
|
|
// v2.5.0 M16: refresh module suggestions cache (autoload=no).
|
|
$module_suggestions_count = 0;
|
|
if ( class_exists( 'TMDO_Module_Detector' ) ) {
|
|
try {
|
|
$detected = TMDO_Module_Detector::detect_all( true );
|
|
foreach ( $detected as $r ) {
|
|
if ( ! empty( $r['available'] ) && 'enable' === ( $r['recommendation'] ?? '' ) ) {
|
|
++$module_suggestions_count;
|
|
}
|
|
}
|
|
} catch ( Throwable $e ) {
|
|
// phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- detector failure must not break health check.
|
|
error_log( '[WPDO] Module detector exception in health cron: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
|
}
|
|
}
|
|
|
|
// 3. Audit log entry.
|
|
if ( class_exists( 'TMDO_Logger' ) ) {
|
|
TMDO_Logger::info(
|
|
'health_check_daily',
|
|
array(
|
|
'critical' => $critical_count,
|
|
'recommended' => $recommended_count,
|
|
'duration_ms' => $summary['duration_ms'],
|
|
'autoload_kb' => (int) round( $autoload / 1024 ),
|
|
'conflicts' => (int) ( $conflict['total'] ?? 0 ),
|
|
'module_suggestions_count' => $module_suggestions_count,
|
|
)
|
|
);
|
|
}
|
|
|
|
// 4. Fire action for downstream subscribers (v2.4.0 email notifier).
|
|
if ( $critical_count > 0 ) {
|
|
do_action( 'wpdo/health_alert_critical', $summary );
|
|
} else {
|
|
do_action( 'wpdo/health_check_passed', $summary );
|
|
}
|
|
|
|
return array(
|
|
'ok' => true,
|
|
'summary' => $summary,
|
|
'critical_count' => $critical_count,
|
|
'recommended_count' => $recommended_count,
|
|
'ts' => $summary['ran_at'],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Read most recent run (for SOP runbook + Doctor tab streak counter).
|
|
*
|
|
* @return array|null
|
|
*/
|
|
public static function get_last_run(): ?array {
|
|
$v = get_option( self::OPTION_LAST_RUN );
|
|
return is_array( $v ) ? $v : null;
|
|
}
|
|
|
|
/**
|
|
* Compute consecutive green days from audit log (best-effort for SOP UI).
|
|
*
|
|
* @return int
|
|
*/
|
|
public static function consecutive_green_days(): int {
|
|
$last = self::get_last_run();
|
|
if ( null === $last ) {
|
|
return 0;
|
|
}
|
|
// If today's run is critical, streak = 0.
|
|
if ( ( $last['critical_count'] ?? 0 ) > 0 ) {
|
|
return 0;
|
|
}
|
|
// Otherwise approximate via TMDO_Logger — count distinct days with health_check_daily and 0 critical.
|
|
// Conservative best-effort: just check today's run is green = 1 day.
|
|
return 1;
|
|
}
|
|
|
|
// ─── private helpers ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Run all 7 Site Health tests directly (without the WP Site Health UI loop).
|
|
*
|
|
* @return array<string,array> Test slug → result array.
|
|
*/
|
|
private static function run_site_health_tests(): array {
|
|
$out = array();
|
|
if ( ! class_exists( 'TMDO_Site_Health' ) ) {
|
|
return $out;
|
|
}
|
|
$tests = array(
|
|
'wpdo_schema_drift' => 'check_schema_drift',
|
|
'wpdo_error_budget' => 'check_error_budget',
|
|
'wpdo_hook_conflicts' => 'check_hook_conflicts',
|
|
'wpdo_autoload_bloat' => 'check_autoload_bloat',
|
|
'wpdo_postmeta_explosion' => 'check_postmeta_explosion',
|
|
'wpdo_orphan_zone_rows' => 'check_orphan_zone_rows',
|
|
'wpdo_missing_snapshot' => 'check_missing_snapshot',
|
|
);
|
|
foreach ( $tests as $slug => $cb ) {
|
|
try {
|
|
$result = call_user_func( array( 'TMDO_Site_Health', $cb ) );
|
|
if ( is_array( $result ) ) {
|
|
$out[ $slug ] = array(
|
|
'status' => (string) ( $result['status'] ?? 'good' ),
|
|
'severity' => (string) ( $result['severity'] ?? 'good' ),
|
|
'label' => (string) ( $result['label'] ?? $slug ),
|
|
'description' => wp_strip_all_tags( (string) ( $result['description'] ?? '' ) ),
|
|
);
|
|
}
|
|
} catch ( Throwable $e ) {
|
|
$out[ $slug ] = array(
|
|
'status' => 'critical',
|
|
'severity' => 'critical',
|
|
'label' => $slug,
|
|
'description' => 'test threw: ' . $e->getMessage(),
|
|
);
|
|
}
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* Return a conflict summary from TMDO_Conflict_Monitor.
|
|
*
|
|
* @return array {total:int, hook_overlap:int, uaepg_overlap:int}
|
|
*/
|
|
private static function summarize_conflicts(): array {
|
|
if ( ! class_exists( 'TMDO_Conflict_Monitor' ) ) {
|
|
return array(
|
|
'total' => 0,
|
|
'hook_overlap' => 0,
|
|
'uaepg_overlap' => 0,
|
|
);
|
|
}
|
|
$summary = TMDO_Conflict_Monitor::get_summary();
|
|
return array(
|
|
'total' => (int) ( $summary['total'] ?? 0 ),
|
|
'hook_overlap' => (int) ( $summary['hook_overlap'] ?? 0 ),
|
|
'uaepg_overlap' => (int) ( $summary['uaepg_overlap'] ?? 0 ),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Per-module shadow_diffs ratio (only for modules currently in `verify`).
|
|
* Reads wp_wpdo_shadow_diffs and bucket-counts by entity_type.
|
|
*
|
|
* @return array<string,array>
|
|
*/
|
|
private static function summarize_shadow_diffs(): array {
|
|
global $wpdb;
|
|
$out = array();
|
|
$table = $wpdb->prefix . 'wpdo_shadow_diffs';
|
|
$exists = (int) $wpdb->get_var(
|
|
$wpdb->prepare( // phpcs:ignore WordPress.DB
|
|
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
|
|
$table
|
|
)
|
|
);
|
|
if ( 0 === $exists ) {
|
|
return $out;
|
|
}
|
|
$rows = $wpdb->get_results( "SELECT entity_type, COUNT(*) AS cnt FROM `{$table}` WHERE ts >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR) GROUP BY entity_type", ARRAY_A ); // phpcs:ignore WordPress.DB
|
|
if ( is_array( $rows ) ) {
|
|
foreach ( $rows as $r ) {
|
|
$out[ (string) $r['entity_type'] ] = array(
|
|
'diffs_24h' => (int) $r['cnt'],
|
|
);
|
|
}
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* Return the total byte size of autoloaded options.
|
|
*
|
|
* @return int Bytes in autoloaded options.
|
|
*/
|
|
private static function measure_autoload_size(): int {
|
|
global $wpdb;
|
|
return (int) $wpdb->get_var( "SELECT COALESCE(SUM(LENGTH(option_value)),0) FROM `{$wpdb->options}` WHERE autoload = 'yes'" ); // phpcs:ignore WordPress.DB
|
|
}
|
|
|
|
/**
|
|
* Return the slug of the first critical test result, or null if none.
|
|
*
|
|
* @param array $results Site Health test results map.
|
|
* @return string|null Slug of first critical test, or null.
|
|
*/
|
|
private static function first_critical_test( array $results ): ?string {
|
|
foreach ( $results as $slug => $r ) {
|
|
if ( 'critical' === ( $r['status'] ?? '' ) ) {
|
|
return $slug;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
}
|