*/ public function collect(string $type): array { $payload = array(); $payload['published_posts_count'] = $this->tryInt('published_posts_count', function () { return $this->countPublishedPosts(); }); $payload['published_pages_count'] = $this->tryInt('published_pages_count', function () { return $this->countPublishedPages(); }); $payload['categories_count'] = $this->tryInt('categories_count', function () { return $this->countCategories(); }); $payload['tags_count'] = $this->tryInt('tags_count', function () { return $this->countTags(); }); $redirects = $this->statusCountsWithState( 'getRedirectStatusCountsResult', 'getRedirectStatusCounts' ); $redirectCounts = $redirects['counts']; $payload['redirects_active_total'] = $this->pluckInt($redirectCounts, 'all'); $payload['redirects_manual_count'] = $this->pluckInt($redirectCounts, 'manual'); $payload['redirects_automatic_count'] = $this->pluckInt($redirectCounts, 'auto'); $payload['redirects_regex_count'] = $this->pluckInt($redirectCounts, 'regex'); $payload['redirects_trashed_count'] = $this->pluckInt($redirectCounts, 'trash'); $payload['redirects_status_counts_state'] = $redirects['state']; $payload['redirect_hit_count_histogram'] = $this->redirectHitCountHistogram(); $captured = $this->statusCountsWithState( 'getCapturedStatusCountsResult', 'getCapturedStatusCounts' ); $capturedCounts = $captured['counts']; $payload['captured_404s_active_total'] = $this->pluckInt($capturedCounts, 'all'); $payload['captured_404s_new_count'] = $this->pluckInt($capturedCounts, 'captured'); $payload['captured_404s_ignored_count'] = $this->pluckInt($capturedCounts, 'ignored'); $payload['captured_404s_later_count'] = $this->pluckInt($capturedCounts, 'later'); $payload['captured_404s_trashed_count'] = $this->pluckInt($capturedCounts, 'trash'); $payload['captured_404s_status_counts_state'] = $captured['state']; $payload['log_entries_count'] = $this->tryInt('log_entries_count', function () { return $this->logEntriesCount(); }); $payload['log_table_size_bytes'] = $this->tryInt('log_table_size_bytes', function () { return $this->logTableSizeBytes(); }); $payload += $this->debugLogReportFields($type); return $payload; } /** * Run one count lookup. A failed lookup and a "could not determine" * answer both ship as null, the value the report schema reserves for an * unknown count; neither may fail the payload. * * @param string $field Payload field the count fills, for the log line. */ private function tryInt(string $field, callable $fn): ?int { try { $v = $fn(); } catch (\Throwable $e) { ABJ_404_Solution_FeedbackTransportLog::log('warn', 'FeedbackDiagnosticsCollector count lookup failed for ' . $field . ': ' . $e->getMessage()); return null; } return is_int($v) ? self::knownCount($field, $v) : null; } /** * The single owner of the unknown-count rule. The read layer answers * "could not determine" with a negative count (LogsMetricsReader returns * -1 when a count or size query fails or times out). Copied into the * payload, that sentinel violates report.schema.json's minimum of 0 and * FeedbackPayloadSchemaGuard::assert() refuses the whole report, so a site * with a failing log query could send no error report, heartbeat or * support request at all. A negative count is unknown: report it as null. * * @param string $field Payload field or map key, for the log line. * @param int $count The count as the read layer reported it. * @return int|null The count, or null when the reader could not determine it. */ private static function knownCount(string $field, int $count): ?int { if ($count >= 0) { return $count; } ABJ_404_Solution_FeedbackTransportLog::log('warn', 'FeedbackDiagnosticsCollector ' . $field . ' could not be determined (reader answered ' . $count . '); reporting it as unknown'); return null; } private function tryString(callable $fn): string { try { $v = $fn(); return is_string($v) ? $v : ''; } catch (\Throwable $e) { ABJ_404_Solution_FeedbackTransportLog::log('warn', 'FeedbackDiagnosticsCollector string lookup failed: ' . $e->getMessage()); return ''; } } /** * @return array */ private function tryArray(callable $fn): array { try { $v = $fn(); if (!is_array($v)) { return array(); } $coerced = array(); foreach ($v as $k => $val) { if (is_string($k) && is_int($val)) { $coerced[$k] = $val; } } return $coerced; } catch (\Throwable $e) { ABJ_404_Solution_FeedbackTransportLog::log('warn', 'FeedbackDiagnosticsCollector array lookup failed: ' . $e->getMessage()); return array(); } } /** * @param array $map */ private function pluckInt(array $map, string $key): ?int { if (!array_key_exists($key, $map)) { return null; } $v = $map[$key]; return is_scalar($v) ? self::knownCount($key, (int)$v) : null; } private function countPublishedPosts(): int { if (!function_exists('wp_count_posts')) { throw new \RuntimeException('wp_count_posts unavailable'); } $posts = wp_count_posts(); if (is_object($posts) && isset($posts->publish) && is_scalar($posts->publish)) { return (int)$posts->publish; } throw new \RuntimeException('wp_count_posts returned unexpected shape'); } private function countPublishedPages(): int { if (!function_exists('wp_count_posts')) { throw new \RuntimeException('wp_count_posts unavailable'); } $pages = wp_count_posts('page'); if (is_object($pages) && isset($pages->publish) && is_scalar($pages->publish)) { return (int)$pages->publish; } throw new \RuntimeException('wp_count_posts(page) returned unexpected shape'); } private function countCategories(): int { if (!function_exists('wp_count_terms')) { throw new \RuntimeException('wp_count_terms unavailable'); } $v = wp_count_terms(array('taxonomy' => 'category')); if (function_exists('is_wp_error') && is_wp_error($v)) { throw new \RuntimeException('wp_count_terms(category) returned WP_Error'); } if (is_scalar($v)) { return (int)$v; } throw new \RuntimeException('wp_count_terms(category) returned unexpected shape'); } private function countTags(): int { if (!function_exists('wp_count_terms')) { throw new \RuntimeException('wp_count_terms unavailable'); } $v = wp_count_terms(array('taxonomy' => 'post_tag')); if (function_exists('is_wp_error') && is_wp_error($v)) { throw new \RuntimeException('wp_count_terms(post_tag) returned WP_Error'); } if (is_scalar($v)) { return (int)$v; } throw new \RuntimeException('wp_count_terms(post_tag) returned unexpected shape'); } /** * Read one status-count scope together with the cache state that produced * it. Without the state, a support report cannot tell a site that has * genuinely zero redirects from one whose count has never been computed: * both arrive as NULL/0 tallies. * * @param string $resultMethod State-carrying accessor (preferred). * @param string $flatMethod Legacy flat accessor, used when a read service * predates the state-carrying one; its `_incomplete` marker still * distinguishes uncomputed from computed. * @return array{counts: array, state: string} */ private function statusCountsWithState(string $resultMethod, string $flatMethod): array { try { $viewReadService = $this->viewReadService(); if ($viewReadService === null) { throw new \RuntimeException('view_read_service unavailable'); } if (method_exists($viewReadService, $resultMethod)) { return self::normalizeStatusCountsResult( $resultMethod, $viewReadService->{$resultMethod}() ); } if (!method_exists($viewReadService, $flatMethod)) { throw new \RuntimeException('ViewReadService::' . $flatMethod . ' unavailable'); } $flat = $viewReadService->{$flatMethod}(); if (!is_array($flat)) { throw new \RuntimeException($flatMethod . ' returned non-array'); } $counts = self::intMap($flat); return array( 'counts' => $counts, 'state' => empty($counts['_incomplete']) ? ABJ_404_Solution_StatusCountsRefreshCoordinator::STATE_FRESH : ABJ_404_Solution_StatusCountsRefreshCoordinator::STATE_UNCOMPUTED, ); } catch (\Throwable $e) { ABJ_404_Solution_FeedbackTransportLog::log('warn', 'FeedbackDiagnosticsCollector status-count lookup failed: ' . $e->getMessage()); return array('counts' => array(), 'state' => self::STATUS_COUNTS_STATE_UNAVAILABLE); } } /** * @param mixed $raw * @return array{counts: array, state: string} */ private static function normalizeStatusCountsResult(string $method, $raw): array { if (!is_array($raw) || !isset($raw['counts']) || !is_array($raw['counts']) || !isset($raw['state']) || !is_string($raw['state']) || $raw['state'] === '') { throw new \RuntimeException($method . ' returned an unexpected shape'); } return array('counts' => self::intMap($raw['counts']), 'state' => $raw['state']); } /** * @param array $raw * @return array */ private static function intMap(array $raw): array { $out = array(); foreach ($raw as $k => $v) { if (is_string($k) && is_scalar($v)) { $out[$k] = (int)$v; } } return $out; } /** * @return array|null */ private function redirectHitCountHistogram(): ?array { $histogram = $this->tryArray(function () { return $this->redirectHitCountHistogramRaw(); }); if (empty($histogram)) { return null; } $buckets = array( 'zero_hits' => 0, 'one_to_ten_hits' => 0, 'eleven_to_hundred_hits' => 0, 'over_hundred_hits' => 0, ); foreach ($buckets as $key => $_default) { if (!array_key_exists($key, $histogram)) { continue; } $count = $this->pluckInt($histogram, $key); if ($count === null) { // One uncounted bucket makes the distribution unknown; // shipping it as 0 would misstate how the hits are spread. return null; } $buckets[$key] = $count; } return $buckets; } /** * @return array */ private function redirectHitCountHistogramRaw(): array { $viewReadService = $this->viewReadService(); if ($viewReadService === null || !method_exists($viewReadService, 'getRedirectHitCountHistogram')) { throw new \RuntimeException('ViewReadService::getRedirectHitCountHistogram unavailable'); } $raw = $viewReadService->getRedirectHitCountHistogram(); if (!is_array($raw)) { throw new \RuntimeException('getRedirectHitCountHistogram returned non-array'); } $out = array(); foreach ($raw as $k => $v) { if (is_string($k) && is_scalar($v)) { $out[$k] = (int)$v; } } return $out; } private function logEntriesCount(): int { $viewReadService = $this->viewReadService(); if ($viewReadService === null || !method_exists($viewReadService, 'getLogsCount')) { throw new \RuntimeException('ViewReadService::getLogsCount unavailable'); } $v = $viewReadService->getLogsCount(0); if (is_scalar($v)) { return (int)$v; } throw new \RuntimeException('getLogsCount returned unexpected shape'); } private function logTableSizeBytes(): int { $dao = $this->viewReadService(); if ($dao === null || !method_exists($dao, 'getLogDiskUsage')) { throw new \RuntimeException('DataAccess::getLogDiskUsage unavailable'); } $v = $dao->getLogDiskUsage(); if (!is_scalar($v)) { throw new \RuntimeException('getLogDiskUsage returned unexpected shape'); } // A -1 "query failed" answer is mapped to null by knownCount(). return (int)$v; } /** * Count, size, tail, and anchor are projected from one reader result. The * production Logging facade exposes the snapshot API; the compatibility * branch keeps older test doubles and partially upgraded installs usable. * * @return array */ private function debugLogReportFields(string $type): array { try { $evidence = $this->debugLogReportEvidence(); $fields = array( 'error_count_in_log' => isset($evidence['total_error_count']) && is_scalar($evidence['total_error_count']) ? (int)$evidence['total_error_count'] : null, 'debug_file_size_bytes' => isset($evidence['debug_file_size_bytes']) && is_scalar($evidence['debug_file_size_bytes']) ? (int)$evidence['debug_file_size_bytes'] : null, ); if ($type === 'error') { $fields['debug_log'] = isset($evidence['debug_log']) && is_string($evidence['debug_log']) ? $evidence['debug_log'] : ''; $fields['debug_log_evidence'] = isset($evidence['debug_log_evidence']) && is_array($evidence['debug_log_evidence']) ? $evidence['debug_log_evidence'] : ABJ_404_Solution_DebugLogEvidenceBudget::emptyEvidence()['debug_log_evidence']; } return $fields; } catch (\Throwable $e) { ABJ_404_Solution_FeedbackTransportLog::log( 'warn', 'FeedbackDiagnosticsCollector debug-log snapshot failed: ' . $e->getMessage() ); $fields = array('error_count_in_log' => null, 'debug_file_size_bytes' => null); if ($type === 'error') { $fields['debug_log'] = ''; $fields['debug_log_evidence'] = ABJ_404_Solution_DebugLogEvidenceBudget::emptyEvidence()['debug_log_evidence']; } return $fields; } } /** @return array */ private function debugLogReportEvidence(): array { if (!function_exists('abj_service')) { throw new \RuntimeException('abj_service unavailable'); } $logger = abj_service('logging'); if (!is_object($logger)) { throw new \RuntimeException('Logging service unavailable'); } if (method_exists($logger, 'getDebugLogSnapshot')) { $snapshot = $logger->getDebugLogSnapshot(); if (is_array($snapshot)) { return $this->shapeDebugLogSnapshot($snapshot); } throw new \RuntimeException('getDebugLogSnapshot returned unexpected shape'); } if (method_exists($logger, 'getDebugFilePath')) { $path = $logger->getDebugFilePath(); if (is_string($path) && $path !== '' && class_exists('ABJ_404_Solution_DebugLogReader')) { $reader = new ABJ_404_Solution_DebugLogReader(function (string $message): void { ABJ_404_Solution_FeedbackTransportLog::log('warn', $message); }); return $this->shapeDebugLogSnapshot($reader->getSnapshot($path)); } } if (!method_exists($logger, 'getLatestErrorLine')) { throw new \RuntimeException('Logging debug-log readers unavailable'); } $latest = $logger->getLatestErrorLine(); if (!is_array($latest)) { throw new \RuntimeException('getLatestErrorLine returned unexpected shape'); } return array( 'total_error_count' => isset($latest['total_error_count']) && is_scalar($latest['total_error_count']) ? (int)$latest['total_error_count'] : 0, 'debug_file_size_bytes' => 0, 'debug_log' => '', 'debug_log_evidence' => ABJ_404_Solution_DebugLogEvidenceBudget::emptyEvidence()['debug_log_evidence'], ); } /** @return array */ /** * @param array $snapshot * @return array */ private function shapeDebugLogSnapshot(array $snapshot): array { $shaped = ABJ_404_Solution_DebugLogEvidenceBudget::fromSnapshot($snapshot); return array( 'total_error_count' => isset($snapshot['total_error_count']) && is_scalar($snapshot['total_error_count']) ? (int)$snapshot['total_error_count'] : 0, 'debug_file_size_bytes' => isset($snapshot['file_size']) && is_scalar($snapshot['file_size']) ? (int)$snapshot['file_size'] : 0, 'debug_log' => $shaped['debug_log'], 'debug_log_evidence' => $shaped['debug_log_evidence'], ); } private function viewReadService(): ?object { if (!function_exists('abj_service_optional')) { return null; } $svc = abj_service_optional('view_read_service'); return is_object($svc) ? $svc : null; } }