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 / diagnostics / CrashBeacon.php

CrashBeacon.php in 404 Solution trunk, at includes/diagnostics/CrashBeacon.php

244 lines 9.8 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 /**
8 * Immutable record of a fatal-error "crash beacon".
9 *
10 * When a request fatals or runs out of memory, the HTTP telemetry path never
11 * runs, so the worst incidents are invisible to the feedback server by
12 * construction (see the 4.3.0 OOM, docs/crash-beacon-design.md). This value
13 * object captures the minimum PII-safe facts about a fatal so that a LATER
14 * healthy request (possibly after a plugin update, since the on-disk file lives
15 * in the uploads dir and survives the plugin-directory replacement) can phone
16 * home a post-mortem `error` report.
17 *
18 * Two hard constraints shape this class:
19 *
20 * 1. It is built inside the fatal shutdown handler where memory may already be
21 * exhausted. fromLastError() therefore uses ONLY primitives (no WP APIs, no
22 * service container, no heavy normalizer class) and does a cheap inline
23 * redaction. The full canonical normalization happens later, at report
24 * time, when memory is plentiful.
25 *
26 * 2. The serialized form is a forward-compatibility contract: a beacon written
27 * by plugin version X is read by the recovered version Y (which may be older
28 * OR newer). The format is versioned (SCHEMA_VERSION) and read tolerantly;
29 * a reader that does not recognise the version leaves the file alone rather
30 * than destroying evidence (handled by ABJ_404_Solution_CrashBeaconStore).
31 *
32 * PII: the persisted message is redacted AT REST (absolute paths folded to
33 * basename, non-printable bytes dropped, long digit runs folded) because the
34 * uploads dir can be web-accessible or backed up. The stored "file" is always
35 * plugin-relative or a bare basename, never an absolute server path.
36 */
37 final class ABJ_404_Solution_CrashBeacon {
38
39 /**
40 * On-disk format version. Bump ONLY on a breaking shape change. Readers
41 * discard older shapes and leave newer shapes untouched (see CrashBeaconStore).
42 */
43 const SCHEMA_VERSION = 1;
44
45 /** Hard cap on the persisted message so a runaway error string cannot bloat
46 * the beacon file during an OOM write. Kept small deliberately (PII + memory). */
47 const MESSAGE_MAX_LEN = 256;
48
49 /** @var string */
50 private $pluginVersion;
51 /** @var int PHP error-level constant (E_ERROR, E_PARSE, ...). */
52 private $errorType;
53 /** @var string plugin-relative path, or a bare basename for foreign-scope files. */
54 private $relativeFile;
55 /** @var int */
56 private $line;
57 /** @var string redacted, truncated, ASCII-only. */
58 private $message;
59 /** @var int epoch seconds. */
60 private $capturedAt;
61
62 /**
63 * @param string $pluginVersion
64 * @param int $errorType
65 * @param string $relativeFile
66 * @param int $line
67 * @param string $message
68 * @param int $capturedAt
69 */
70 public function __construct(string $pluginVersion, int $errorType, string $relativeFile, int $line, string $message, int $capturedAt) {
71 $this->pluginVersion = $pluginVersion;
72 $this->errorType = $errorType;
73 $this->relativeFile = $relativeFile;
74 $this->line = $line;
75 $this->message = $message;
76 $this->capturedAt = $capturedAt;
77 }
78
79 /**
80 * Build from a PHP error_get_last() shape using only primitives so it is
81 * safe to call inside the OOM fatal handler.
82 *
83 * @param array<string,mixed> $lasterror error_get_last() shape (type/file/line/message).
84 * @param string $pluginVersion ABJ404_VERSION.
85 * @param string $pluginRoot ABJ404_PATH (a define; no allocation) used to make the path plugin-relative.
86 * @param int $now epoch seconds (pass time() directly in the OOM path; do not load the clock service).
87 * @return self
88 */
89 public static function fromLastError(array $lasterror, string $pluginVersion, string $pluginRoot, int $now): self {
90 $type = isset($lasterror['type']) && is_scalar($lasterror['type']) ? (int)$lasterror['type'] : 0;
91 $file = isset($lasterror['file']) && is_string($lasterror['file']) ? $lasterror['file'] : '';
92 $line = isset($lasterror['line']) && is_scalar($lasterror['line']) ? (int)$lasterror['line'] : 0;
93 $rawMsg = isset($lasterror['message']) && is_string($lasterror['message']) ? $lasterror['message'] : '';
94
95 return new self(
96 $pluginVersion,
97 $type,
98 self::toPluginRelative($file, $pluginRoot),
99 $line,
100 self::lightRedact($rawMsg),
101 $now
102 );
103 }
104
105 /**
106 * Strip an absolute path to a plugin-relative one (e.g.
107 * "includes/core/PluginLogicOptionsResolver.php"). For a file outside the
108 * plugin root (foreign-scope fatal) fall back to the bare basename. NEVER
109 * returns an absolute path, so the server filesystem layout and any username
110 * in the path are not disclosed.
111 *
112 * @param string $file
113 * @param string $pluginRoot
114 * @return string
115 */
116 private static function toPluginRelative(string $file, string $pluginRoot): string {
117 if ($file === '') {
118 return '';
119 }
120 if ($pluginRoot !== '' && strpos($file, $pluginRoot) === 0) {
121 $rel = substr($file, strlen($pluginRoot));
122 return is_string($rel) && $rel !== '' ? $rel : basename($file);
123 }
124 return basename($file);
125 }
126
127 /**
128 * Cheap, dependency-free redaction safe to run during an OOM. Mirrors the
129 * first regexes of the canonical normalizer
130 * (ABJ_404_Solution_FeedbackEnvironmentExtras_DebugLogSignatures::normalizeErrorSignature),
131 * which cannot be loaded in the fatal handler. Truncate FIRST to cap
132 * allocation, then fold absolute paths to basename (PII at rest), drop
133 * non-printable/invalid-UTF-8 bytes (so json_encode cannot fail on the
134 * message), then fold long digit runs.
135 *
136 * The digit fold exempts a run immediately followed by "bytes": PHP's own
137 * OOM message ("Allowed memory size of N bytes exhausted (tried to
138 * allocate N bytes)") is the single most common message this captures,
139 * and a byte count is never PII -- it is the memory_limit/allocation-size
140 * diagnostic this whole crash-beacon feature exists to report. Folding it
141 * away defeated the feature for its primary case.
142 *
143 * @param string $msg
144 * @return string
145 */
146 private static function lightRedact(string $msg): string {
147 if (strlen($msg) > self::MESSAGE_MAX_LEN) {
148 $msg = substr($msg, 0, self::MESSAGE_MAX_LEN);
149 }
150 $msg = preg_replace('#/[A-Za-z0-9_\-\./]+/([A-Za-z0-9_\-]+\.php)#', '$1', $msg);
151 $msg = is_string($msg) ? $msg : '';
152 $msg = preg_replace('/[^\x20-\x7E]/', '', $msg);
153 $msg = is_string($msg) ? $msg : '';
154 $msg = preg_replace('/\b\d{4,}\b(?!\s*bytes\b)/i', 'N', $msg);
155 return is_string($msg) ? trim($msg) : '';
156 }
157
158 /**
159 * @return array<string,mixed> serializable form (the on-disk contract).
160 */
161 public function toArray(): array {
162 return array(
163 'beacon_schema_version' => self::SCHEMA_VERSION,
164 'plugin_version' => $this->pluginVersion,
165 'error_type' => $this->errorType,
166 'file' => $this->relativeFile,
167 'line' => $this->line,
168 'message' => $this->message,
169 'captured_at' => $this->capturedAt,
170 );
171 }
172
173 /**
174 * Tolerant reverse of toArray(). Returns null when $data is not an array or
175 * its beacon_schema_version is not the version this code understands. The
176 * caller (CrashBeaconStore) distinguishes "older/corrupt" (discardable) from
177 * "newer" (leave untouched) by inspecting the raw version itself, so this
178 * method only needs to accept the exact known version. Missing scalar fields
179 * default rather than fail.
180 *
181 * @param mixed $data
182 * @return self|null
183 */
184 public static function fromArray($data): ?self {
185 if (!is_array($data)) {
186 return null;
187 }
188 $ver = isset($data['beacon_schema_version']) && is_scalar($data['beacon_schema_version'])
189 ? (int)$data['beacon_schema_version'] : 0;
190 if ($ver !== self::SCHEMA_VERSION) {
191 return null;
192 }
193 return new self(
194 isset($data['plugin_version']) && is_scalar($data['plugin_version']) ? (string)$data['plugin_version'] : '',
195 isset($data['error_type']) && is_scalar($data['error_type']) ? (int)$data['error_type'] : 0,
196 isset($data['file']) && is_scalar($data['file']) ? (string)$data['file'] : '',
197 isset($data['line']) && is_scalar($data['line']) ? (int)$data['line'] : 0,
198 isset($data['message']) && is_scalar($data['message']) ? (string)$data['message'] : '',
199 isset($data['captured_at']) && is_scalar($data['captured_at']) ? (int)$data['captured_at'] : 0
200 );
201 }
202
203 /** @return string */
204 public function pluginVersion(): string {
205 return $this->pluginVersion;
206 }
207
208 /** @return int */
209 public function errorType(): int {
210 return $this->errorType;
211 }
212
213 /** @return string plugin-relative path or bare basename. */
214 public function relativeFile(): string {
215 return $this->relativeFile;
216 }
217
218 /** @return int */
219 public function line(): int {
220 return $this->line;
221 }
222
223 /** @return string */
224 public function message(): string {
225 return $this->message;
226 }
227
228 /** @return int */
229 public function capturedAt(): int {
230 return $this->capturedAt;
231 }
232
233 /**
234 * Stable grouping key for cooldown and "first crash wins": version + error
235 * location, NOT the timestamp or message detail. Matches how the feedback
236 * server groups error signatures.
237 *
238 * @return string
239 */
240 public function signatureKey(): string {
241 return $this->pluginVersion . '|' . $this->errorType . '|' . $this->relativeFile . '|' . $this->line;
242 }
243 }
244