chore: initial snapshot of 2meet-data-optimizer v0.1.0
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
<?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
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Monthly_Summary — 30-day rollup report (v2.4.0 M11).
|
||||
*
|
||||
* Generates a monthly executive summary covering:
|
||||
* - Health success / fail / rate-limit ratios over last 30 days
|
||||
* - Zone size growth (deltas from 30 days ago snapshot if available)
|
||||
* - Per-module FSM trajectory (who promoted, who rolled back)
|
||||
* - Snapshot retention overview (count, oldest, newest)
|
||||
*
|
||||
* Hooks the existing `wpdo_health_snapshot_monthly` action (Core line ~489)
|
||||
* so it runs once per month at 03:00 UTC on the 1st. Output:
|
||||
* 1. Persisted to wp_wpdo_audit (op='monthly_summary').
|
||||
* 2. Stored as wp_options.wpdo_monthly_summary_latest (autoload=no).
|
||||
* 3. Rendered on the admin Doctor tab "📅 Monthly Summary" sub-section.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monthly summary aggregator.
|
||||
*/
|
||||
class TMDO_Monthly_Summary {
|
||||
|
||||
public const OPTION_LATEST = 'wpdo_monthly_summary_latest';
|
||||
|
||||
/**
|
||||
* Hook into Core's existing monthly cron.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'wpdo_health_snapshot_monthly', array( __CLASS__, 'generate' ), 20 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build + persist the monthly summary.
|
||||
*
|
||||
* @return array Summary structure.
|
||||
*/
|
||||
public static function generate(): array {
|
||||
$started_at = microtime( true );
|
||||
$summary = array(
|
||||
'period_start' => gmdate( 'Y-m-d H:i:s', strtotime( '-30 days' ) ),
|
||||
'period_end' => gmdate( 'Y-m-d H:i:s' ),
|
||||
'health' => self::aggregate_health(),
|
||||
'fsm_trajectory' => self::aggregate_fsm(),
|
||||
'snapshots' => self::aggregate_snapshots(),
|
||||
'zone_growth' => self::aggregate_zone_growth(),
|
||||
'autoload_size' => self::measure_autoload(),
|
||||
'duration_ms' => 0,
|
||||
'generated_at' => gmdate( 'Y-m-d H:i:s' ),
|
||||
);
|
||||
$summary['duration_ms'] = (int) round( ( microtime( true ) - $started_at ) * 1000 );
|
||||
|
||||
update_option( self::OPTION_LATEST, $summary, false );
|
||||
|
||||
// v2.5.0 M14: archive into history (max 12 entries).
|
||||
$history = (array) get_option( 'wpdo_monthly_summary_history', array() );
|
||||
array_unshift( $history, $summary );
|
||||
$history = array_slice( $history, 0, 12 );
|
||||
update_option( 'wpdo_monthly_summary_history', $history, false );
|
||||
|
||||
if ( class_exists( 'TMDO_Logger' ) ) {
|
||||
TMDO_Logger::info(
|
||||
'monthly_summary',
|
||||
array(
|
||||
'health_success' => (int) ( $summary['health']['success'] ?? 0 ),
|
||||
'health_critical' => (int) ( $summary['health']['critical'] ?? 0 ),
|
||||
'fsm_promotions' => count( $summary['fsm_trajectory']['promotions'] ?? array() ),
|
||||
'fsm_rollbacks' => count( $summary['fsm_trajectory']['rollbacks'] ?? array() ),
|
||||
'snapshots_total' => (int) ( $summary['snapshots']['total'] ?? 0 ),
|
||||
'autoload_kb' => (int) round( ( $summary['autoload_size'] ?? 0 ) / 1024 ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
do_action( 'wpdo/monthly_summary_generated', $summary );
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the latest summary (from wp_options).
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public static function get_latest(): ?array {
|
||||
$v = get_option( self::OPTION_LATEST );
|
||||
return is_array( $v ) ? $v : null;
|
||||
}
|
||||
|
||||
// ─── private ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Aggregate health-check audit log entries from last 30 days.
|
||||
*
|
||||
* @return array {success:int, recommended:int, critical:int, total:int}
|
||||
*/
|
||||
private static function aggregate_health(): array {
|
||||
global $wpdb;
|
||||
$out = array(
|
||||
'success' => 0,
|
||||
'recommended' => 0,
|
||||
'critical' => 0,
|
||||
'total' => 0,
|
||||
);
|
||||
|
||||
// wp_wpdo_audit may not exist on early v1.x installs; check first.
|
||||
$audit = $wpdb->prefix . 'wpdo_audit';
|
||||
$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',
|
||||
$audit
|
||||
)
|
||||
);
|
||||
if ( 0 === $exists ) {
|
||||
return $out;
|
||||
}
|
||||
// Health check audit rows have op='health_check_daily'.
|
||||
$rows = (array) $wpdb->get_results( "SELECT * FROM `{$audit}` WHERE op = 'health_check_daily' AND ts >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 30 DAY)", ARRAY_A ); // phpcs:ignore WordPress.DB
|
||||
foreach ( $rows as $r ) {
|
||||
$ctx = isset( $r['value_after'] ) ? json_decode( (string) $r['value_after'], true ) : null;
|
||||
if ( ! is_array( $ctx ) ) {
|
||||
continue;
|
||||
}
|
||||
$crit = (int) ( $ctx['critical'] ?? 0 );
|
||||
$rec = (int) ( $ctx['recommended'] ?? 0 );
|
||||
++$out['total'];
|
||||
if ( $crit > 0 ) {
|
||||
++$out['critical'];
|
||||
} elseif ( $rec > 0 ) {
|
||||
++$out['recommended'];
|
||||
} else {
|
||||
++$out['success'];
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort FSM trajectory: which modules changed state in the last 30
|
||||
* days. Reads `wpdo_fsm_state_entered` + current state.
|
||||
*
|
||||
* @return array {promotions:array, rollbacks:array, stationary:array}
|
||||
*/
|
||||
private static function aggregate_fsm(): array {
|
||||
$out = array(
|
||||
'promotions' => array(),
|
||||
'rollbacks' => array(),
|
||||
'stationary' => array(),
|
||||
);
|
||||
if ( ! class_exists( 'TMDO_Feature_Flags' ) ) {
|
||||
return $out;
|
||||
}
|
||||
$entered = (array) get_option( 'wpdo_fsm_state_entered', array() );
|
||||
$now = time();
|
||||
foreach ( TMDO_Feature_Flags::all() as $module => $state ) {
|
||||
$ts = isset( $entered[ $module ]['entered_at'] ) ? strtotime( (string) $entered[ $module ]['entered_at'] . ' UTC' ) : 0;
|
||||
$age_days = $ts > 0 ? (int) floor( ( $now - $ts ) / DAY_IN_SECONDS ) : null;
|
||||
|
||||
if ( $ts > 0 && ( $now - $ts ) <= ( 30 * DAY_IN_SECONDS ) ) {
|
||||
// Recently changed — bucket by direction.
|
||||
if ( 'idle' === $state ) {
|
||||
$out['rollbacks'][ $module ] = array(
|
||||
'state' => $state,
|
||||
'days_in_state' => $age_days,
|
||||
);
|
||||
} else {
|
||||
$out['promotions'][ $module ] = array(
|
||||
'state' => $state,
|
||||
'days_in_state' => $age_days,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$out['stationary'][ $module ] = array(
|
||||
'state' => $state,
|
||||
'days_in_state' => $age_days,
|
||||
);
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate snapshot retention stats from the snapshots table.
|
||||
*
|
||||
* @return array {total:int, oldest:string|null, newest:string|null, total_bytes:int}
|
||||
*/
|
||||
private static function aggregate_snapshots(): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'wpdo_snapshots';
|
||||
$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 array(
|
||||
'total' => 0,
|
||||
'oldest' => null,
|
||||
'newest' => null,
|
||||
'total_bytes' => 0,
|
||||
);
|
||||
}
|
||||
$row = $wpdb->get_row( "SELECT COUNT(*) AS c, MIN(created_at) AS oldest, MAX(created_at) AS newest, COALESCE(SUM(size_bytes),0) AS bytes FROM `{$table}`", ARRAY_A ); // phpcs:ignore WordPress.DB
|
||||
return array(
|
||||
'total' => (int) ( $row['c'] ?? 0 ),
|
||||
'oldest' => isset( $row['oldest'] ) ? (string) $row['oldest'] : null,
|
||||
'newest' => isset( $row['newest'] ) ? (string) $row['newest'] : null,
|
||||
'total_bytes' => (int) ( $row['bytes'] ?? 0 ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-zone row count snapshot. Compares to previous month's value when
|
||||
* available (delta_rows positive = growth).
|
||||
*
|
||||
* @return array<string,array>
|
||||
*/
|
||||
private static function aggregate_zone_growth(): array {
|
||||
// Use historical site_metrics when available — avoids live COUNT(*) on large tables
|
||||
// and provides a 30-day delta (current − oldest snapshot).
|
||||
if ( class_exists( 'TMDO_Site_Metrics_Collector' ) ) {
|
||||
$latest = TMDO_Site_Metrics_Collector::get_latest_snapshot();
|
||||
if ( ! empty( $latest ) ) {
|
||||
// Get oldest snapshot within 30 days for delta calculation.
|
||||
$keys_of_interest = array( 'eav.postmeta_rows', 'flat.hot_rows', 'flat.cold_rows', 'flat.warm_rows', 'custom_tables.total_rows' );
|
||||
$out = array();
|
||||
foreach ( $keys_of_interest as $mk ) {
|
||||
if ( ! isset( $latest[ $mk ] ) ) {
|
||||
continue;
|
||||
}
|
||||
$history = TMDO_Site_Metrics_Collector::get_history( $mk, 30 );
|
||||
$oldest = ! empty( $history ) ? (int) $history[0]['value'] : null;
|
||||
$current = (int) $latest[ $mk ];
|
||||
$out[ $mk ] = array(
|
||||
'rows' => $current,
|
||||
'delta_rows' => null !== $oldest ? $current - $oldest : null,
|
||||
);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: live COUNT(*) for warm + archive tables (pre-v2.6.2 installs).
|
||||
global $wpdb;
|
||||
$out = array();
|
||||
$keys = array( 'wpdo_warm', 'wpdo_archive' );
|
||||
foreach ( $keys as $slug ) {
|
||||
$table = $wpdb->prefix . $slug;
|
||||
$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 ) {
|
||||
continue;
|
||||
}
|
||||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB
|
||||
$out[ $slug ] = array(
|
||||
'rows' => $count,
|
||||
'delta_rows' => null,
|
||||
);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the total byte size of autoloaded options.
|
||||
*
|
||||
* @return int Bytes in autoloaded options.
|
||||
*/
|
||||
private static function measure_autoload(): 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform diagnostic: raw meta inspection for site health
|
||||
/**
|
||||
* TMDO_Site_Health — WordPress Site Health integration (v2.2.0 M3).
|
||||
*
|
||||
* Registers 7 tests under Tools → Site Health → Status:
|
||||
* 1. wpdo_schema_drift (critical)
|
||||
* 2. wpdo_error_budget (recommended)
|
||||
* 3. wpdo_hook_conflicts (recommended)
|
||||
* 4. wpdo_autoload_bloat (recommended)
|
||||
* 5. wpdo_postmeta_explosion (recommended)
|
||||
* 6. wpdo_orphan_zone_rows (recommended)
|
||||
* 7. wpdo_missing_snapshot (critical)
|
||||
*
|
||||
* Each test result is cached for 5 minutes to keep Site Health responsive.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Site Health test suite. Hook on `init` admin context.
|
||||
*/
|
||||
class TMDO_Site_Health {
|
||||
|
||||
/** Transient cache TTL for individual checks. */
|
||||
private const CACHE_TTL = 300;
|
||||
|
||||
/** Test name → callable suffix mapping. */
|
||||
private const 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',
|
||||
);
|
||||
|
||||
/**
|
||||
* Hook into Site Health.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_filter( 'site_status_tests', array( __CLASS__, 'register_tests' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the 7 tests with WP Site Health.
|
||||
*
|
||||
* @param array $tests Existing tests.
|
||||
* @return array
|
||||
*/
|
||||
public static function register_tests( array $tests ): array {
|
||||
foreach ( self::TESTS as $key => $cb_suffix ) {
|
||||
$tests['direct'][ $key ] = array(
|
||||
'label' => self::label_for( $key ),
|
||||
'test' => array( __CLASS__, $cb_suffix ),
|
||||
);
|
||||
}
|
||||
return $tests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable label for each test.
|
||||
*
|
||||
* @param string $key Test slug.
|
||||
* @return string
|
||||
*/
|
||||
private static function label_for( string $key ): string {
|
||||
$map = array(
|
||||
'wpdo_schema_drift' => __( 'WPDO schema drift', '2meet-data-optimizer' ),
|
||||
'wpdo_error_budget' => __( 'WPDO error budget', '2meet-data-optimizer' ),
|
||||
'wpdo_hook_conflicts' => __( 'WPDO hook conflicts', '2meet-data-optimizer' ),
|
||||
'wpdo_autoload_bloat' => __( 'WPDO autoload bloat', '2meet-data-optimizer' ),
|
||||
'wpdo_postmeta_explosion' => __( 'WPDO postmeta explosion', '2meet-data-optimizer' ),
|
||||
'wpdo_orphan_zone_rows' => __( 'WPDO orphan zone rows', '2meet-data-optimizer' ),
|
||||
'wpdo_missing_snapshot' => __( 'WPDO missing snapshot', '2meet-data-optimizer' ),
|
||||
);
|
||||
return $map[ $key ] ?? $key;
|
||||
}
|
||||
|
||||
// ─── Test 1: Schema drift ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verify all expected v2 tables exist.
|
||||
*
|
||||
* @return array Site Health result.
|
||||
*/
|
||||
public static function check_schema_drift(): array {
|
||||
$result = self::cached(
|
||||
'wpdo_sh_schema_drift',
|
||||
static function () {
|
||||
if ( ! class_exists( 'TMDO_Installer' ) ) {
|
||||
return self::pass( __( 'WPDO installer not loaded.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
if ( ! method_exists( 'TMDO_Installer', 'v2_tables_status' ) ) {
|
||||
return self::pass( __( 'Schema check unavailable on this version.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
$status = TMDO_Installer::v2_tables_status();
|
||||
$missing = array_keys( array_filter( $status, static fn( $exists ) => ! $exists ) );
|
||||
if ( empty( $missing ) ) {
|
||||
return self::pass( __( 'All WPDO v2 tables exist.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
return self::fail(
|
||||
__( 'WPDO v2 tables missing', '2meet-data-optimizer' ),
|
||||
sprintf(
|
||||
/* translators: %s: comma-separated list of missing table names */
|
||||
__( 'Missing tables: %s. Run wp wpdo install or re-activate the plugin.', '2meet-data-optimizer' ),
|
||||
implode( ', ', $missing )
|
||||
),
|
||||
'critical'
|
||||
);
|
||||
}
|
||||
);
|
||||
return self::wrap( 'wpdo_schema_drift', $result );
|
||||
}
|
||||
|
||||
// ─── Test 2: Error budget (last 7 days) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Count errors in wp_wpdo_errors over the last 7 days.
|
||||
*
|
||||
* @return array Site Health result.
|
||||
*/
|
||||
public static function check_error_budget(): array {
|
||||
$result = self::cached(
|
||||
'wpdo_sh_error_budget',
|
||||
static function () {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'wpdo_errors';
|
||||
$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 self::pass( __( 'Error log not present (yet) — clean.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
$threshold = (int) apply_filters( 'wpdo/site_health/error_budget_threshold', 100 );
|
||||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE created_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 7 DAY)" ); // phpcs:ignore WordPress.DB
|
||||
if ( $count <= $threshold ) {
|
||||
return self::pass(
|
||||
sprintf(
|
||||
/* translators: %d: error count */
|
||||
__( '%d errors in the last 7 days (within budget).', '2meet-data-optimizer' ),
|
||||
$count
|
||||
)
|
||||
);
|
||||
}
|
||||
return self::fail(
|
||||
__( 'WPDO error budget exceeded', '2meet-data-optimizer' ),
|
||||
sprintf(
|
||||
/* translators: 1: error count, 2: threshold */
|
||||
__( '%1$d errors in the last 7 days (threshold %2$d). Inspect under Tools → WP Data Optimizer → Logs.', '2meet-data-optimizer' ),
|
||||
$count,
|
||||
$threshold
|
||||
),
|
||||
'recommended'
|
||||
);
|
||||
}
|
||||
);
|
||||
return self::wrap( 'wpdo_error_budget', $result );
|
||||
}
|
||||
|
||||
// ─── Test 3: Hook conflicts ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check for interceptor or hook conflicts via TMDO_Conflict_Monitor.
|
||||
*
|
||||
* @return array Site Health result.
|
||||
*/
|
||||
public static function check_hook_conflicts(): array {
|
||||
$result = self::cached(
|
||||
'wpdo_sh_hook_conflicts',
|
||||
static function () {
|
||||
if ( ! class_exists( 'TMDO_Conflict_Monitor' ) ) {
|
||||
return self::pass( __( 'Conflict monitor not loaded.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
$summary = TMDO_Conflict_Monitor::get_summary();
|
||||
$total = (int) ( $summary['total'] ?? 0 );
|
||||
if ( 0 === $total ) {
|
||||
return self::pass( __( 'No interceptor / hook conflicts detected.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
return self::fail(
|
||||
__( 'WPDO hook conflicts detected', '2meet-data-optimizer' ),
|
||||
sprintf(
|
||||
/* translators: %d: number of conflicts */
|
||||
__( '%d interceptor/hook conflicts detected. Run wp wpdo conflict-scan for details.', '2meet-data-optimizer' ),
|
||||
$total
|
||||
),
|
||||
'recommended'
|
||||
);
|
||||
}
|
||||
);
|
||||
return self::wrap( 'wpdo_hook_conflicts', $result );
|
||||
}
|
||||
|
||||
// ─── Test 4: Autoload bloat ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check total autoloaded options size against a configurable threshold.
|
||||
*
|
||||
* @return array Site Health result.
|
||||
*/
|
||||
public static function check_autoload_bloat(): array {
|
||||
$result = self::cached(
|
||||
'wpdo_sh_autoload_bloat',
|
||||
static function () {
|
||||
global $wpdb;
|
||||
$threshold_mb = (int) apply_filters( 'wpdo/site_health/autoload_threshold_mb', 5 );
|
||||
$bytes = (int) $wpdb->get_var( "SELECT SUM(LENGTH(option_value)) FROM `{$wpdb->options}` WHERE autoload = 'yes'" ); // phpcs:ignore WordPress.DB
|
||||
$mb = $bytes / 1024 / 1024;
|
||||
if ( $mb < $threshold_mb ) {
|
||||
return self::pass(
|
||||
sprintf(
|
||||
/* translators: 1: actual size in MB */
|
||||
__( 'Autoload total %1$0.2f MB (under %2$d MB threshold).', '2meet-data-optimizer' ),
|
||||
$mb,
|
||||
$threshold_mb
|
||||
)
|
||||
);
|
||||
}
|
||||
return self::fail(
|
||||
__( 'Autoload size large', '2meet-data-optimizer' ),
|
||||
sprintf(
|
||||
/* translators: 1: actual size in MB, 2: threshold in MB */
|
||||
__( 'Autoload total %1$0.2f MB exceeds %2$d MB threshold. Consider migrating large autoloaded options to wp_wpdo_uni_options.', '2meet-data-optimizer' ),
|
||||
$mb,
|
||||
$threshold_mb
|
||||
),
|
||||
'recommended'
|
||||
);
|
||||
}
|
||||
);
|
||||
return self::wrap( 'wpdo_autoload_bloat', $result );
|
||||
}
|
||||
|
||||
// ─── Test 5: Postmeta explosion ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check whether wp_postmeta row count exceeds the explosion threshold.
|
||||
*
|
||||
* @return array Site Health result.
|
||||
*/
|
||||
public static function check_postmeta_explosion(): array {
|
||||
$result = self::cached(
|
||||
'wpdo_sh_postmeta_explosion',
|
||||
static function () {
|
||||
global $wpdb;
|
||||
$threshold = (int) apply_filters( 'wpdo/site_health/postmeta_threshold', 5_000_000 );
|
||||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->postmeta}`" ); // phpcs:ignore WordPress.DB
|
||||
if ( $count < $threshold ) {
|
||||
return self::pass(
|
||||
sprintf(
|
||||
/* translators: %s: row count */
|
||||
__( 'wp_postmeta has %s rows (under threshold).', '2meet-data-optimizer' ),
|
||||
number_format_i18n( $count )
|
||||
)
|
||||
);
|
||||
}
|
||||
// Also check if any zone module is active.
|
||||
$any_active = false;
|
||||
if ( class_exists( 'TMDO_Feature_Flags' ) ) {
|
||||
foreach ( TMDO_Feature_Flags::all() as $state ) {
|
||||
if ( 'idle' !== $state ) {
|
||||
$any_active = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$severity = $any_active ? 'recommended' : 'critical';
|
||||
return self::fail(
|
||||
__( 'wp_postmeta is large', '2meet-data-optimizer' ),
|
||||
sprintf(
|
||||
/* translators: %s: row count */
|
||||
__( 'wp_postmeta has %s rows. Run the Classifier to identify candidates for migration into Hot/Warm/Cold/Archive zones.', '2meet-data-optimizer' ),
|
||||
number_format_i18n( $count )
|
||||
),
|
||||
$severity
|
||||
);
|
||||
}
|
||||
);
|
||||
return self::wrap( 'wpdo_postmeta_explosion', $result );
|
||||
}
|
||||
|
||||
// ─── Test 6: Orphan zone rows ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detect zone table rows that belong to modules currently in idle state.
|
||||
*
|
||||
* @return array Site Health result.
|
||||
*/
|
||||
public static function check_orphan_zone_rows(): array {
|
||||
$result = self::cached(
|
||||
'wpdo_sh_orphan_zone',
|
||||
static function () {
|
||||
global $wpdb;
|
||||
if ( ! class_exists( 'TMDO_Feature_Flags' ) ) {
|
||||
return self::pass( __( 'Feature flags not loaded.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
$idle_modules = array_keys( array_filter( TMDO_Feature_Flags::all(), static fn( $state ) => 'idle' === $state ) );
|
||||
$orphans = array();
|
||||
foreach ( $idle_modules as $module ) {
|
||||
// Best-effort: zone tables for hot_*/cold_* are dynamically named.
|
||||
$candidates = array(
|
||||
$wpdb->prefix . 'wpdo_warm',
|
||||
$wpdb->prefix . 'wpdo_archive',
|
||||
);
|
||||
foreach ( $candidates as $table ) {
|
||||
$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 ) {
|
||||
continue;
|
||||
}
|
||||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB
|
||||
if ( $count > 0 ) {
|
||||
$orphans[ $table ] = $count;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ( empty( $orphans ) ) {
|
||||
return self::pass( __( 'No orphan zone rows from idle modules.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
$lines = array();
|
||||
foreach ( $orphans as $t => $n ) {
|
||||
$lines[] = sprintf( '%s (%s rows)', $t, number_format_i18n( $n ) );
|
||||
}
|
||||
return self::fail(
|
||||
__( 'Orphan zone rows detected', '2meet-data-optimizer' ),
|
||||
sprintf(
|
||||
/* translators: %s: list of tables and row counts */
|
||||
__( 'Modules in idle state but zone tables still hold data: %s. These rows are typically cleanup leftovers — verify before truncating.', '2meet-data-optimizer' ),
|
||||
implode( ', ', $lines )
|
||||
),
|
||||
'recommended'
|
||||
);
|
||||
}
|
||||
);
|
||||
return self::wrap( 'wpdo_orphan_zone_rows', $result );
|
||||
}
|
||||
|
||||
// ─── Test 7: Missing snapshot ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verify that a recent snapshot exists when modules are in risk states.
|
||||
*
|
||||
* @return array Site Health result.
|
||||
*/
|
||||
public static function check_missing_snapshot(): array {
|
||||
$result = self::cached(
|
||||
'wpdo_sh_missing_snapshot',
|
||||
static function () {
|
||||
global $wpdb;
|
||||
if ( ! class_exists( 'TMDO_Snapshot_Manager' ) || ! class_exists( 'TMDO_Feature_Flags' ) ) {
|
||||
return self::pass( __( 'Snapshot system not yet available.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
// Risk only applies to modules in cutover/cleanup/complete (data is in custom tables).
|
||||
$risk_modules = array_keys(
|
||||
array_filter(
|
||||
TMDO_Feature_Flags::all(),
|
||||
static fn( $state ) => in_array( $state, array( 'cutover', 'cleanup', 'complete' ), true )
|
||||
)
|
||||
);
|
||||
if ( empty( $risk_modules ) ) {
|
||||
return self::pass( __( 'No modules in risk state — snapshot not required.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
$snap_table = $wpdb->prefix . TMDO_Snapshot_Manager::TABLE_SLUG;
|
||||
$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',
|
||||
$snap_table
|
||||
)
|
||||
);
|
||||
if ( 0 === $exists ) {
|
||||
return self::fail(
|
||||
__( 'Snapshot table missing', '2meet-data-optimizer' ),
|
||||
__( 'Snapshot system table not present. Run wp wpdo install.', '2meet-data-optimizer' ),
|
||||
'critical'
|
||||
);
|
||||
}
|
||||
$days = (int) apply_filters( 'wpdo/site_health/snapshot_max_age_days', 7 );
|
||||
$recent = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM `{$snap_table}` WHERE created_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$snap_table} is $wpdb->prefix + TABLE_SLUG constant (no user input)
|
||||
$days
|
||||
)
|
||||
);
|
||||
if ( $recent > 0 ) {
|
||||
return self::pass(
|
||||
sprintf(
|
||||
/* translators: 1: count, 2: days */
|
||||
__( 'Found %1$d snapshot(s) within the last %2$d days.', '2meet-data-optimizer' ),
|
||||
$recent,
|
||||
$days
|
||||
)
|
||||
);
|
||||
}
|
||||
return self::fail(
|
||||
__( 'No recent WPDO snapshot', '2meet-data-optimizer' ),
|
||||
sprintf(
|
||||
/* translators: 1: comma-separated module names, 2: days */
|
||||
__( 'Modules in risk state (%1$s) but no snapshot in last %2$d days. Run: wp wpdo snapshot create --trigger=manual --notes="catch-up safety net"', '2meet-data-optimizer' ),
|
||||
implode( ', ', $risk_modules ),
|
||||
$days
|
||||
),
|
||||
'critical'
|
||||
);
|
||||
}
|
||||
);
|
||||
return self::wrap( 'wpdo_missing_snapshot', $result );
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wrap a raw check result with required Site Health envelope fields.
|
||||
*
|
||||
* @param string $key Test slug.
|
||||
* @param array $result Raw result with keys label, status, description, severity.
|
||||
* @return array
|
||||
*/
|
||||
private static function wrap( string $key, array $result ): array {
|
||||
$result['test'] = $key;
|
||||
$result['badge'] = array(
|
||||
'label' => 'WPDO',
|
||||
'color' => 'blue',
|
||||
);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache a callable's return value via transient.
|
||||
*
|
||||
* @param string $key Transient key.
|
||||
* @param callable $producer Callable returning result array.
|
||||
* @return array
|
||||
*/
|
||||
private static function cached( string $key, callable $producer ): array {
|
||||
$cached = get_transient( $key );
|
||||
if ( is_array( $cached ) ) {
|
||||
return $cached;
|
||||
}
|
||||
$result = $producer();
|
||||
set_transient( $key, $result, self::CACHE_TTL );
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a "pass" result envelope.
|
||||
*
|
||||
* @param string $description Body text.
|
||||
* @return array
|
||||
*/
|
||||
private static function pass( string $description ): array {
|
||||
return array(
|
||||
'label' => __( 'WPDO check passed', '2meet-data-optimizer' ),
|
||||
'status' => 'good',
|
||||
'description' => '<p>' . esc_html( $description ) . '</p>',
|
||||
'severity' => 'good',
|
||||
'actions' => '',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fail / warning result envelope.
|
||||
*
|
||||
* @param string $label Test heading.
|
||||
* @param string $description Body.
|
||||
* @param string $severity 'critical' | 'recommended'.
|
||||
* @return array
|
||||
*/
|
||||
private static function fail( string $label, string $description, string $severity ): array {
|
||||
return array(
|
||||
'label' => $label,
|
||||
'status' => 'critical' === $severity ? 'critical' : 'recommended',
|
||||
'description' => '<p>' . esc_html( $description ) . '</p>',
|
||||
'severity' => $severity,
|
||||
'actions' => '<p><a href="' . esc_url( admin_url( 'tools.php?page=wp-data-optimizer' ) ) . '">' . esc_html__( 'Open WPDO admin', '2meet-data-optimizer' ) . '</a></p>',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Site_Metrics_Collector — daily EAV health snapshot writer.
|
||||
*
|
||||
* Writes structured rows to `wpdo_site_metrics` once per day (via
|
||||
* `wpdo_collect_site_metrics` cron action, scheduled at 05:00 UTC).
|
||||
*
|
||||
* Metric keys written per run:
|
||||
* eav.postmeta_rows / eav.usermeta_rows / eav.termmeta_rows / eav.commentmeta_rows
|
||||
* flat.hot_rows / flat.cold_rows / flat.warm_rows
|
||||
* custom_tables.total_rows / custom_tables.table_count
|
||||
* errors.last_24h / shadow_diffs.last_24h
|
||||
*
|
||||
* Monthly Summary (`TMDO_Monthly_Summary`) reads these rows for the
|
||||
* `zone_growth` section instead of querying live tables, so the monthly
|
||||
* rollup is fast even on large databases.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.6.2
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects site-wide EAV health metrics and writes to wpdo_site_metrics.
|
||||
*/
|
||||
class TMDO_Site_Metrics_Collector {
|
||||
|
||||
/**
|
||||
* Cron hook name.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const CRON_HOOK = 'wpdo_collect_site_metrics';
|
||||
|
||||
/**
|
||||
* How many days of daily rows to retain before pruning.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private const RETENTION_DAYS = 90;
|
||||
|
||||
/**
|
||||
* Register the cron handler and return the instance for chaining.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( self::CRON_HOOK, array( __CLASS__, 'collect' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all site metrics and persist them to wpdo_site_metrics.
|
||||
*
|
||||
* Called by the daily cron. Safe to call manually (e.g., via WP-CLI).
|
||||
*
|
||||
* @param bool $dry_run When true, collect but do not write to the DB.
|
||||
* @return array<string,int> Map of metric_key => metric_value collected.
|
||||
*/
|
||||
public static function collect( bool $dry_run = false ): array {
|
||||
global $wpdb;
|
||||
|
||||
$now = TMDO_DB::now();
|
||||
$metrics = array();
|
||||
|
||||
// ── EAV row counts ────────────────────────────────────────────────
|
||||
$eav_tables = array(
|
||||
'eav.postmeta_rows' => $wpdb->postmeta,
|
||||
'eav.usermeta_rows' => $wpdb->usermeta,
|
||||
'eav.termmeta_rows' => $wpdb->termmeta,
|
||||
'eav.commentmeta_rows' => $wpdb->commentmeta,
|
||||
);
|
||||
foreach ( $eav_tables as $key => $table ) {
|
||||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB
|
||||
$metrics[ $key ] = $count;
|
||||
}
|
||||
|
||||
// ── Flat-table row counts (hot + cold dynamic tables, warm) ───────
|
||||
$hot_rows = 0;
|
||||
$cold_rows = 0;
|
||||
|
||||
if ( class_exists( 'TMDO_Schema_Registry' ) ) {
|
||||
$registry = TMDO_Schema_Registry::instance();
|
||||
foreach ( $registry->get_hot_post_types() as $pt ) {
|
||||
$ht = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $pt );
|
||||
$exists = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $ht ) ); // phpcs:ignore WordPress.DB
|
||||
if ( $exists ) {
|
||||
$hot_rows += (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$ht}`" ); // phpcs:ignore WordPress.DB
|
||||
}
|
||||
}
|
||||
foreach ( $registry->get_cold_post_types() as $pt ) {
|
||||
$ct = $wpdb->prefix . 'wpdo_cold_' . sanitize_key( $pt );
|
||||
$exists = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $ct ) ); // phpcs:ignore WordPress.DB
|
||||
if ( $exists ) {
|
||||
$cold_rows += (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$ct}`" ); // phpcs:ignore WordPress.DB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$warm_table = $wpdb->prefix . 'wpdo_warm';
|
||||
$metrics['flat.hot_rows'] = $hot_rows;
|
||||
$metrics['flat.cold_rows'] = $cold_rows;
|
||||
$metrics['flat.warm_rows'] = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$warm_table}`" ); // phpcs:ignore WordPress.DB
|
||||
|
||||
// ── Custom table row counts ───────────────────────────────────────
|
||||
$custom_rows = 0;
|
||||
$custom_count = 0;
|
||||
if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) {
|
||||
foreach ( TMDO_Custom_Table_Registry::instance()->all() as $cfg ) {
|
||||
$tbl = $wpdb->prefix . $cfg['table_name'];
|
||||
$exists = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $tbl ) ); // phpcs:ignore WordPress.DB
|
||||
if ( $exists ) {
|
||||
$custom_rows += (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$tbl}`" ); // phpcs:ignore WordPress.DB
|
||||
++$custom_count;
|
||||
}
|
||||
}
|
||||
}
|
||||
$metrics['custom_tables.total_rows'] = $custom_rows;
|
||||
$metrics['custom_tables.table_count'] = $custom_count;
|
||||
|
||||
// ── Error / shadow-diff activity (last 24 h) ─────────────────────
|
||||
$errors_table = $wpdb->prefix . 'wpdo_errors';
|
||||
$metrics['errors.last_24h'] = (int) $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM `{$errors_table}` WHERE created_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR)" // phpcs:ignore WordPress.DB
|
||||
);
|
||||
|
||||
$shadow_table = $wpdb->prefix . 'wpdo_shadow_diffs';
|
||||
$shadow_exists = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $shadow_table ) ); // phpcs:ignore WordPress.DB
|
||||
$metrics['shadow_diffs.last_24h'] = $shadow_exists
|
||||
? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$shadow_table}` WHERE ts >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR)" ) // phpcs:ignore WordPress.DB
|
||||
: 0;
|
||||
|
||||
if ( $dry_run ) {
|
||||
return $metrics;
|
||||
}
|
||||
|
||||
// ── Persist each metric to wpdo_site_metrics ─────────────────────
|
||||
$dest = TMDO_DB::table( 'wpdo_site_metrics' );
|
||||
foreach ( $metrics as $key => $value ) {
|
||||
$wpdb->insert(
|
||||
$dest,
|
||||
array(
|
||||
'collected_at' => $now,
|
||||
'metric_key' => $key,
|
||||
'metric_value' => $value,
|
||||
'context' => null,
|
||||
),
|
||||
array( '%s', '%s', '%d', '%s' )
|
||||
);
|
||||
}
|
||||
|
||||
// ── Prune old rows beyond retention window ────────────────────────
|
||||
$cutoff = gmdate( 'Y-m-d H:i:s', strtotime( '-' . self::RETENTION_DAYS . ' days' ) );
|
||||
$wpdb->query( $wpdb->prepare( "DELETE FROM {$dest} WHERE collected_at < %s", $cutoff ) ); // phpcs:ignore WordPress.DB
|
||||
|
||||
if ( class_exists( 'TMDO_Logger' ) ) {
|
||||
TMDO_Logger::info(
|
||||
'site_metrics_collected',
|
||||
array(
|
||||
'metric_count' => count( $metrics ),
|
||||
'postmeta_rows' => $metrics['eav.postmeta_rows'] ?? 0,
|
||||
'hot_rows' => $metrics['flat.hot_rows'] ?? 0,
|
||||
'custom_rows' => $metrics['custom_tables.total_rows'] ?? 0,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return $metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent snapshot (latest collected_at timestamp).
|
||||
*
|
||||
* @return array<string,int> metric_key => metric_value, or empty on miss.
|
||||
*/
|
||||
public static function get_latest_snapshot(): array {
|
||||
global $wpdb;
|
||||
$dest = TMDO_DB::table( 'wpdo_site_metrics' );
|
||||
|
||||
$latest_ts = $wpdb->get_var( "SELECT MAX(collected_at) FROM `{$dest}`" ); // phpcs:ignore WordPress.DB
|
||||
if ( ! $latest_ts ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$rows = (array) $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT metric_key, metric_value FROM `{$dest}` WHERE collected_at = %s", // phpcs:ignore WordPress.DB
|
||||
$latest_ts
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$out = array();
|
||||
foreach ( $rows as $r ) {
|
||||
$out[ (string) $r['metric_key'] ] = (int) $r['metric_value'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get daily metric history for a single key over the past N days.
|
||||
*
|
||||
* @param string $metric_key Metric key (e.g. 'eav.postmeta_rows').
|
||||
* @param int $days Number of days of history to return (default 30).
|
||||
* @return array<array{collected_at:string,value:int}> Oldest-first.
|
||||
*/
|
||||
public static function get_history( string $metric_key, int $days = 30 ): array {
|
||||
global $wpdb;
|
||||
$dest = TMDO_DB::table( 'wpdo_site_metrics' );
|
||||
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $dest is a validated table name from TMDO_DB::table().
|
||||
$rows = (array) $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT collected_at, metric_value AS value
|
||||
FROM `{$dest}`
|
||||
WHERE metric_key = %s
|
||||
AND collected_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY)
|
||||
ORDER BY collected_at ASC",
|
||||
$metric_key,
|
||||
$days
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
|
||||
return array_map(
|
||||
static fn( $r ) => array(
|
||||
'collected_at' => (string) $r['collected_at'],
|
||||
'value' => (int) $r['value'],
|
||||
),
|
||||
$rows
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user