storage = $storage; $this->client = $client; $this->clock = null === $clock ? 'time' : $clock; } /** * Persist a local observation without contacting the management API. * * @param string $code Allowlisted management diagnostic code. * @param bool $active Whether the condition is currently active. * @return array|\WP_Error */ public function observe( $code, $active ) { $keys = $this->diagnostic_flags(); if ( ! isset( $keys[ $code ] ) || ! is_bool( $active ) ) { return new \WP_Error( 'invalid_management_diagnostic', __( 'The diagnostic condition is not allowed.', 'achlaai' ), array( 'status' => 400 ) ); } $settings = $this->storage->get(); if ( ! $this->storage->management_ready( $settings ) ) { return new \WP_Error( 'diagnostics_requires_ready_binding', __( 'Reconnect this site before sending diagnostics.', 'achlaai' ), array( 'status' => 409 ) ); } $state = $settings['management_diagnostics']; $flag = $keys[ $code ]; $flag_changed = (bool) $state[ $flag ] !== $active; $state[ $flag ] = $active; $selected = $this->selected_code( $state ); $observed_change = $selected !== $state['observed_code']; if ( $observed_change ) { $now = (int) call_user_func( $this->clock ); $state['observed_code'] = $selected; $state['error_first_seen_at'] = '' === $selected ? '' : Protocol::format_canonical_time( $now ); } if ( $flag_changed || $observed_change ) { $settings['management_diagnostics'] = $state; $this->storage->save( $settings ); } return $state; } /** Flush the latest persisted observation from cron. */ public function flush() { $settings = $this->storage->get(); if ( ! $this->storage->management_ready( $settings ) ) { return new \WP_Error( 'diagnostics_requires_ready_binding', __( 'Reconnect this site before sending diagnostics.', 'achlaai' ), array( 'status' => 409 ) ); } $state = $settings['management_diagnostics']; $selected = $this->selected_code( $state ); if ( '' !== $selected ) { return $this->report( $selected, true ); } if ( 'RELEASE_UNAVAILABLE' === $state['current_code'] ) { return $this->report( 'RELEASE_UNAVAILABLE', false ); } if ( 'ATTACH_TARGET_MISSING' === $state['current_code'] ) { return $this->report( 'ATTACH_TARGET_MISSING', false ); } return $state; } /** Return the next time a pending or active diagnostic should be flushed. */ public function next_flush_at() { $settings = $this->storage->get(); if ( ! $this->storage->management_ready( $settings ) ) { return false; } $state = $settings['management_diagnostics']; $selected = $this->selected_code( $state ); if ( '' === $selected && $selected === $state['current_code'] ) { return false; } return max( (int) call_user_func( $this->clock ) + 1, (int) $state['next_send_at'] ); } /** * Report one content-free local condition or its recovery. * * @param string $code Allowlisted management diagnostic code. * @param bool $active Whether the condition is currently active. * @return array|\WP_Error */ public function report( $code, $active ) { $keys = $this->diagnostic_flags(); if ( ! isset( $keys[ $code ] ) || ! is_bool( $active ) ) { return new \WP_Error( 'invalid_management_diagnostic', __( 'The diagnostic condition is not allowed.', 'achlaai' ), array( 'status' => 400 ) ); } $settings = $this->storage->get(); if ( ! $this->storage->management_ready( $settings ) ) { return new \WP_Error( 'diagnostics_requires_ready_binding', __( 'Reconnect this site before sending diagnostics.', 'achlaai' ), array( 'status' => 409 ) ); } $state = $settings['management_diagnostics']; $flag = $keys[ $code ]; $now = (int) call_user_func( $this->clock ); $flag_changed = (bool) $state[ $flag ] !== $active; $state[ $flag ] = $active; $selected = $this->selected_code( $state ); // `observed_code` is the freshest locally-detected condition (drives // error_first_seen_at bookkeeping and updates immediately, regardless of // pacing). `current_code` is the condition last actually reported to the // backend, so it only advances on a successful send -- a transition that is // paced must stay "pending" (not silently treated as delivered) until it is // actually flushed, or it would never be sent at all. $observed_change = $selected !== $state['observed_code']; $pending = $selected !== $state['current_code']; $should_send = $this->should_send( $state, $pending, $selected, $now ); if ( ! $flag_changed && ! $observed_change && ! $should_send ) { return $state; } if ( ! $this->acquire_lock( $now ) ) { return $state; } try { $settings = $this->storage->get(); if ( ! $this->storage->management_ready( $settings ) ) { return new \WP_Error( 'diagnostics_requires_ready_binding', __( 'Reconnect this site before sending diagnostics.', 'achlaai' ), array( 'status' => 409 ) ); } $state = $settings['management_diagnostics']; $flag_changed = (bool) $state[ $flag ] !== $active; $state[ $flag ] = $active; $selected = $this->selected_code( $state ); $observed_change = $selected !== $state['observed_code']; if ( $observed_change ) { $state['observed_code'] = $selected; $state['error_first_seen_at'] = '' === $selected ? '' : Protocol::format_canonical_time( $now ); } // Pacing/backoff (next_send_at) gates the backend call uniformly, whether // the pending change is a same-code refresh (periodic re-affirmation of an // ongoing condition) or a missing<->restored transition: flapping must // never exceed the agreed send interval. Only the local observation is // persisted while paced; the next eligible call flushes the latest state. $pending = $selected !== $state['current_code']; $should_send = $this->should_send( $state, $pending, $selected, $now ); if ( ! $should_send ) { if ( $flag_changed || $observed_change ) { $settings['management_diagnostics'] = $state; $this->storage->save( $settings ); } return $state; } $observed = Protocol::format_canonical_time( $now ); $first = '' === $selected ? null : $state['error_first_seen_at']; $last = '' === $selected ? null : $observed; $result = $this->client->diagnostics( $settings['connection'], array_merge( $this->heartbeat_fields( $settings ), array( 'error_code' => '' === $selected ? null : $selected, 'observed_at' => $observed, 'error_first_seen_at' => $first, 'error_last_seen_at' => $last, ) ) ); if ( is_wp_error( $result ) ) { $data = $result->get_error_data(); if ( is_array( $data ) && isset( $data['status'] ) && in_array( (int) $data['status'], array( 401, 403 ), true ) ) { $this->storage->revoke_management( 'management_revoked' ); return $result; } $failures = min( 6, (int) $state['send_failures'] + 1 ); $state['send_failures'] = $failures; $state['error_last_seen_at'] = null === $last ? '' : $last; $state['next_send_at'] = $now + min( 900, 30 * ( 2 ** ( $failures - 1 ) ) ); $settings['management_diagnostics'] = $state; $this->storage->save( $settings ); return $result; } $state['current_code'] = $selected; $state['error_last_seen_at'] = null === $last ? '' : $last; $state['next_send_at'] = $now + self::COOLDOWN; $state['send_failures'] = 0; $settings['management_diagnostics'] = $state; $this->storage->save( $settings ); return $result; } finally { delete_option( self::LOCK_OPTION ); } } /** @return string */ private function selected_code( $state ) { if ( ! empty( $state['release_unavailable'] ) ) { return 'RELEASE_UNAVAILABLE'; } return ! empty( $state['attach_target_missing'] ) ? 'ATTACH_TARGET_MISSING' : ''; } /** @return array */ private function diagnostic_flags() { return array( 'ATTACH_TARGET_MISSING' => 'attach_target_missing', 'RELEASE_UNAVAILABLE' => 'release_unavailable', ); } /** * A send is due once pacing allows it AND there is something worth telling the * backend: either a not-yet-delivered value (`$pending`, including a transition * to/from a clear that was previously paced), or a periodic re-affirmation of a * still-ongoing (non-empty) condition. * * @param array $state Diagnostic state. * @param bool $pending Whether $selected has not yet been delivered. * @param string $selected Selected code, '' when clear. * @param int $now Unix timestamp. * @return bool */ private function should_send( $state, $pending, $selected, $now ) { return (int) $state['next_send_at'] <= $now && ( $pending || '' !== $selected ); } /** @return array */ private function heartbeat_fields( $settings ) { $index_status = in_array( $settings['lifecycle']['index_status'], array( 'pending', 'indexing', 'ready', 'failed' ), true ) ? $settings['lifecycle']['index_status'] : 'unknown'; if ( 'verified' !== $settings['connection']['status'] || in_array( $settings['lifecycle']['project_status'], array( 'suspended', 'canceled' ), true ) || 'failed' === $index_status || in_array( $settings['release']['status'], array( 'blocked', 'killed' ), true ) ) { $connection_status = 'error'; } elseif ( 'active' !== $settings['lifecycle']['project_status'] || 'ready' !== $index_status || 'ready' !== $settings['release']['status'] ) { $connection_status = 'degraded'; } else { $connection_status = 'connected'; } return array( 'protocol' => 'aip-management-v1', 'plugin_version' => ACHLAAI_VERSION, 'cms' => 'wordpress', 'cms_version' => get_bloginfo( 'version' ), 'runtime' => 'php', 'runtime_version' => Protocol::php_runtime_version(), 'connection_status' => $connection_status, 'index_status' => $index_status, ); } /** @return bool */ private function acquire_lock( $now ) { $expires = get_option( self::LOCK_OPTION, 0 ); if ( is_numeric( $expires ) && (int) $expires <= $now ) { delete_option( self::LOCK_OPTION ); } return add_option( self::LOCK_OPTION, $now + self::LOCK_TTL, '', 'no' ); } }