| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
require_once __DIR__ . '/FeedbackTransportLog.php'; |
| 8 |
require_once __DIR__ . '/FeedbackHttpClient.php'; |
| 9 |
require_once __DIR__ . '/FeedbackEmailFallback.php'; |
| 10 |
require_once __DIR__ . '/FeedbackPayloadBuilder.php'; |
| 11 |
require_once __DIR__ . '/FeedbackPayloadSchemaGuard.php'; |
| 12 |
require_once __DIR__ . '/ReportPayloadJsonSchemaValidator.php'; |
| 13 |
require_once __DIR__ . '/../diagnostics/CrashBeaconReporter.php'; |
| 14 |
|
| 15 |
/** |
| 16 |
* Orchestrates feedback report sends. Owns the queue/cron lifecycle and the |
| 17 |
* post-send diagnostics state that callers inspect to surface specific |
| 18 |
* transport failures to the user. Delegates the actual work: |
| 19 |
* |
| 20 |
* - FeedbackPayloadBuilder - assemble the payload from site state |
| 21 |
* - FeedbackPayloadSchemaGuard - normalize, validate, redact PII |
| 22 |
* - FeedbackHttpClient - POST to the developer endpoint |
| 23 |
* - FeedbackEmailFallback - wp_mail() last-resort path |
| 24 |
* |
| 25 |
* queue($payload, $type): interactive paths (deactivate AJAX). Stores |
| 26 |
* the payload in a transient and schedules a single-shot cron event so |
| 27 |
* the user's click is never blocked on the network. |
| 28 |
* |
| 29 |
* sendNow($payload, $type): already-async paths (nightly cron). |
| 30 |
* Synchronously POSTs the payload, falls back to wp_mail() on non-2xx |
| 31 |
* or WP_Error. |
| 32 |
* |
| 33 |
* handleQueuedSend($uuid): cron handler. Loads transient, calls |
| 34 |
* sendNow(), deletes transient regardless of outcome. |
| 35 |
* |
| 36 |
* buildPayload($type, $extra): re-exports FeedbackPayloadBuilder::build() |
| 37 |
* so existing callers and tests keep working through the historical |
| 38 |
* entry point. |
| 39 |
*/ |
| 40 |
class ABJ_404_Solution_FeedbackTransport { |
| 41 |
|
| 42 |
const TRANSIENT_PREFIX = 'abj404_pending_report_'; |
| 43 |
const TRANSIENT_TTL = 86400; // 24 hours |
| 44 |
const CRON_HOOK = 'abj404_send_queued_report'; |
| 45 |
|
| 46 |
/** |
| 47 |
* Records whether the most recent sendNow() call fell back to wp_mail() |
| 48 |
* after the HTTP POST failed. Read-only for callers that need to surface |
| 49 |
* "we sent via email instead of HTTP" in their own response (e.g. the |
| 50 |
* support-request AJAX handler returning {fallback_used: true}). Reset |
| 51 |
* at the top of every sendNow() call so concurrent reads don't see a |
| 52 |
* stale value from a previous unrelated send. |
| 53 |
* |
| 54 |
* @var bool |
| 55 |
*/ |
| 56 |
private static $lastSendUsedFallback = false; |
| 57 |
|
| 58 |
/** |
| 59 |
* Diagnostic details from the most recent sendNow() call. Populated |
| 60 |
* unconditionally so callers (e.g. the support-request AJAX handler) |
| 61 |
* can surface the actual failure code and reason to the user instead |
| 62 |
* of a generic "could not send" message. |
| 63 |
* |
| 64 |
* Shape: |
| 65 |
* http_status: int|null HTTP status code from the developer |
| 66 |
* endpoint when the wp_remote_post() |
| 67 |
* call completed, or null when the |
| 68 |
* request never reached HTTP. |
| 69 |
* http_reason: string Short slug (json_encode_failed, |
| 70 |
* gzencode_failed, wp_error, |
| 71 |
* http_<code>) usable for log greps. |
| 72 |
* http_detail: string Free-form context (WP_Error message, |
| 73 |
* etc). May be empty. |
| 74 |
* email_attempted: bool true when HTTP failed and the email |
| 75 |
* fallback ran. |
| 76 |
* email_ok: bool|null Result of the email fallback when it |
| 77 |
* ran; null when not attempted. |
| 78 |
* |
| 79 |
* @var array{http_status: int|null, http_reason: string, http_detail: string, email_attempted: bool, email_ok: bool|null} |
| 80 |
*/ |
| 81 |
private static $lastSendDiagnostics = array( |
| 82 |
'http_status' => null, |
| 83 |
'http_reason' => '', |
| 84 |
'http_detail' => '', |
| 85 |
'email_attempted' => false, |
| 86 |
'email_ok' => null, |
| 87 |
); |
| 88 |
|
| 89 |
/** |
| 90 |
* Queue a payload for asynchronous send. Used by interactive paths |
| 91 |
* (deactivate AJAX). Returns immediately; the actual send happens in a |
| 92 |
* single-shot cron event. |
| 93 |
* |
| 94 |
* Schedules the cron event and then kicks WP-Cron via spawn_cron() so the |
| 95 |
* send happens on the next request cycle instead of waiting for a natural |
| 96 |
* cron tick. On low-traffic sites a natural tick can be hours away, which |
| 97 |
* is long enough for the deactivate flow to forget about the report. |
| 98 |
* |
| 99 |
* @param array<string, mixed> $payload |
| 100 |
* @param string $type |
| 101 |
* @return void |
| 102 |
*/ |
| 103 |
public static function queue(array $payload, string $type): void { |
| 104 |
$payload = ABJ_404_Solution_FeedbackPayloadSchemaGuard::normalize($payload); |
| 105 |
$contract = ABJ_404_Solution_FeedbackPayloadSchemaGuard::validate($payload, $type); |
| 106 |
if (empty($contract['valid'])) { |
| 107 |
return; |
| 108 |
} |
| 109 |
ABJ_404_Solution_FeedbackPayloadSchemaGuard::logContractWarnings($payload, $type); |
| 110 |
$uuid = ABJ_404_Solution_FeedbackPayloadBuilder::generateUuid(); |
| 111 |
$envelope = array( |
| 112 |
'payload' => $payload, |
| 113 |
'type' => $type, |
| 114 |
); |
| 115 |
// allow-cache-empty: feedback envelope is generated locally and may contain an intentionally empty payload. |
| 116 |
$stored = set_transient(self::TRANSIENT_PREFIX . $uuid, $envelope, self::TRANSIENT_TTL); |
| 117 |
if (!$stored) { |
| 118 |
// The cron handler's whole job is to load THIS uuid's envelope and |
| 119 |
// send it. Scheduling it against a store that just refused the |
| 120 |
// write books a job whose only possible outcome is finding nothing, |
| 121 |
// and the report is gone with no record that it ever existed. |
| 122 |
// Options tables do fill up and object caches do go down; those are |
| 123 |
// the hosting failures this plugin degrades past rather than |
| 124 |
// crashes on, so this is a warning and not an error, but it is not |
| 125 |
// silence. |
| 126 |
ABJ_404_Solution_FeedbackTransportLog::log('warn', sprintf( |
| 127 |
'abj404_transport: could not persist the %s report envelope (uuid %s); ' |
| 128 |
. 'no send was scheduled because there is nothing to send.', |
| 129 |
$type, |
| 130 |
$uuid |
| 131 |
)); |
| 132 |
return; |
| 133 |
} |
| 134 |
abj_cron_scheduler()->scheduleSingle(self::CRON_HOOK, 0, array($uuid)); |
| 135 |
|
| 136 |
// Trigger spawn_cron so the listener runs on the next request rather |
| 137 |
// than waiting for the next page load on a logged-in admin. spawn_cron |
| 138 |
// is a no-op when DISABLE_WP_CRON is true or a cron is already running. |
| 139 |
if (function_exists('spawn_cron')) { |
| 140 |
spawn_cron(); |
| 141 |
} |
| 142 |
} |
| 143 |
|
| 144 |
/** |
| 145 |
* Synchronously POST and fall back to wp_mail() on failure. Used by paths |
| 146 |
* already in cron context (nightly maintenance). |
| 147 |
* |
| 148 |
* The email fallback is intentionally skipped for type='heartbeat': a |
| 149 |
* heartbeat is a low-value, high-frequency signal, and mailing its full |
| 150 |
* payload to the developer on every transport hiccup would flood the |
| 151 |
* inbox with noise. type='error' (and the interactive types) keep the |
| 152 |
* fallback since those reports are the ones actually worth not losing. |
| 153 |
* |
| 154 |
* @param array<string, mixed> $payload |
| 155 |
* @param string $type |
| 156 |
* @return bool true if any transport (HTTP or email) succeeded, or if |
| 157 |
* type='heartbeat' and the HTTP POST itself succeeded. |
| 158 |
*/ |
| 159 |
public static function sendNow(array $payload, string $type): bool { |
| 160 |
self::$lastSendUsedFallback = false; |
| 161 |
self::$lastSendDiagnostics = array( |
| 162 |
'http_status' => null, |
| 163 |
'http_reason' => '', |
| 164 |
'http_detail' => '', |
| 165 |
'email_attempted' => false, |
| 166 |
'email_ok' => null, |
| 167 |
); |
| 168 |
|
| 169 |
$payload = ABJ_404_Solution_FeedbackPayloadSchemaGuard::normalize($payload); |
| 170 |
$contract = ABJ_404_Solution_FeedbackPayloadSchemaGuard::validate($payload, $type); |
| 171 |
if (empty($contract['valid'])) { |
| 172 |
self::$lastSendDiagnostics = array( |
| 173 |
'http_status' => null, |
| 174 |
'http_reason' => ABJ_404_Solution_ReportPayloadJsonSchemaValidator::REASON_VALIDATION_FAILED, |
| 175 |
'http_detail' => isset($contract['detail']) && is_scalar($contract['detail']) ? (string)$contract['detail'] : '', |
| 176 |
'email_attempted' => false, |
| 177 |
'email_ok' => null, |
| 178 |
); |
| 179 |
return false; |
| 180 |
} |
| 181 |
ABJ_404_Solution_FeedbackPayloadSchemaGuard::logContractWarnings($payload, $type); |
| 182 |
$payload = ABJ_404_Solution_FeedbackPayloadSchemaGuard::redact($payload); |
| 183 |
$started = abj_clock()->nowFloat(); |
| 184 |
$result = ABJ_404_Solution_FeedbackHttpClient::send($payload); |
| 185 |
$elapsedMs = (int) round((abj_clock()->nowFloat() - $started) * 1000); |
| 186 |
|
| 187 |
$statusStr = isset($result['status']) && is_scalar($result['status']) ? (string)$result['status'] : ''; |
| 188 |
$reasonStr = isset($result['reason']) && is_scalar($result['reason']) ? (string)$result['reason'] : ''; |
| 189 |
$detailStr = isset($result['detail']) && is_scalar($result['detail']) ? (string)$result['detail'] : ''; |
| 190 |
|
| 191 |
self::$lastSendDiagnostics = array( |
| 192 |
'http_status' => $statusStr !== '' ? (int)$statusStr : null, |
| 193 |
'http_reason' => $reasonStr, |
| 194 |
'http_detail' => $detailStr, |
| 195 |
'email_attempted' => false, |
| 196 |
'email_ok' => null, |
| 197 |
); |
| 198 |
|
| 199 |
if (!empty($result['ok'])) { |
| 200 |
ABJ_404_Solution_FeedbackTransportLog::log('info', sprintf( |
| 201 |
'abj404_transport: type=%s http_status=%s fallback_used=false ms_elapsed=%d', |
| 202 |
$type, |
| 203 |
$statusStr !== '' ? $statusStr : 'ok', |
| 204 |
$elapsedMs |
| 205 |
)); |
| 206 |
return true; |
| 207 |
} |
| 208 |
|
| 209 |
$statusLabel = $statusStr !== '' ? $statusStr : ($reasonStr !== '' ? $reasonStr : 'unknown'); |
| 210 |
|
| 211 |
if ($type === 'heartbeat') { |
| 212 |
ABJ_404_Solution_FeedbackTransportLog::log('warn', sprintf( |
| 213 |
'abj404_transport: type=%s http_status=%s fallback_used=false (heartbeat never falls back) ms_elapsed=%d detail=%s', |
| 214 |
$type, |
| 215 |
$statusLabel, |
| 216 |
$elapsedMs, |
| 217 |
$detailStr |
| 218 |
)); |
| 219 |
return false; |
| 220 |
} |
| 221 |
|
| 222 |
ABJ_404_Solution_FeedbackTransportLog::log('warn', sprintf( |
| 223 |
'abj404_transport: type=%s http_status=%s fallback_used=true ms_elapsed=%d detail=%s', |
| 224 |
$type, |
| 225 |
$statusLabel, |
| 226 |
$elapsedMs, |
| 227 |
$detailStr |
| 228 |
)); |
| 229 |
|
| 230 |
self::$lastSendUsedFallback = true; |
| 231 |
self::$lastSendDiagnostics['email_attempted'] = true; |
| 232 |
$emailOk = ABJ_404_Solution_FeedbackEmailFallback::send($payload, $type); |
| 233 |
self::$lastSendDiagnostics['email_ok'] = $emailOk; |
| 234 |
return $emailOk; |
| 235 |
} |
| 236 |
|
| 237 |
/** |
| 238 |
* Diagnostic context from the most recent sendNow() call. Callers |
| 239 |
* that surface a user-facing failure message must include the |
| 240 |
* http_status / http_reason here so the message is actionable. |
| 241 |
* "Could not send" alone is the diagnostic black-hole this method |
| 242 |
* exists to prevent (CLAUDE.md > Error visibility). |
| 243 |
* |
| 244 |
* @return array{http_status: int|null, http_reason: string, http_detail: string, email_attempted: bool, email_ok: bool|null} |
| 245 |
*/ |
| 246 |
public static function lastSendDiagnostics(): array { |
| 247 |
return self::$lastSendDiagnostics; |
| 248 |
} |
| 249 |
|
| 250 |
/** |
| 251 |
* Whether the most recent sendNow() call used the wp_mail() fallback |
| 252 |
* after the HTTP POST failed. Callers (e.g. the support-request AJAX |
| 253 |
* handler) read this immediately after sendNow() to surface the |
| 254 |
* transport result to the user. |
| 255 |
* |
| 256 |
* @return bool |
| 257 |
*/ |
| 258 |
public static function lastSendUsedFallback(): bool { |
| 259 |
return self::$lastSendUsedFallback; |
| 260 |
} |
| 261 |
|
| 262 |
/** |
| 263 |
* Cron handler for queued sends. Loads payload from transient, calls |
| 264 |
* sendNow(), deletes transient regardless of outcome (24h TTL still |
| 265 |
* cleans up if anything throws before the delete). |
| 266 |
* |
| 267 |
* @param string $uuid |
| 268 |
* @return void |
| 269 |
*/ |
| 270 |
public static function handleQueuedSend(string $uuid): void { |
| 271 |
$key = self::TRANSIENT_PREFIX . $uuid; |
| 272 |
$envelope = get_transient($key); |
| 273 |
if (!is_array($envelope) || !isset($envelope['payload']) || !is_array($envelope['payload'])) { |
| 274 |
// Transient expired before WP-Cron fired, or the cron event fired |
| 275 |
// twice and the second invocation found the key already cleared. |
| 276 |
// Log so the data loss is visible to admins; 24h TTL means this |
| 277 |
// path is reachable on sites where WP-Cron is broken or paused. |
| 278 |
ABJ_404_Solution_FeedbackTransportLog::log('warn', sprintf( |
| 279 |
'abj404_transport: queued send missed - transient absent or malformed (key=%s). ' . |
| 280 |
'Most commonly: WP-Cron did not fire within the %d second TTL.', |
| 281 |
$key, |
| 282 |
self::TRANSIENT_TTL |
| 283 |
)); |
| 284 |
delete_transient($key); |
| 285 |
return; |
| 286 |
} |
| 287 |
/** @var array<string, mixed> $payload */ |
| 288 |
$payload = $envelope['payload']; |
| 289 |
$type = isset($envelope['type']) && is_string($envelope['type']) ? $envelope['type'] : 'unknown'; |
| 290 |
|
| 291 |
try { |
| 292 |
self::sendNow($payload, $type); |
| 293 |
} catch (\Throwable $e) { |
| 294 |
// sendNow() must be defensive, but if anything escapes we still |
| 295 |
// log and let the transient be cleared so cron doesn't loop on it. |
| 296 |
ABJ_404_Solution_FeedbackTransportLog::log('warn', 'abj404_transport: sendNow threw: ' . $e->getMessage()); |
| 297 |
} |
| 298 |
|
| 299 |
delete_transient($key); |
| 300 |
} |
| 301 |
|
| 302 |
/** |
| 303 |
* Build a full payload from current site state. Public re-export of |
| 304 |
* FeedbackPayloadBuilder::build() so existing callers and tests keep |
| 305 |
* working through this historical entry point. |
| 306 |
* |
| 307 |
* @param string $type One of 'error', 'heartbeat', 'uninstall', 'support_request'. |
| 308 |
* @param array<string, mixed> $extra |
| 309 |
* @return array<string, mixed> |
| 310 |
*/ |
| 311 |
public static function buildPayload(string $type, array $extra = array()): array { |
| 312 |
return ABJ_404_Solution_FeedbackPayloadBuilder::build($type, $extra); |
| 313 |
} |
| 314 |
|
| 315 |
/** |
| 316 |
* Drain any pending crash beacon and report it as a post-mortem `error` |
| 317 |
* report. A crash beacon is written by the fatal shutdown handler when a |
| 318 |
* fatal/OOM kills a request before it can phone home; this reports it on a |
| 319 |
* later healthy request. Lives here, beside the other transports, so the |
| 320 |
* Core crash-beacon reporter is reached within the Core layer. |
| 321 |
* |
| 322 |
* @return bool True if a crash beacon was reported. |
| 323 |
*/ |
| 324 |
public static function drainCrashBeacon(): bool { |
| 325 |
$reporter = new ABJ_404_Solution_CrashBeaconReporter( |
| 326 |
ABJ_404_Solution_CrashBeaconStore::forCurrentSite()); |
| 327 |
return $reporter->drainAndReport(); |
| 328 |
} |
| 329 |
|
| 330 |
/** |
| 331 |
* Build a redacted, schema-conforming payload (uninstall opt-out path). |
| 332 |
* Public re-export of FeedbackPayloadBuilder::buildMinimal(). |
| 333 |
* |
| 334 |
* @param string $type One of 'error', 'heartbeat', 'uninstall', 'support_request'. |
| 335 |
* @param array<string, mixed> $extra |
| 336 |
* @return array<string, mixed> |
| 337 |
*/ |
| 338 |
public static function buildMinimalPayload(string $type, array $extra = array()): array { |
| 339 |
return ABJ_404_Solution_FeedbackPayloadBuilder::buildMinimal($type, $extra); |
| 340 |
} |
| 341 |
|
| 342 |
} |
| 343 |
|