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

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

427 lines 17.7 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__ . '/DebugLogEvidenceBudget.php';
8 require_once __DIR__ . '/FeedbackTransportLog.php';
9
10 /**
11 * Collects content, redirect, captured-404, log, and debug-file diagnostics
12 * for feedback payloads. Each source degrades independently so one broken
13 * optional service cannot abort the whole report.
14 */
15 class ABJ_404_Solution_FeedbackDiagnosticsCollector {
16
17 /**
18 * Status-count freshness reported alongside the tallies. The three
19 * cache-derived values mirror
20 * ABJ_404_Solution_StatusCountsRefreshCoordinator::STATE_*; `unavailable`
21 * is this collector's own state for "the read service could not be
22 * reached at all", which is otherwise indistinguishable from a cold cache
23 * because both ship NULL counts.
24 */
25 const STATUS_COUNTS_STATE_UNAVAILABLE = 'unavailable';
26
27 /**
28 * The state a minimal / diagnostics-redacted payload carries. The counts
29 * were deliberately withheld, which is a different fact from a cold cache
30 * or an unreachable service, and saying so keeps the discriminator honest.
31 */
32 const STATUS_COUNTS_STATE_REDACTED = 'redacted';
33
34 /**
35 * @return array<string, mixed>
36 */
37 public function collect(string $type): array {
38 $payload = array();
39
40 $payload['published_posts_count'] = $this->tryInt(function () { return $this->countPublishedPosts(); });
41 $payload['published_pages_count'] = $this->tryInt(function () { return $this->countPublishedPages(); });
42 $payload['categories_count'] = $this->tryInt(function () { return $this->countCategories(); });
43 $payload['tags_count'] = $this->tryInt(function () { return $this->countTags(); });
44
45 $redirects = $this->statusCountsWithState(
46 'getRedirectStatusCountsResult', 'getRedirectStatusCounts'
47 );
48 $redirectCounts = $redirects['counts'];
49 $payload['redirects_active_total'] = $this->pluckInt($redirectCounts, 'all');
50 $payload['redirects_manual_count'] = $this->pluckInt($redirectCounts, 'manual');
51 $payload['redirects_automatic_count'] = $this->pluckInt($redirectCounts, 'auto');
52 $payload['redirects_regex_count'] = $this->pluckInt($redirectCounts, 'regex');
53 $payload['redirects_trashed_count'] = $this->pluckInt($redirectCounts, 'trash');
54 $payload['redirects_status_counts_state'] = $redirects['state'];
55 $payload['redirect_hit_count_histogram'] = $this->redirectHitCountHistogram();
56
57 $captured = $this->statusCountsWithState(
58 'getCapturedStatusCountsResult', 'getCapturedStatusCounts'
59 );
60 $capturedCounts = $captured['counts'];
61 $payload['captured_404s_active_total'] = $this->pluckInt($capturedCounts, 'all');
62 $payload['captured_404s_new_count'] = $this->pluckInt($capturedCounts, 'captured');
63 $payload['captured_404s_ignored_count'] = $this->pluckInt($capturedCounts, 'ignored');
64 $payload['captured_404s_later_count'] = $this->pluckInt($capturedCounts, 'later');
65 $payload['captured_404s_trashed_count'] = $this->pluckInt($capturedCounts, 'trash');
66 $payload['captured_404s_status_counts_state'] = $captured['state'];
67
68 $payload['log_entries_count'] = $this->tryInt(function () { return $this->logEntriesCount(); });
69 $payload['log_table_size_bytes'] = $this->tryInt(function () { return $this->logTableSizeBytes(); });
70 $payload += $this->debugLogReportFields($type);
71
72 return $payload;
73 }
74
75 private function tryInt(callable $fn): ?int {
76 try {
77 $v = $fn();
78 return is_int($v) ? $v : null;
79 } catch (\Throwable $e) {
80 ABJ_404_Solution_FeedbackTransportLog::log('warn', 'FeedbackDiagnosticsCollector count lookup failed: ' . $e->getMessage());
81 return null;
82 }
83 }
84
85 private function tryString(callable $fn): string {
86 try {
87 $v = $fn();
88 return is_string($v) ? $v : '';
89 } catch (\Throwable $e) {
90 ABJ_404_Solution_FeedbackTransportLog::log('warn', 'FeedbackDiagnosticsCollector string lookup failed: ' . $e->getMessage());
91 return '';
92 }
93 }
94
95 /**
96 * @return array<string, int>
97 */
98 private function tryArray(callable $fn): array {
99 try {
100 $v = $fn();
101 if (!is_array($v)) {
102 return array();
103 }
104 $coerced = array();
105 foreach ($v as $k => $val) {
106 if (is_string($k) && is_int($val)) {
107 $coerced[$k] = $val;
108 }
109 }
110 return $coerced;
111 } catch (\Throwable $e) {
112 ABJ_404_Solution_FeedbackTransportLog::log('warn', 'FeedbackDiagnosticsCollector array lookup failed: ' . $e->getMessage());
113 return array();
114 }
115 }
116
117 /**
118 * @param array<string, mixed> $map
119 */
120 private function pluckInt(array $map, string $key): ?int {
121 if (!array_key_exists($key, $map)) {
122 return null;
123 }
124 $v = $map[$key];
125 return is_scalar($v) ? (int)$v : null;
126 }
127
128 private function countPublishedPosts(): int {
129 if (!function_exists('wp_count_posts')) {
130 throw new \RuntimeException('wp_count_posts unavailable');
131 }
132 $posts = wp_count_posts();
133 if (is_object($posts) && isset($posts->publish) && is_scalar($posts->publish)) {
134 return (int)$posts->publish;
135 }
136 throw new \RuntimeException('wp_count_posts returned unexpected shape');
137 }
138
139 private function countPublishedPages(): int {
140 if (!function_exists('wp_count_posts')) {
141 throw new \RuntimeException('wp_count_posts unavailable');
142 }
143 $pages = wp_count_posts('page');
144 if (is_object($pages) && isset($pages->publish) && is_scalar($pages->publish)) {
145 return (int)$pages->publish;
146 }
147 throw new \RuntimeException('wp_count_posts(page) returned unexpected shape');
148 }
149
150 private function countCategories(): int {
151 if (!function_exists('wp_count_terms')) {
152 throw new \RuntimeException('wp_count_terms unavailable');
153 }
154 $v = wp_count_terms(array('taxonomy' => 'category'));
155 if (function_exists('is_wp_error') && is_wp_error($v)) {
156 throw new \RuntimeException('wp_count_terms(category) returned WP_Error');
157 }
158 if (is_scalar($v)) {
159 return (int)$v;
160 }
161 throw new \RuntimeException('wp_count_terms(category) returned unexpected shape');
162 }
163
164 private function countTags(): int {
165 if (!function_exists('wp_count_terms')) {
166 throw new \RuntimeException('wp_count_terms unavailable');
167 }
168 $v = wp_count_terms(array('taxonomy' => 'post_tag'));
169 if (function_exists('is_wp_error') && is_wp_error($v)) {
170 throw new \RuntimeException('wp_count_terms(post_tag) returned WP_Error');
171 }
172 if (is_scalar($v)) {
173 return (int)$v;
174 }
175 throw new \RuntimeException('wp_count_terms(post_tag) returned unexpected shape');
176 }
177
178 /**
179 * Read one status-count scope together with the cache state that produced
180 * it. Without the state, a support report cannot tell a site that has
181 * genuinely zero redirects from one whose count has never been computed:
182 * both arrive as NULL/0 tallies.
183 *
184 * @param string $resultMethod State-carrying accessor (preferred).
185 * @param string $flatMethod Legacy flat accessor, used when a read service
186 * predates the state-carrying one; its `_incomplete` marker still
187 * distinguishes uncomputed from computed.
188 * @return array{counts: array<string, int>, state: string}
189 */
190 private function statusCountsWithState(string $resultMethod, string $flatMethod): array {
191 try {
192 $viewReadService = $this->viewReadService();
193 if ($viewReadService === null) {
194 throw new \RuntimeException('view_read_service unavailable');
195 }
196 if (method_exists($viewReadService, $resultMethod)) {
197 return self::normalizeStatusCountsResult(
198 $resultMethod,
199 $viewReadService->{$resultMethod}()
200 );
201 }
202 if (!method_exists($viewReadService, $flatMethod)) {
203 throw new \RuntimeException('ViewReadService::' . $flatMethod . ' unavailable');
204 }
205 $flat = $viewReadService->{$flatMethod}();
206 if (!is_array($flat)) {
207 throw new \RuntimeException($flatMethod . ' returned non-array');
208 }
209 $counts = self::intMap($flat);
210 return array(
211 'counts' => $counts,
212 'state' => empty($counts['_incomplete'])
213 ? ABJ_404_Solution_StatusCountsRefreshCoordinator::STATE_FRESH
214 : ABJ_404_Solution_StatusCountsRefreshCoordinator::STATE_UNCOMPUTED,
215 );
216 } catch (\Throwable $e) {
217 ABJ_404_Solution_FeedbackTransportLog::log('warn',
218 'FeedbackDiagnosticsCollector status-count lookup failed: ' . $e->getMessage());
219 return array('counts' => array(), 'state' => self::STATUS_COUNTS_STATE_UNAVAILABLE);
220 }
221 }
222
223 /**
224 * @param mixed $raw
225 * @return array{counts: array<string, int>, state: string}
226 */
227 private static function normalizeStatusCountsResult(string $method, $raw): array {
228 if (!is_array($raw) || !isset($raw['counts']) || !is_array($raw['counts'])
229 || !isset($raw['state']) || !is_string($raw['state']) || $raw['state'] === '') {
230 throw new \RuntimeException($method . ' returned an unexpected shape');
231 }
232 return array('counts' => self::intMap($raw['counts']), 'state' => $raw['state']);
233 }
234
235 /**
236 * @param array<mixed, mixed> $raw
237 * @return array<string, int>
238 */
239 private static function intMap(array $raw): array {
240 $out = array();
241 foreach ($raw as $k => $v) {
242 if (is_string($k) && is_scalar($v)) {
243 $out[$k] = (int)$v;
244 }
245 }
246 return $out;
247 }
248
249 /**
250 * @return array<string, int>|null
251 */
252 private function redirectHitCountHistogram(): ?array {
253 $histogram = $this->tryArray(function () { return $this->redirectHitCountHistogramRaw(); });
254 if (empty($histogram)) {
255 return null;
256 }
257 $buckets = array(
258 'zero_hits' => 0,
259 'one_to_ten_hits' => 0,
260 'eleven_to_hundred_hits' => 0,
261 'over_hundred_hits' => 0,
262 );
263 foreach ($buckets as $key => $_default) {
264 $buckets[$key] = $this->pluckInt($histogram, $key) ?? 0;
265 }
266 return $buckets;
267 }
268
269 /**
270 * @return array<string, int>
271 */
272 private function redirectHitCountHistogramRaw(): array {
273 $viewReadService = $this->viewReadService();
274 if ($viewReadService === null || !method_exists($viewReadService, 'getRedirectHitCountHistogram')) {
275 throw new \RuntimeException('ViewReadService::getRedirectHitCountHistogram unavailable');
276 }
277 $raw = $viewReadService->getRedirectHitCountHistogram();
278 if (!is_array($raw)) {
279 throw new \RuntimeException('getRedirectHitCountHistogram returned non-array');
280 }
281 $out = array();
282 foreach ($raw as $k => $v) {
283 if (is_string($k) && is_scalar($v)) {
284 $out[$k] = (int)$v;
285 }
286 }
287 return $out;
288 }
289
290 private function logEntriesCount(): int {
291 $viewReadService = $this->viewReadService();
292 if ($viewReadService === null || !method_exists($viewReadService, 'getLogsCount')) {
293 throw new \RuntimeException('ViewReadService::getLogsCount unavailable');
294 }
295 $v = $viewReadService->getLogsCount(0);
296 if (is_scalar($v)) {
297 return (int)$v;
298 }
299 throw new \RuntimeException('getLogsCount returned unexpected shape');
300 }
301
302 private function logTableSizeBytes(): int {
303 $dao = $this->viewReadService();
304 if ($dao === null || !method_exists($dao, 'getLogDiskUsage')) {
305 throw new \RuntimeException('DataAccess::getLogDiskUsage unavailable');
306 }
307 $v = $dao->getLogDiskUsage();
308 if (!is_scalar($v)) {
309 throw new \RuntimeException('getLogDiskUsage returned unexpected shape');
310 }
311 $bytes = (int)$v;
312 if ($bytes < 0) {
313 // -1 is the documented "query failed / unknown" sentinel from
314 // LogsMetricsReader::getLogDiskUsage(). Map to null via tryInt()
315 // so the feedback payload matches its schema (minimum: 0 | null).
316 throw new \RuntimeException('getLogDiskUsage unknown (query failed)');
317 }
318 return $bytes;
319 }
320
321 /**
322 * Count, size, tail, and anchor are projected from one reader result. The
323 * production Logging facade exposes the snapshot API; the compatibility
324 * branch keeps older test doubles and partially upgraded installs usable.
325 *
326 * @return array<string, mixed>
327 */
328 private function debugLogReportFields(string $type): array {
329 try {
330 $evidence = $this->debugLogReportEvidence();
331 $fields = array(
332 'error_count_in_log' => isset($evidence['total_error_count'])
333 && is_scalar($evidence['total_error_count'])
334 ? (int)$evidence['total_error_count'] : null,
335 'debug_file_size_bytes' => isset($evidence['debug_file_size_bytes'])
336 && is_scalar($evidence['debug_file_size_bytes'])
337 ? (int)$evidence['debug_file_size_bytes'] : null,
338 );
339 if ($type === 'error') {
340 $fields['debug_log'] = isset($evidence['debug_log']) && is_string($evidence['debug_log'])
341 ? $evidence['debug_log'] : '';
342 $fields['debug_log_evidence'] = isset($evidence['debug_log_evidence'])
343 && is_array($evidence['debug_log_evidence'])
344 ? $evidence['debug_log_evidence'] : ABJ_404_Solution_DebugLogEvidenceBudget::emptyEvidence()['debug_log_evidence'];
345 }
346 return $fields;
347 } catch (\Throwable $e) {
348 ABJ_404_Solution_FeedbackTransportLog::log(
349 'warn',
350 'FeedbackDiagnosticsCollector debug-log snapshot failed: ' . $e->getMessage()
351 );
352 $fields = array('error_count_in_log' => null, 'debug_file_size_bytes' => null);
353 if ($type === 'error') {
354 $fields['debug_log'] = '';
355 $fields['debug_log_evidence'] = ABJ_404_Solution_DebugLogEvidenceBudget::emptyEvidence()['debug_log_evidence'];
356 }
357 return $fields;
358 }
359 }
360
361 /** @return array<string, mixed> */
362 private function debugLogReportEvidence(): array {
363 if (!function_exists('abj_service')) {
364 throw new \RuntimeException('abj_service unavailable');
365 }
366 $logger = abj_service('logging');
367 if (!is_object($logger)) {
368 throw new \RuntimeException('Logging service unavailable');
369 }
370 if (method_exists($logger, 'getDebugLogSnapshot')) {
371 $snapshot = $logger->getDebugLogSnapshot();
372 if (is_array($snapshot)) {
373 return $this->shapeDebugLogSnapshot($snapshot);
374 }
375 throw new \RuntimeException('getDebugLogSnapshot returned unexpected shape');
376 }
377 if (method_exists($logger, 'getDebugFilePath')) {
378 $path = $logger->getDebugFilePath();
379 if (is_string($path) && $path !== '' && class_exists('ABJ_404_Solution_DebugLogReader')) {
380 $reader = new ABJ_404_Solution_DebugLogReader(function (string $message): void {
381 ABJ_404_Solution_FeedbackTransportLog::log('warn', $message);
382 });
383 return $this->shapeDebugLogSnapshot($reader->getSnapshot($path));
384 }
385 }
386 if (!method_exists($logger, 'getLatestErrorLine')) {
387 throw new \RuntimeException('Logging debug-log readers unavailable');
388 }
389 $latest = $logger->getLatestErrorLine();
390 if (!is_array($latest)) {
391 throw new \RuntimeException('getLatestErrorLine returned unexpected shape');
392 }
393 return array(
394 'total_error_count' => isset($latest['total_error_count']) && is_scalar($latest['total_error_count'])
395 ? (int)$latest['total_error_count'] : 0,
396 'debug_file_size_bytes' => 0,
397 'debug_log' => '',
398 'debug_log_evidence' => ABJ_404_Solution_DebugLogEvidenceBudget::emptyEvidence()['debug_log_evidence'],
399 );
400 }
401
402 /** @return array<string, mixed> */
403 /**
404 * @param array<string, mixed> $snapshot
405 * @return array<string, mixed>
406 */
407 private function shapeDebugLogSnapshot(array $snapshot): array {
408 $shaped = ABJ_404_Solution_DebugLogEvidenceBudget::fromSnapshot($snapshot);
409 return array(
410 'total_error_count' => isset($snapshot['total_error_count']) && is_scalar($snapshot['total_error_count'])
411 ? (int)$snapshot['total_error_count'] : 0,
412 'debug_file_size_bytes' => isset($snapshot['file_size']) && is_scalar($snapshot['file_size'])
413 ? (int)$snapshot['file_size'] : 0,
414 'debug_log' => $shaped['debug_log'],
415 'debug_log_evidence' => $shaped['debug_log_evidence'],
416 );
417 }
418
419 private function viewReadService(): ?object {
420 if (!function_exists('abj_service_optional')) {
421 return null;
422 }
423 $svc = abj_service_optional('view_read_service');
424 return is_object($svc) ? $svc : null;
425 }
426 }
427