settings->get( 'backoff_until', 0 ) > $now ) { return array( 'batches' => 0, 'accepted' => 0, 'error' => 'backoff' ); } $start = microtime( true ); $batches = 0; $accepted = 0; for ( $i = 0; $i < self::MAX_BATCHES_RUN; $i++ ) { if ( microtime( true ) - $start > self::TIME_BUDGET_S ) { break; } $batch = $this->spool->read_batch( self::MAX_EVENTS, self::MAX_BATCH_BYTES ); if ( $batch['events'] === array() ) { break; } $stats = $this->spool->stats(); $payload = array( 'schema_version' => Event::SCHEMA_VERSION, 'batch_id' => Event::uuid7(), 'sent_at' => gmdate( 'Y-m-d\TH:i:s\Z' ), 'agent_version' => OPERANSE_AGENT_VERSION, 'dropped' => (object) $stats['dropped'], 'via' => $this->via === 'stream' ? 'stream' : 'cron', 'events' => $batch['events'], ); $res = $this->client->post_json( '/v1/agent/telemetry', $payload, true ); if ( $res['ok'] ) { $this->spool->ack( $batch['handle'] ); $this->spool->reset_dropped(); $accepted += (int) ( $res['body']['accepted'] ?? 0 ); $batches++; $this->settings->set_many( array( 'last_flush_at' => time(), 'last_flush_ok' => true, 'backoff_seconds' => 0, 'backoff_until' => 0, 'last_error' => '' ) ); continue; } if ( ! $res['retryable'] && $res['status'] >= 400 ) { // Permanent rejection by the cloud (schema/auth): drop this batch locally so a poison batch cannot block the queue, // but keep a bounded dead-letter copy for diagnostics. $this->dead_letter( $batch['events'], (string) $res['error'] ); $this->spool->ack( $batch['handle'] ); $this->settings->set_many( array( 'last_flush_at' => time(), 'last_flush_ok' => false, 'last_error' => 'telemetry rejected: ' . $res['error'], 'last_error_at' => time() ) ); $batches++; continue; } // Retryable (network, 5xx, 429) or local error (status 0): keep the data, back off. $this->backoff( (string) $res['error'] ); return array( 'batches' => $batches, 'accepted' => $accepted, 'error' => $res['error'] ); } return array( 'batches' => $batches, 'accepted' => $accepted, 'error' => null ); } private function backoff( string $error ): void { $prev = (int) $this->settings->get( 'backoff_seconds', 0 ); $next = $prev === 0 ? self::BACKOFF_MIN_S : min( self::BACKOFF_MAX_S, $prev * 2 ); $jitter = random_int( 0, (int) max( 1, $next / 4 ) ); $this->settings->set_many( array( 'backoff_seconds' => $next, 'backoff_until' => time() + $next + $jitter, 'last_flush_at' => time(), 'last_flush_ok' => false, 'last_error' => 'telemetry upload failed: ' . $error, 'last_error_at' => time(), ) ); } /** @param list> $events */ private function dead_letter( array $events, string $reason ): void { $existing = get_option( 'operanse_agent_dead_letters', array() ); $existing = is_array( $existing ) ? $existing : array(); $existing[] = array( 'at' => time(), 'reason' => substr( $reason, 0, 200 ), 'count' => count( $events ), 'sample' => array_slice( $events, 0, 3 ) ); update_option( 'operanse_agent_dead_letters', array_slice( $existing, -20 ), false ); } }