| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
require_once __DIR__ . '/CrashBeacon.php'; |
| 8 |
require_once __DIR__ . '/CrashBeaconStore.php'; |
| 9 |
|
| 10 |
/** |
| 11 |
* Drains a pending crash beacon on a HEALTHY request and reports it as a |
| 12 |
* post-mortem `error` feedback report. Runs in the existing async maintenance |
| 13 |
* telemetry path (alongside the error-log and heartbeat dispatch), gated by the |
| 14 |
* same send_error_logs opt-in, so the GDPR transport-disable control governs it. |
| 15 |
* |
| 16 |
* Because this runs with memory plentiful, it does the work the capture path |
| 17 |
* could not: full canonical PII normalization of the signature, cooldown |
| 18 |
* bookkeeping, and transport. Collaborators are injected as callables so the |
| 19 |
* behavior is unit-testable without mocking the static FeedbackTransport facade. |
| 20 |
* |
| 21 |
* Wire contract: the crash marker rides the EXISTING, already-versioned |
| 22 |
* error_signature field as a machine-stable, versioned prefix |
| 23 |
* "[abj404-crash-beacon:v1 plugin=X] ...". The marker comes FIRST so a |
| 24 |
* server-side signature length cap cannot truncate it away. No new wire field |
| 25 |
* and no server change is required; the report is an ordinary `error` report. |
| 26 |
*/ |
| 27 |
class ABJ_404_Solution_CrashBeaconReporter { |
| 28 |
|
| 29 |
/** Transient key prefix for the per-signature cooldown. */ |
| 30 |
const COOLDOWN_TRANSIENT_PREFIX = 'abj404_crash_beacon_sent_'; |
| 31 |
|
| 32 |
/** One report per crash signature per 24h. */ |
| 33 |
const COOLDOWN_SECONDS = 86400; |
| 34 |
|
| 35 |
/** Only discard a corrupt/partial file once it is older than this, so a file |
| 36 |
* caught mid-write by a concurrent crashing request is not deleted before |
| 37 |
* its writer finishes. */ |
| 38 |
const CORRUPT_DISCARD_AGE_SECONDS = 300; |
| 39 |
|
| 40 |
/** @var ABJ_404_Solution_CrashBeaconStore */ |
| 41 |
private $store; |
| 42 |
/** @var callable fn(string $message): string */ |
| 43 |
private $normalizer; |
| 44 |
/** @var callable fn(string $type, array $extra): array */ |
| 45 |
private $payloadBuilder; |
| 46 |
/** @var callable fn(array $payload, string $type): bool */ |
| 47 |
private $sender; |
| 48 |
/** @var callable fn(): int epoch seconds */ |
| 49 |
private $clock; |
| 50 |
|
| 51 |
/** |
| 52 |
* @param ABJ_404_Solution_CrashBeaconStore $store |
| 53 |
* @param callable|null $normalizer fn(string $message): string. Default: canonical normalizeErrorSignature. |
| 54 |
* @param callable|null $payloadBuilder fn(string $type, array $extra): array. Default: FeedbackTransport::buildPayload. |
| 55 |
* @param callable|null $sender fn(array $payload, string $type): bool. Default: FeedbackTransport::sendNow. |
| 56 |
* @param callable|null $clock fn(): int epoch seconds. Default: abj_clock()->now(). |
| 57 |
*/ |
| 58 |
public function __construct( |
| 59 |
ABJ_404_Solution_CrashBeaconStore $store, |
| 60 |
$normalizer = null, |
| 61 |
$payloadBuilder = null, |
| 62 |
$sender = null, |
| 63 |
$clock = null |
| 64 |
) { |
| 65 |
$this->store = $store; |
| 66 |
$this->normalizer = is_callable($normalizer) ? $normalizer : function (string $message): string { |
| 67 |
return (new ABJ_404_Solution_FeedbackEnvironmentExtras_DebugLogSignatures())->normalizeErrorSignature($message); |
| 68 |
}; |
| 69 |
$this->payloadBuilder = is_callable($payloadBuilder) ? $payloadBuilder : function (string $type, array $extra): array { |
| 70 |
return ABJ_404_Solution_FeedbackTransport::buildPayload($type, $extra); |
| 71 |
}; |
| 72 |
$this->sender = is_callable($sender) ? $sender : function (array $payload, string $type): bool { |
| 73 |
return ABJ_404_Solution_FeedbackTransport::sendNow($payload, $type); |
| 74 |
}; |
| 75 |
$this->clock = is_callable($clock) ? $clock : function (): int { |
| 76 |
return abj_clock()->now(); |
| 77 |
}; |
| 78 |
} |
| 79 |
|
| 80 |
/** |
| 81 |
* Read any pending beacon and, if its signature is outside the 24h cooldown, |
| 82 |
* report it as an `error` feedback report. On a successful send the cooldown |
| 83 |
* transient is set BEFORE the file is cleared, so a failed unlink cannot |
| 84 |
* cause a resend. Wrapped in a Throwable guard: a transport failure on the |
| 85 |
* healthy path must never escalate (and must never feed back into the crash |
| 86 |
* capture path). |
| 87 |
* |
| 88 |
* @return bool true iff a report was sent. |
| 89 |
*/ |
| 90 |
public function drainAndReport(): bool { |
| 91 |
try { |
| 92 |
$result = $this->store->read(); |
| 93 |
$status = isset($result['status']) ? (string)$result['status'] : 'absent'; |
| 94 |
$beacon = isset($result['beacon']) && $result['beacon'] instanceof ABJ_404_Solution_CrashBeacon |
| 95 |
? $result['beacon'] : null; |
| 96 |
|
| 97 |
if ($status === 'absent' || $status === 'future') { |
| 98 |
// Nothing pending, or a newer-format file we must leave for a |
| 99 |
// compatible version to drain (forward-compatibility). |
| 100 |
return false; |
| 101 |
} |
| 102 |
|
| 103 |
if ($beacon === null) { |
| 104 |
// corrupt / oversized / unreadable: discard only once it is old |
| 105 |
// enough that any in-flight write has certainly completed. |
| 106 |
$modifiedAt = $this->store->modifiedAt(); |
| 107 |
if ($modifiedAt !== null && ($this->now() - $modifiedAt) > self::CORRUPT_DISCARD_AGE_SECONDS) { |
| 108 |
$this->store->clear(); |
| 109 |
} |
| 110 |
return false; |
| 111 |
} |
| 112 |
|
| 113 |
$cooldownKey = self::COOLDOWN_TRANSIENT_PREFIX . md5($beacon->signatureKey()); |
| 114 |
if (get_transient($cooldownKey) !== false) { |
| 115 |
// Already reported this signature recently. Clear the file so a |
| 116 |
// different future crash can be captured (first-crash-wins frees up). |
| 117 |
$this->store->clear(); |
| 118 |
return false; |
| 119 |
} |
| 120 |
|
| 121 |
$signature = $this->buildSignature($beacon); |
| 122 |
$payload = call_user_func($this->payloadBuilder, 'error', array( |
| 123 |
'error_signature' => $signature, |
| 124 |
'previously_sent_line' => 0, |
| 125 |
'debug_log_evidence' => array( |
| 126 |
'schema_version' => 1, |
| 127 |
'source' => 'crash_beacon', |
| 128 |
'error_excerpt' => $signature, |
| 129 |
'error_excerpt_in_debug_log' => false, |
| 130 |
'error_line_number' => -1, |
| 131 |
'total_evidence_bytes' => strlen($signature), |
| 132 |
), |
| 133 |
)); |
| 134 |
|
| 135 |
$sent = (bool) call_user_func($this->sender, $payload, 'error'); |
| 136 |
if ($sent) { |
| 137 |
set_transient($cooldownKey, 1, self::COOLDOWN_SECONDS); |
| 138 |
$this->store->clear(); |
| 139 |
} |
| 140 |
return $sent; |
| 141 |
} catch (\Throwable $e) { |
| 142 |
// The drain is a best-effort recovery path and must never fatal -- |
| 143 |
// including for want of a runtime helper that a degraded or isolated |
| 144 |
// bootstrap has not loaded yet (every other abj404_logRuntimeWarning |
| 145 |
// caller guards the same way). Fall back to the inert PHP-error-log |
| 146 |
// sink, then to nothing. |
| 147 |
if (function_exists('abj404_logRuntimeWarning')) { |
| 148 |
abj404_logRuntimeWarning('Crash beacon drain failed', $e); |
| 149 |
} elseif (function_exists('abj404_logPhpFallback')) { |
| 150 |
abj404_logPhpFallback('crash-beacon', 'drain failed: ' . $e->getMessage()); |
| 151 |
} |
| 152 |
return false; |
| 153 |
} |
| 154 |
} |
| 155 |
|
| 156 |
/** |
| 157 |
* Machine-stable, versioned signature. The marker is placed first; the |
| 158 |
* description is fully normalized (paths -> basename, digits -> N, hex -> |
| 159 |
* 0xN) before it goes on the wire. |
| 160 |
* |
| 161 |
* @param ABJ_404_Solution_CrashBeacon $beacon |
| 162 |
* @return string |
| 163 |
*/ |
| 164 |
private function buildSignature(ABJ_404_Solution_CrashBeacon $beacon): string { |
| 165 |
$marker = '[abj404-crash-beacon:v1 plugin=' . $beacon->pluginVersion() . '] '; |
| 166 |
$description = 'type=' . $beacon->errorType() . ' ' |
| 167 |
. $beacon->relativeFile() . ':' . $beacon->line() . ' ' . $beacon->message(); |
| 168 |
$normalized = (string) call_user_func($this->normalizer, $description); |
| 169 |
return $marker . $normalized; |
| 170 |
} |
| 171 |
|
| 172 |
/** @return int epoch seconds */ |
| 173 |
private function now(): int { |
| 174 |
return (int) call_user_func($this->clock); |
| 175 |
} |
| 176 |
} |
| 177 |
|