) usable for log greps. * http_detail: string Free-form context (WP_Error message, * etc). May be empty. * email_attempted: bool true when HTTP failed and the email * fallback ran. * email_ok: bool|null Result of the email fallback when it * ran; null when not attempted. * * @var array{http_status: int|null, http_reason: string, http_detail: string, email_attempted: bool, email_ok: bool|null} */ private static $lastSendDiagnostics = array( 'http_status' => null, 'http_reason' => '', 'http_detail' => '', 'email_attempted' => false, 'email_ok' => null, ); /** * Queue a payload for asynchronous send. Used by interactive paths * (deactivate AJAX). Returns immediately; the actual send happens in a * single-shot cron event. * * Schedules the cron event and then kicks WP-Cron via spawn_cron() so the * send happens on the next request cycle instead of waiting for a natural * cron tick. On low-traffic sites a natural tick can be hours away, which * is long enough for the deactivate flow to forget about the report. * * @param array $payload * @param string $type * @return void */ public static function queue(array $payload, string $type): void { $payload = ABJ_404_Solution_FeedbackPayloadSchemaGuard::normalize($payload); $contract = ABJ_404_Solution_FeedbackPayloadSchemaGuard::validate($payload, $type); if (empty($contract['valid'])) { return; } ABJ_404_Solution_FeedbackPayloadSchemaGuard::logContractWarnings($payload, $type); $uuid = ABJ_404_Solution_FeedbackPayloadBuilder::generateUuid(); $envelope = array( 'payload' => $payload, 'type' => $type, ); // allow-cache-empty: feedback envelope is generated locally and may contain an intentionally empty payload. $stored = ABJ_404_Solution_TransientStore::store(self::TRANSIENT_PREFIX . $uuid, $envelope, self::TRANSIENT_TTL); if (!$stored) { // The cron handler's whole job is to load THIS uuid's envelope and // send it. Scheduling it against a store that just refused the // write books a job whose only possible outcome is finding nothing, // and the report is gone with no record that it ever existed. // Options tables do fill up and object caches do go down; those are // the hosting failures this plugin degrades past rather than // crashes on, so this is a warning and not an error, but it is not // silence. ABJ_404_Solution_FeedbackTransportLog::log('warn', sprintf( 'abj404_transport: could not persist the %s report envelope (uuid %s); ' . 'no send was scheduled because there is nothing to send.', $type, $uuid )); return; } abj_cron_scheduler()->scheduleSingle(self::CRON_HOOK, 0, array($uuid)); // Trigger spawn_cron so the listener runs on the next request rather // than waiting for the next page load on a logged-in admin. spawn_cron // is a no-op when DISABLE_WP_CRON is true or a cron is already running. if (function_exists('spawn_cron')) { spawn_cron(); } } /** * Synchronously POST and fall back to wp_mail() on failure. Used by paths * already in cron context (nightly maintenance). * * The email fallback is intentionally skipped for type='heartbeat': a * heartbeat is a low-value, high-frequency signal, and mailing its full * payload to the developer on every transport hiccup would flood the * inbox with noise. type='error' (and the interactive types) keep the * fallback since those reports are the ones actually worth not losing. * * @param array $payload * @param string $type * @return bool true if any transport (HTTP or email) succeeded, or if * type='heartbeat' and the HTTP POST itself succeeded. */ public static function sendNow(array $payload, string $type): bool { self::$lastSendUsedFallback = false; self::$lastSendDiagnostics = array( 'http_status' => null, 'http_reason' => '', 'http_detail' => '', 'email_attempted' => false, 'email_ok' => null, ); $payload = ABJ_404_Solution_FeedbackPayloadSchemaGuard::normalize($payload); $contract = ABJ_404_Solution_FeedbackPayloadSchemaGuard::validate($payload, $type); if (empty($contract['valid'])) { self::$lastSendDiagnostics = array( 'http_status' => null, 'http_reason' => ABJ_404_Solution_ReportPayloadJsonSchemaValidator::REASON_VALIDATION_FAILED, 'http_detail' => isset($contract['detail']) && is_scalar($contract['detail']) ? (string)$contract['detail'] : '', 'email_attempted' => false, 'email_ok' => null, ); return false; } ABJ_404_Solution_FeedbackPayloadSchemaGuard::logContractWarnings($payload, $type); $payload = ABJ_404_Solution_FeedbackPayloadSchemaGuard::redact($payload); $started = abj_clock()->nowFloat(); $result = ABJ_404_Solution_FeedbackHttpClient::send($payload); $elapsedMs = (int) round((abj_clock()->nowFloat() - $started) * 1000); $statusStr = isset($result['status']) && is_scalar($result['status']) ? (string)$result['status'] : ''; $reasonStr = isset($result['reason']) && is_scalar($result['reason']) ? (string)$result['reason'] : ''; $detailStr = isset($result['detail']) && is_scalar($result['detail']) ? (string)$result['detail'] : ''; self::$lastSendDiagnostics = array( 'http_status' => $statusStr !== '' ? (int)$statusStr : null, 'http_reason' => $reasonStr, 'http_detail' => $detailStr, 'email_attempted' => false, 'email_ok' => null, ); if (!empty($result['ok'])) { ABJ_404_Solution_FeedbackTransportLog::log('info', sprintf( 'abj404_transport: type=%s http_status=%s fallback_used=false ms_elapsed=%d', $type, $statusStr !== '' ? $statusStr : 'ok', $elapsedMs )); return true; } $statusLabel = $statusStr !== '' ? $statusStr : ($reasonStr !== '' ? $reasonStr : 'unknown'); if ($type === 'heartbeat') { ABJ_404_Solution_FeedbackTransportLog::log('warn', sprintf( 'abj404_transport: type=%s http_status=%s fallback_used=false (heartbeat never falls back) ms_elapsed=%d detail=%s', $type, $statusLabel, $elapsedMs, $detailStr )); return false; } ABJ_404_Solution_FeedbackTransportLog::log('warn', sprintf( 'abj404_transport: type=%s http_status=%s fallback_used=true ms_elapsed=%d detail=%s', $type, $statusLabel, $elapsedMs, $detailStr )); self::$lastSendUsedFallback = true; self::$lastSendDiagnostics['email_attempted'] = true; $emailOk = ABJ_404_Solution_FeedbackEmailFallback::send($payload, $type); self::$lastSendDiagnostics['email_ok'] = $emailOk; return $emailOk; } /** * Diagnostic context from the most recent sendNow() call. Callers * that surface a user-facing failure message must include the * http_status / http_reason here so the message is actionable. * "Could not send" alone is the diagnostic black-hole this method * exists to prevent (CLAUDE.md > Error visibility). * * @return array{http_status: int|null, http_reason: string, http_detail: string, email_attempted: bool, email_ok: bool|null} */ public static function lastSendDiagnostics(): array { return self::$lastSendDiagnostics; } /** * Whether the most recent sendNow() call used the wp_mail() fallback * after the HTTP POST failed. Callers (e.g. the support-request AJAX * handler) read this immediately after sendNow() to surface the * transport result to the user. * * @return bool */ public static function lastSendUsedFallback(): bool { return self::$lastSendUsedFallback; } /** * Cron handler for queued sends. Loads payload from transient, calls * sendNow(), deletes transient regardless of outcome (24h TTL still * cleans up if anything throws before the delete). * * @param string $uuid * @return void */ public static function handleQueuedSend(string $uuid): void { $key = self::TRANSIENT_PREFIX . $uuid; $envelope = get_transient($key); if (!is_array($envelope) || !isset($envelope['payload']) || !is_array($envelope['payload'])) { // Transient expired before WP-Cron fired, or the cron event fired // twice and the second invocation found the key already cleared. // Log so the data loss is visible to admins; 24h TTL means this // path is reachable on sites where WP-Cron is broken or paused. ABJ_404_Solution_FeedbackTransportLog::log('warn', sprintf( 'abj404_transport: queued send missed - transient absent or malformed (key=%s). ' . 'Most commonly: WP-Cron did not fire within the %d second TTL.', $key, self::TRANSIENT_TTL )); delete_transient($key); return; } /** @var array $payload */ $payload = $envelope['payload']; $type = isset($envelope['type']) && is_string($envelope['type']) ? $envelope['type'] : 'unknown'; try { self::sendNow($payload, $type); } catch (\Throwable $e) { // sendNow() must be defensive, but if anything escapes we still // log and let the transient be cleared so cron doesn't loop on it. ABJ_404_Solution_FeedbackTransportLog::log('warn', 'abj404_transport: sendNow threw: ' . $e->getMessage()); } delete_transient($key); } /** * Build a full payload from current site state. Public re-export of * FeedbackPayloadBuilder::build() so existing callers and tests keep * working through this historical entry point. * * @param string $type One of 'error', 'heartbeat', 'uninstall', 'support_request'. * @param array $extra * @return array */ public static function buildPayload(string $type, array $extra = array()): array { return ABJ_404_Solution_FeedbackPayloadBuilder::build($type, $extra); } /** * Drain any pending crash beacon and report it as a post-mortem `error` * report. A crash beacon is written by the fatal shutdown handler when a * fatal/OOM kills a request before it can phone home; this reports it on a * later healthy request. Lives here, beside the other transports, so the * Core crash-beacon reporter is reached within the Core layer. * * @return bool True if a crash beacon was reported. */ public static function drainCrashBeacon(): bool { $reporter = new ABJ_404_Solution_CrashBeaconReporter( ABJ_404_Solution_CrashBeaconStore::forCurrentSite()); return $reporter->drainAndReport(); } /** * Build a redacted, schema-conforming payload (uninstall opt-out path). * Public re-export of FeedbackPayloadBuilder::buildMinimal(). * * @param string $type One of 'error', 'heartbeat', 'uninstall', 'support_request'. * @param array $extra * @return array */ public static function buildMinimalPayload(string $type, array $extra = array()): array { return ABJ_404_Solution_FeedbackPayloadBuilder::buildMinimal($type, $extra); } }