connection = $connection; $this->logger = $logger; $this->token_provider = $token_provider; } /** WP-Cron hook that delivers the outbox when the response cannot be finished first. */ public const CRON_HOOK = 'wppilot_deliver_events'; /** Option holding event payloads waiting for the cron delivery. */ private const OUTBOX_OPTION = 'wppilot_event_outbox'; /** * Push an event into the batch buffer. Registers the shutdown flush hook on * first call, and writes a transient fallback in case PHP crashes before * shutdown fires. * * @param Activity_Event $event The event to buffer. */ public function push( Activity_Event $event ): void { $this->buffer[] = $event; $this->register_shutdown(); // Crash-resilient fallback: persist the buffer to a transient so a // subsequent request can pick it up if this request dies before shutdown. $this->persist_fallback(); } /** * Flush all buffered events (current request + any leftover from crashed * requests). Called from the `shutdown` hook, last. */ public function flush(): void { // Merge any fallback events from prior crashed requests. $fallback = $this->load_fallback(); $events = array_merge( $fallback, $this->buffer ); $this->buffer = array(); $this->clear_fallback(); if ( empty( $events ) ) { return; } $this->send_batch( $events ); } /** * Recover leftover fallback events from a prior request. Called early in the * request lifecycle (from the hook registry's init); it only arranges for * them to go out with this request's flush, so nothing is sent — and nothing * waits on the backend — at the start of a request. */ public function flush_fallback(): void { if ( ! empty( $this->load_fallback() ) ) { $this->register_shutdown(); } } /** * Deliver the outbox. Runs as the WP-Cron single event CRON_HOOK, in a * request no owner or visitor is waiting on. */ public function deliver_outbox(): void { $payloads = get_option( self::OUTBOX_OPTION, array() ); delete_option( self::OUTBOX_OPTION ); if ( ! is_array( $payloads ) || empty( $payloads ) ) { return; } $this->deliver( array_values( $payloads ) ); } /** Register the shutdown flush once per request, after every other shutdown callback. */ private function register_shutdown(): void { if ( ! $this->shutdown_registered ) { $this->shutdown_registered = true; add_action( 'shutdown', array( $this, 'flush' ), PHP_INT_MAX ); } } /** * Hand a batch of events on for delivery, without making the request that * produced them wait for the backend. * * WP-CLI dedup (Phase 1 §6.2): Within a single batch (same PHP request), * collapse duplicate events by dedup key (type + significant metadata fields). * WP-CLI commands like `wp post update` fire save_post twice per request; * keeping the last event per key preserves the most up-to-date data. * * @param Activity_Event[] $events Events to send. */ private function send_batch( array $events ): void { if ( ! $this->may_send( count( $events ) ) ) { return; } // WP-CLI dedup: collapse duplicate events within the batch. $payloads = array_values( array_map( static function ( Activity_Event $e ): array { return $e->to_payload(); }, $this->deduplicate_batch( $events ) ) ); /* * Never make a page wait on the backend. Delivery is blocking (so a 401 * can be heard) and needs a token (which may mean an exchange), so it * runs only where nobody is waiting: * - a cron or WP-CLI run — no visitor; * - after the response has been handed to the client * (fastcgi_finish_request / litespeed_finish_request); * - otherwise in a WP-Cron single event, spawned now. */ if ( wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ) || $this->finish_response() ) { $this->deliver( $payloads ); return; } $this->enqueue( $payloads ); } /** * The checks that decide whether delivery may be attempted at all. * * @param int $count Events in the batch, for the log. */ private function may_send( int $count ): bool { if ( ! $this->connection->is_connected() ) { return false; } /* * A pairing the backend refused, or deliveries refused often enough to * stop, is not retried from here: every attempt would be one more 401 * (2026-10-08: two sites, one every ~10 seconds, indefinitely). The * admin page shows "needs reconnect" from the same fact, and only a * successful reconnect clears it. */ if ( $this->connection->needs_reconnect() ) { $this->logger->debug( 'Event batch not sent: the site must be reconnected', array( 'count' => $count ) ); return false; } if ( ! $this->connection->may_deliver_now() ) { $this->logger->info( 'Event batch not sent: delivery is paused after a refusal', array( 'count' => $count ) ); return false; } return true; } /** * Hand the response to the client so the rest of this request runs with * nobody waiting. Runs last on `shutdown`, after core has flushed output. * * @return bool Whether the response was finished. */ private function finish_response(): bool { foreach ( array( 'fastcgi_finish_request', 'litespeed_finish_request' ) as $finish ) { if ( function_exists( $finish ) ) { return (bool) $finish(); } } return false; } /** * Queue payloads for the WP-Cron delivery and make sure it runs soon. * Scheduling and spawn_cron() are non-blocking: the spawn is a loopback * request WordPress fires without waiting for an answer. * * @param array> $payloads Wire-format events. */ private function enqueue( array $payloads ): void { $outbox = get_option( self::OUTBOX_OPTION, array() ); $outbox = array_merge( is_array( $outbox ) ? $outbox : array(), $payloads ); if ( count( $outbox ) > self::FALLBACK_MAX ) { $outbox = array_slice( $outbox, -self::FALLBACK_MAX ); } update_option( self::OUTBOX_OPTION, $outbox, false ); if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { wp_schedule_single_event( time(), self::CRON_HOOK ); } spawn_cron(); } /** * Deliver wire-format events to the backend's ingest endpoint and act on * the answer. Blocking with a short timeout; only ever called where no * request is waiting (see send_batch()). * * @param array> $payloads Wire-format events. */ private function deliver( array $payloads ): void { // Re-checked here: a cron delivery can run after a refusal recorded // since the events were queued. if ( ! $this->may_send( count( $payloads ) ) ) { return; } $auth_token = $this->get_auth_token(); if ( empty( $auth_token ) ) { return; } $body = wp_json_encode( array( 'events' => $payloads ) ); $timestamp = (string) time(); // HMAC signing — pattern from WPFort's webhooks.php (proven, directly // portable). Signature = HMAC-SHA256(timestamp + '.' + body, secret). $secret = $this->connection->get_secret(); $signature = hash_hmac( 'sha256', $timestamp . '.' . $body, $secret ); $response = wp_remote_post( WPPILOT_BACKEND_URL . '/v1/events/ingest', array( 'headers' => array( 'Content-Type' => 'application/json', 'X-WPPilot-Signature' => $signature, 'X-WPPilot-Timestamp' => $timestamp, 'Authorization' => 'Bearer ' . $auth_token, ), 'body' => $body, // Blocking, so the answer is heard. A fire-and-forget POST // cannot see a 401, which is how a refused site kept sending // for as long as the logs went back. 'timeout' => 3, 'blocking' => true, ) ); if ( is_wp_error( $response ) ) { // No verdict: the backend never answered. Never counted as a refusal. $this->logger->warning( 'Event batch delivery failed', array( 'error' => $response->get_error_message(), 'count' => count( $payloads ) ) ); return; } $this->record_outcome( (int) wp_remote_retrieve_response_code( $response ), count( $payloads ) ); } /** * Act on the backend's answer to a delivery. * * 401 — the token was refused. The cached token is dropped so the next * attempt exchanges the secret afresh (whose own 401 is the pairing * verdict), delivery pauses for an exponentially growing interval, * and Connection::DELIVERY_REFUSAL_LIMIT consecutive refusals stop it * until the owner reconnects. * 2xx — accepted; any refusal run is over. * Anything else (5xx, 429, ...) says nothing about the pairing and changes * nothing. * * @param int $code HTTP status of the delivery. * @param int $count Events in the batch, for the log. */ private function record_outcome( int $code, int $count ): void { if ( 401 === $code ) { $this->connection->clear_jwt(); $refusals = $this->connection->record_delivery_refused(); if ( $refusals >= Connection::DELIVERY_REFUSAL_LIMIT ) { $this->logger->error( 'Activity delivery refused repeatedly; sending stopped until the site is reconnected', array( 'http_code' => $code, 'refusals' => $refusals, 'count' => $count ) ); return; } $this->logger->warning( 'Activity delivery refused; pausing before the next attempt', array( 'http_code' => $code, 'refusals' => $refusals, 'count' => $count ) ); return; } if ( $code >= 200 && $code < 300 ) { $this->connection->record_delivery_accepted(); return; } $this->logger->warning( 'Event batch delivery not accepted', array( 'http_code' => $code, 'count' => $count ) ); } /** * Retrieve the JWT for backend auth. Uses the cached JWT from the connection * table (same token the heartbeat and all outbound plugin→backend calls use). * * @return string JWT, or empty if not available. */ private function get_auth_token(): string { /* * The stored token, read raw, was never renewed here: once it expired or * the backend revoked it, every batch was refused (401) for good — seen * on two connected sites, 2026-10-08, with nothing else on the site * calling the renewal path. The provider renews a token more than a * day old; a refused delivery (record_outcome()) also drops the cached * token, so a revoked one is replaced on the next attempt. */ if ( null !== $this->token_provider ) { $token = call_user_func( $this->token_provider ); return is_string( $token ) ? $token : ''; } return $this->connection->get_jwt(); } /** * Persist the current buffer to a transient (crash-resilient fallback). */ private function persist_fallback(): void { $payloads = array_map( static function ( Activity_Event $e ): array { return array( 'type' => $e->type, 'severity' => $e->severity, 'category' => $e->category, 'metadata' => $e->metadata, 'timestamp' => $e->timestamp, ); }, $this->buffer ); // Cap to prevent runaway growth. if ( count( $payloads ) > self::FALLBACK_MAX ) { $payloads = array_slice( $payloads, -self::FALLBACK_MAX ); } set_transient( self::FALLBACK_TRANSIENT, $payloads, 300 ); // 5 min TTL. } /** * Load events from the fallback transient. * * @return Activity_Event[] */ private function load_fallback(): array { $raw = get_transient( self::FALLBACK_TRANSIENT ); if ( ! is_array( $raw ) ) { return array(); } $events = array(); foreach ( $raw as $entry ) { if ( ! is_array( $entry ) || empty( $entry['type'] ) ) { continue; } $events[] = new Activity_Event( $entry['type'], $entry['severity'] ?? 'info', $entry['category'] ?? 'system', $entry['metadata'] ?? array(), $entry['timestamp'] ?? 0.0 ); } return $events; } /** * Clear the fallback transient. */ private function clear_fallback(): void { delete_transient( self::FALLBACK_TRANSIENT ); } /** * Deduplicate events within a batch (WP-CLI dedup, Phase 1 §6.2). * * Within a single PHP request lifecycle, WP-CLI fires save_post twice for * `wp post update`. This method collapses duplicate events by a dedup key * (type + significant metadata fields), keeping the last event per key. * * @param Activity_Event[] $events The raw batch. * @return Activity_Event[] Deduplicated batch. */ private function deduplicate_batch( array $events ): array { /** @var array Dedup key → last index in $events */ $seen = array(); foreach ( $events as $i => $event ) { $payload = $event->to_payload(); $meta = $payload['metadata'] ?? array(); // Build dedup key: type + significant metadata fields. $key_parts = array( $payload['type'] ); foreach ( array( 'post_id', 'plugin_slug', 'theme_slug', 'username', 'user_id' ) as $field ) { if ( isset( $meta[ $field ] ) ) { $key_parts[] = $field . '=' . $meta[ $field ]; } } $key = implode( ':', $key_parts ); $seen[ $key ] = $i; // Last one wins. } // Rebuild in original order. $indices = array_values( $seen ); sort( $indices ); $result = array(); foreach ( $indices as $i ) { $result[] = $events[ $i ]; } return $result; } }