client = $client; } /** * Register cron callbacks. */ public static function register(): void { add_action( self::HOOK, static function (): void { ( new self( Connection::client() ) )->send(); } ); add_action( self::RETRY_HOOK, static function (): void { ( new self( Connection::client() ) )->send(); } ); } /** * Hourly schedule while connected; nothing while disconnected. * * @param int $first_run Unix time of the first run. */ public static function schedule( int $first_run = 0 ): void { if ( ! wp_next_scheduled( self::HOOK ) ) { wp_schedule_event( $first_run > 0 ? $first_run : time() + HOUR_IN_SECONDS, RemoteConfig::recurrence(), self::HOOK ); } } /** * Apply a new report interval from the signed configuration (next run one interval from now). */ public static function reschedule(): void { wp_clear_scheduled_hook( self::HOOK ); $schedules = function_exists( 'wp_get_schedules' ) ? wp_get_schedules() : array(); $interval = isset( $schedules[ RemoteConfig::recurrence() ]['interval'] ) ? (int) $schedules[ RemoteConfig::recurrence() ]['interval'] : HOUR_IN_SECONDS; self::schedule( time() + $interval ); } /** * Stop all sending immediately (disconnect, deactivate, uninstall). */ public static function unschedule(): void { wp_clear_scheduled_hook( self::HOOK ); wp_clear_scheduled_hook( self::RETRY_HOOK ); delete_option( self::OUTBOX ); } /** * Keep the schedule in line with the connection state (admin_init upgrade path). */ public static function sync(): void { if ( Connection::is_connected() ) { self::schedule(); } elseif ( wp_next_scheduled( self::HOOK ) ) { self::unschedule(); } } /** * Build and send one report. * * @return string Outcome: "sent", "skipped", "disconnected" or "retry". */ public function send(): string { if ( ! Connection::is_connected() ) { self::unschedule(); return 'skipped'; } $changes = new ChangeLog(); $changes->detect(); // Optional data follows the signed configuration from the WPXLab dashboard (RemoteConfig). $commerce = RemoteConfig::collects( 'woocommerce' ); $payload = self::payload( Registry::run( Registry::checks() ), $commerce ? OrderStats::collect() : null, $commerce ? Pages::collect() : null ); $payload = self::with_history( $payload, RemoteConfig::collects( 'changes' ) ? $changes->all() : array(), RemoteConfig::collects( 'errors' ) ? ( new ErrorStore() )->all() : array(), time() ); if ( Connection::inventory_allowed() ) { $payload = self::with_inventory( $payload, SiteInventory::components(), SiteInventory::metrics() ); } $response = $this->client->api()->request( 'POST', '/keepwatch/reports', $payload ); if ( in_array( $response['status'], array( 200, 201 ), true ) ) { delete_option( self::OUTBOX ); $version = $response['body']['config_version'] ?? null; if ( is_string( $version ) && RemoteConfig::version() !== $version ) { RemoteConfig::refresh( $this->client ); } return 'sent'; } // The Hub no longer knows this site (disconnected from the dashboard): stop locally as well. if ( in_array( $response['status'], array( 403, 404 ), true ) ) { delete_option( Connection::OPTION ); self::unschedule(); return 'disconnected'; } $this->schedule_retry(); return 'retry'; } /** * Report payload (keepwatch-report.v1). * * @param array $groups Grouped check results. * @param array{from: int, to: int, hours: list}|null $commerce Hourly order totals (WooCommerce only). * @param array|null $pages Public WooCommerce page URLs (WooCommerce only). * @return array */ public static function payload( array $groups, ?array $commerce = null, ?array $pages = null ): array { global $wp_version; $results = array(); $summary = array( 'good' => 0, 'recommended' => 0, 'critical' => 0, ); foreach ( $groups as $group => $group_results ) { $group = substr( (string) preg_replace( '/[^a-z0-9_-]/', '', strtolower( (string) $group ) ), 0, 32 ); foreach ( $group_results as $result ) { $id = substr( (string) preg_replace( '/[^a-z0-9_.-]/', '', strtolower( $result->id ) ), 0, 64 ); if ( '' === $id || '' === $group || ! isset( $summary[ $result->status ] ) || count( $results ) >= self::MAX_RESULTS ) { continue; } ++$summary[ $result->status ]; $results[] = array( 'id' => $id, 'group' => $group, 'status' => $result->status, 'label' => function_exists( 'mb_substr' ) ? mb_substr( $result->label, 0, self::LABEL_LIMIT ) : substr( $result->label, 0, self::LABEL_LIMIT ), ); } } $payload = array( 'schema' => 'keepwatch-report.v1', 'generated_at' => time(), 'environment' => array( 'wp' => (string) $wp_version, 'php' => PHP_VERSION, 'plugin' => KEEPWATCH_VERSION, 'woocommerce' => defined( 'WC_VERSION' ) ? (string) constant( 'WC_VERSION' ) : null, 'multisite' => is_multisite(), ), 'summary' => $summary, 'results' => $results, ); if ( null !== $commerce ) { $payload['commerce'] = $commerce; } if ( null !== $pages && array() !== $pages ) { $payload['pages'] = $pages; } return $payload; } /** * Adds the last 7 days of changes (plugin/theme/core name, slug and versions) and fatal errors as * fingerprint + component + times only. Error messages and file paths are not sent. * * @param array $payload Report payload. * @param array $changes Changes, newest first. * @param array $errors Errors. * @param int $now Current time. * @return array */ public static function with_history( array $payload, array $changes, array $errors, int $now ): array { $since = $now - WEEK_IN_SECONDS; $rows = array(); foreach ( $changes as $change ) { if ( $change['time'] < $since || count( $rows ) >= self::MAX_CHANGES ) { continue; } $rows[] = array( 'time' => $change['time'], 'kind' => $change['kind'], 'type' => $change['type'], 'slug' => substr( (string) preg_replace( '/[^a-z0-9_.-]/', '', strtolower( $change['slug'] ) ), 0, 100 ), 'name' => function_exists( 'mb_substr' ) ? mb_substr( $change['name'], 0, 80 ) : substr( $change['name'], 0, 80 ), 'from' => null === $change['from'] ? null : substr( $change['from'], 0, 32 ), 'to' => null === $change['to'] ? null : substr( $change['to'], 0, 32 ), ); } $list = array(); foreach ( $errors as $fingerprint => $error ) { if ( (int) $error['last_seen'] < $since || count( $list ) >= self::MAX_ERRORS ) { continue; } $list[] = array( 'fp' => substr( (string) $fingerprint, 0, 32 ), 'component' => Correlator::component( (string) $error['file'] ), 'first_seen' => (int) $error['first_seen'], 'last_seen' => (int) $error['last_seen'], 'count' => min( 1000000, (int) $error['count'] ), ); } $payload['changes'] = $rows; $payload['errors'] = $list; return $payload; } /** * Report v2 (keepwatch-report.v2): installed components and site metrics (only with consent version 2). * * @param array $payload Report payload (v1). * @param array $components SiteInventory::components(). * @param array $metrics SiteInventory::metrics(). * @return array */ public static function with_inventory( array $payload, array $components, array $metrics ): array { $payload['schema'] = 'keepwatch-report.v2'; $payload['components'] = $components; $payload['metrics'] = $metrics; return $payload; } /** * Next retry with exponential backoff; give up after the last step (the hourly run tries again). */ private function schedule_retry(): void { $outbox = get_option( self::OUTBOX ); $attempts = is_array( $outbox ) && isset( $outbox['attempts'] ) ? (int) $outbox['attempts'] : 0; if ( $attempts >= count( self::BACKOFF ) ) { delete_option( self::OUTBOX ); return; } update_option( self::OUTBOX, array( 'attempts' => $attempts + 1, 'last_error' => time(), ), false ); wp_clear_scheduled_hook( self::RETRY_HOOK ); wp_schedule_single_event( time() + self::BACKOFF[ $attempts ], self::RETRY_HOOK ); } }