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 / logs / LoggingFeedbackDispatcher.php

LoggingFeedbackDispatcher.php in 404 Solution trunk, at includes/logs/LoggingFeedbackDispatcher.php

216 lines 8.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 // allow-no-test-found: covered by tests/LoggingTest.php public Logging feedback entry-point tests.
9
10 /**
11 * Orchestrates debug-log feedback reports from the Logging facade.
12 *
13 * Owns the cron-context decisions for developer error reports and heartbeat
14 * reports: debug-file presence checks, latest-error dedupe state, update
15 * eligibility, FeedbackTransport payload construction, and weekly heartbeat
16 * guarding. File reading/writing and transport delivery remain delegated to
17 * their existing collaborators.
18 */
19 class ABJ_404_Solution_LoggingFeedbackDispatcher {
20
21 /** @var ABJ_404_Solution_Logging */
22 private $logging;
23 /** @var ABJ_404_Solution_ErrorEmailDedupeState|null */
24 private $dedupeState = null;
25 /** @var ABJ_404_Solution_LoggingStateStore|null */
26 private $loggingStateStore = null;
27
28 private const HEARTBEAT_MIN_INSTALL_AGE_SECONDS = 5 * 86400;
29 private const HEARTBEAT_INTERVAL_SECONDS = 7 * 86400;
30
31 /**
32 * @param ABJ_404_Solution_Logging $logging Stable facade used for public-path behavior.
33 */
34 public function __construct(ABJ_404_Solution_Logging $logging) {
35 $this->logging = $logging;
36 }
37
38 /**
39 * Send a developer error report when the debug log contains a new error.
40 *
41 * @return bool
42 */
43 public function emailErrorLogIfNecessary(): bool {
44 $debugFilePath = $this->logging->getDebugFilePath();
45 if (!file_exists($debugFilePath)) {
46 $this->logging->debugMessage("No log file found so no errors were found.");
47 return false;
48 }
49
50 $latestErrorLineFound = $this->logging->getLatestErrorLine();
51 if ($latestErrorLineFound['num'] == -1) {
52 $this->logging->debugMessage("No errors found in the log file.");
53 return false;
54 }
55
56 $dedupe = $this->getDedupeState();
57 $sentinelFilePath = $this->logging->getDebugFilePathSentFile();
58 $sentLine = $dedupe->readSentLine($sentinelFilePath);
59 $this->logging->debugMessage("Dedupe pointer: sentLine=" . $sentLine);
60
61 if ($dedupe->isAlreadySent($sentLine, $latestErrorLineFound, $debugFilePath)) {
62 $this->logging->debugMessage("The latest error line from the log file was already emailed. " .
63 $latestErrorLineFound['num'] . ' <= ' . $sentLine);
64 return false;
65 }
66
67 $pluginUpdateRepo = abj_service('plugin_update_metadata_repository');
68 if (!$pluginUpdateRepo->shouldEmailErrorFileFor($pluginUpdateRepo->getLatestPluginVersion())) {
69 return false;
70 }
71
72 if (!$dedupe->recordSent($sentinelFilePath, $debugFilePath, $latestErrorLineFound)) {
73 $this->logging->errorMessage("There was an issue writing to the file " . $sentinelFilePath);
74 return false;
75 }
76
77 try {
78 $payload = ABJ_404_Solution_FeedbackTransport::buildPayload('error', array(
79 'error_signature' => (string)($latestErrorLineFound['line'] ?? ''),
80 'previously_sent_line' => (int)$sentLine,
81 'error_count_in_log' => (int)$latestErrorLineFound['total_error_count'],
82 ));
83 return ABJ_404_Solution_FeedbackTransport::sendNow($payload, 'error');
84 } catch (\Throwable $e) {
85 // buildPayload() throws if the payload fails its schema
86 // contract; a build/transport failure must never escape this
87 // cron-context call and crash the rest of the maintenance run.
88 // errorMessage()'s second param only accepts Exception (not the
89 // wider Throwable an \Error also matches), so the message/class
90 // are folded into the log line itself rather than dropped.
91 $this->logDispatchFailure('emailErrorLogIfNecessary: report build/send failed', $e);
92 return false;
93 }
94 }
95
96 /**
97 * Send a heartbeat at most once per week after the plugin has been active
98 * for five days. The timestamp is persisted only after a successful
99 * transport send so failed reports retry on the next maintenance run.
100 *
101 * @return bool True if a heartbeat was sent successfully.
102 */
103 public function sendHeartbeatIfDueWeekly(): bool {
104 $now = abj_clock()->now();
105 $installedAt = $this->installedAt();
106 if ($installedAt <= 0) {
107 $this->logging->debugMessage("Weekly heartbeat skipped: installed time unavailable.");
108 return false;
109 }
110
111 $installAgeSeconds = $now - $installedAt;
112 if ($installAgeSeconds < self::HEARTBEAT_MIN_INSTALL_AGE_SECONDS) {
113 $this->logging->debugMessage("Weekly heartbeat skipped: plugin active for {$installAgeSeconds}s (< " .
114 self::HEARTBEAT_MIN_INSTALL_AGE_SECONDS . "s).");
115 return false;
116 }
117
118 $state = $this->getLoggingStateStore();
119 $lastSentAt = $state->getLastHeartbeatSentAt();
120 if ($lastSentAt > 0 && ($now - $lastSentAt) < self::HEARTBEAT_INTERVAL_SECONDS) {
121 $this->logging->debugMessage("Weekly heartbeat skipped: last sent at {$lastSentAt}.");
122 return false;
123 }
124
125 if (!file_exists($this->logging->getDebugFilePath())) {
126 return false;
127 }
128
129 $this->logging->debugMessage("Weekly heartbeat due. Sending heartbeat log.");
130 $errorInfo = $this->logging->getLatestErrorLine();
131
132 try {
133 $payload = ABJ_404_Solution_FeedbackTransport::buildPayload('heartbeat', array(
134 'error_signature' => 'Heartbeat: no errors to report.',
135 'previously_sent_line' => 0,
136 'error_count_in_log' => (int)$errorInfo['total_error_count'],
137 ));
138 $sent = ABJ_404_Solution_FeedbackTransport::sendNow($payload, 'heartbeat');
139 if ($sent) {
140 $state->setLastHeartbeatSentAt($now);
141 }
142 return $sent;
143 } catch (\Throwable $e) {
144 $this->logDispatchFailure('sendHeartbeatIfDueWeekly: report build/send failed', $e);
145 return false;
146 }
147 }
148
149 /**
150 * Record a report build/send failure at the severity its cause deserves.
151 *
152 * These run in the async maintenance (cron) context, which is also where a
153 * plugin self-update lands on a live request: WordPress swaps the plugin
154 * directory underneath the running process, and a class the incoming
155 * release added cannot be resolved (production report 266). That is a
156 * hosting event which fixes itself on the next run, so it degrades to a
157 * warning; a real schema-contract or transport failure still reports as an
158 * error.
159 *
160 * @param string $context Already-composed prefix naming the failed step.
161 * @param \Throwable $e
162 * @return void
163 */
164 private function logDispatchFailure($context, \Throwable $e) {
165 $message = $context . ': ' . get_class($e) . ': ' . $e->getMessage();
166 if (function_exists('abj404_logCallbackFailure')) {
167 abj404_logCallbackFailure($this->logging, $message, $e);
168 return;
169 }
170 $this->logging->errorMessage($message, $e instanceof \Exception ? $e : null);
171 }
172
173 /**
174 * Drain any pending crash beacon and report it as a post-mortem `error`
175 * report. Runs in the same async maintenance context as the error-log and
176 * heartbeat dispatch (and under the same send_error_logs opt-in), so a fatal
177 * or OOM that could not phone home at the time gets reported on the next
178 * healthy maintenance run (after a plugin update, for an every-request OOM).
179 *
180 * Delegates to FeedbackTransport (the Core telemetry facade this dispatcher
181 * already routes error/heartbeat sends through) so the crash-beacon reporter
182 * lives beside the other transports in the Core layer.
183 *
184 * @return bool True if a crash beacon was reported.
185 */
186 public function drainCrashBeaconIfNecessary(): bool {
187 return ABJ_404_Solution_FeedbackTransport::drainCrashBeacon();
188 }
189
190 /**
191 * @return ABJ_404_Solution_ErrorEmailDedupeState
192 */
193 private function getDedupeState(): ABJ_404_Solution_ErrorEmailDedupeState {
194 if ($this->dedupeState === null) {
195 $this->dedupeState = new ABJ_404_Solution_ErrorEmailDedupeState(
196 $this->getLoggingStateStore());
197 }
198 return $this->dedupeState;
199 }
200
201 private function getLoggingStateStore(): ABJ_404_Solution_LoggingStateStore {
202 if ($this->loggingStateStore === null) {
203 $this->loggingStateStore = ABJ_404_Solution_LoggingStateStore::resolve();
204 }
205 return $this->loggingStateStore;
206 }
207
208 private function installedAt(): int {
209 if (!function_exists('get_option')) {
210 return 0;
211 }
212 $installedAt = get_option('abj404_installed_time', null);
213 return is_scalar($installedAt) && is_numeric($installedAt) ? (int)$installedAt : 0;
214 }
215 }
216