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 / CrashBeaconReporter.php

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

177 lines 7.9 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__ . '/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