PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / feedback / FeedbackTransport.php

FeedbackTransport.php in 404 Solution trunk, at includes/feedback/FeedbackTransport.php

343 lines 15.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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