Files
2meet-data-optimizer/includes/diagnostic/class-tmdo-monthly-summary.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

286 lines
9.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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
*/
declare(strict_types=1);
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
}
}