Files
2meet-data-optimizer/includes/diagnostic/class-tmdo-health-cron.php
T
wpdev 76c01e44df refactor: 全部 128 個生產檔加入 declare(strict_types=1)(PR-H)
對齊 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
2026-07-31 06:13:33 +08:00

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;
}
}