$knownFailingIds Requests condemned across every * journal, so this excerpt and the stage-trace one rank identically. This * journal already contains its own verdicts; passing the union in is what * makes the two agree rather than each ranking off what it happens to hold. * @param array{paths: array, manifest: array}|null $fileSelection * Shared selection plan used by the primary support manifest. */ public static function readRecentForSupport( array $knownFailingIds = array(), ?array $fileSelection = null ): string { $source = self::supportCollectionSource(); $paths = $source['paths']; if ($paths === array()) { return ''; } $required = self::requiredSupportEvidence($paths); $requiredBlock = $required === '' ? '' : self::REQUIRED_EVIDENCE_HEADING . $required; $rankedBudget = self::MAX_SUPPORT_EXCERPT_BYTES - ($requiredBlock === '' ? 0 : strlen($requiredBlock) + 1); $activePath = ABJ_404_Solution_DurableOperationRecorder::activePath( $source['directory'] ); $rankedPaths = array_values(array_filter( $paths, static fn(string $path): bool => $path !== $activePath )); $rankedSelection = self::withoutPathFromSelection($fileSelection, $activePath); $activeLines = $activePath === '' ? array() : ABJ_404_Solution_ActiveOperationBreadcrumbState::compactSupportLines( ABJ_404_Solution_DiagnosticJournalExcerpt::readAllLines(array($activePath)) ); $activeClosedIds = ABJ_404_Solution_CheckpointIntentCorrelation::closedCheckpointIds($activeLines); $ranked = ABJ_404_Solution_DiagnosticJournalExcerpt::compose( $rankedPaths, max(0, $rankedBudget), "Recent AJAX request checkpoints (JSONL):\n", $knownFailingIds, static function (array $lines) use ($activeClosedIds): array { return self::compactForSupport($lines, $activeClosedIds); }, $rankedSelection ); if ($requiredBlock === '') { return $ranked; } return $ranked === '' ? $requiredBlock : $requiredBlock . "\n" . $ranked; } /** * Latest complete record of each evidence type that must bypass ordinary * request-group ranking, fitted to MAX_REQUIRED_EVIDENCE_BYTES. * * The selector owns the record schemas and what each record is worth. The * support compaction runs over the whole selection first, because it picks * the slowest write across it; the fit then measures the compacted bytes, * which are the bytes the report actually carries. * * @param array $paths */ private static function requiredSupportEvidence(array $paths): string { $lines = ABJ_404_Solution_DurableOperationRecorder::compactSupportLines( ABJ_404_Solution_DiagnosticJournalExcerpt::readAllLines($paths) ); $units = ABJ_404_Solution_RequiredCheckpointEvidence::selectUnits($lines); $flat = array(); foreach ($units as $unit) { foreach ($unit['lines'] as $line) { $flat[] = $line; } } $compacted = self::compactRoutinePhaseMaps($flat); if (count($compacted) !== count($flat)) { // The redaction policy is mid-update and answered with its notice. return implode("\n", $compacted); } $offset = 0; foreach ($units as $position => $unit) { $count = count($unit['lines']); $units[$position]['lines'] = array_slice($compacted, $offset, $count); $offset += $count; } return implode("\n", ABJ_404_Solution_RequiredCheckpointEvidenceBudget::fit( $units, self::MAX_REQUIRED_EVIDENCE_BYTES - strlen(self::REQUIRED_EVIDENCE_HEADING) )); } /** * The active-state file is reserved in full, so ranking it again would * duplicate culprits and spend the lifecycle budget twice. * * @param array{paths: array, manifest: array}|null $selection * @return array{paths: array, manifest: array}|null */ private static function withoutPathFromSelection(?array $selection, string $excludedPath): ?array { if ($selection === null || $excludedPath === '') { return $selection; } $selection['paths'] = array_values(array_filter( is_array($selection['paths'] ?? null) ? $selection['paths'] : array(), static fn(string $path): bool => $path !== $excludedPath )); if (is_array($selection['manifest'] ?? null)) { $selection['manifest']['selected_files'] = count($selection['paths']); } return $selection; } /** * Drop only intents whose exact checkpoint_id has a terminal non-intent record. * Unmatched and malformed intents remain: they are the evidence that * enrichment or its final append never completed. Every total call cost * remains. The excerpt keeps every slow/failed phase map plus the single * slowest baseline in the session; routine maps are removed only from the * bounded excerpt, never from the durable journal/archive. * * @param array $lines * @param array $additionalClosedIds Exact terminal records * reserved outside the ranked lines, such as active-operation state. * @return array */ public static function compactForSupport(array $lines, array $additionalClosedIds = array()): array { $lines = ABJ_404_Solution_DurableOperationRecorder::compactSupportLines($lines); $withoutClosedIntents = ABJ_404_Solution_CheckpointIntentCorrelation::withoutClosedIntents( $lines, array_merge( ABJ_404_Solution_CheckpointIntentCorrelation::closedCheckpointIds($lines), $additionalClosedIds ) ); return self::compactRoutinePhaseMaps( ABJ_404_Solution_CheckpointIntentCorrelation::withoutKeyedIntents( $withoutClosedIntents ) ); } /** * @param array $lines */ private static function slowestTelemetryIndex(array $lines): int { $slowestIndex = -1; $slowestTotalUs = -1; foreach ($lines as $index => $line) { $record = json_decode($line, true); if (!is_array($record)) { continue; } $previous = $record['previous_checkpoint_write'] ?? null; $totalUs = is_array($previous) ? ABJ_404_Solution_ExactInteger::readOr($previous['total_us'] ?? null, 0, -1) : -1; if ($totalUs > $slowestTotalUs) { $slowestIndex = $index; $slowestTotalUs = $totalUs; } } return $slowestIndex; } /** * A journal record is a JSON object. A line that decodes to anything else * (a scalar, a list, a torn write) is not a record and stays verbatim. * * @return array|null */ private static function decodeJournalRecord(string $line): ?array { $decoded = json_decode($line, true); if (!is_array($decoded)) { return null; } $record = array(); foreach ($decoded as $key => $value) { if (!is_string($key)) { return null; } $record[$key] = $value; } return $record; } /** * @param array $lines * @return array */ private static function compactRoutinePhaseMaps(array $lines): array { // An updater can replace this reader after the older policy class was loaded. if (!class_exists('ABJ_404_Solution_ClientTransportReport') || !in_array('sanitizeJournalLine', get_class_methods('ABJ_404_Solution_ClientTransportReport'), true)) { return array('Diagnostic journal evidence unavailable: client redaction policy is updating.'); } $slowestIndex = self::slowestTelemetryIndex($lines); $decodedByIndex = array(); $hostPressureSnapshotByRequest = array(); foreach ($lines as $index => $line) { $line = ABJ_404_Solution_ClientTransportReport::sanitizeJournalLine($line); $lines[$index] = $line; $decodedByIndex[$index] = self::decodeJournalRecord($line); } foreach ($decodedByIndex as $index => $record) { if (!is_array($record)) { continue; } if (($record['event'] ?? '') === 'query_probe') { // Support needs source + shape hash, not even redacted SQL // text. This also keeps one unmatched probe per failed // request from displacing that request's lifecycle. unset($record['sql']); } $record = ABJ_404_Solution_HostPressureSampler::compactRepeatedHostPressureSnapshots( $record, $hostPressureSnapshotByRequest ); if (!is_array($record['previous_checkpoint_write'] ?? null)) { $encoded = json_encode($record, JSON_UNESCAPED_SLASHES); if (is_string($encoded)) { $lines[$index] = $encoded; } continue; } $previous = $record['previous_checkpoint_write']; $totalUs = ABJ_404_Solution_ExactInteger::readOr( $previous['total_us'] ?? null, 0, -1 ); $isSlowest = $slowestIndex === $index; $isSlow = $totalUs >= self::RECORDER_PHASE_DETAIL_THRESHOLD_US; $failed = ($previous['status'] ?? '') !== 'complete' || (($previous['intent_status'] ?? 'complete') !== 'complete'); if (!$isSlowest && !$isSlow && !$failed && isset($previous['phases_us'])) { unset($record['previous_checkpoint_write']['phases_us']); } // Closed intents are gone at this point, so their correlation IDs // have completed their only job. Keep IDs on unmatched intents, // but do not spend bounded support bytes repeating them here. unset($record['checkpoint_id']); unset($record['previous_checkpoint_write']['checkpoint_id']); $encoded = json_encode($record, JSON_UNESCAPED_SLASHES); if (is_string($encoded)) { $lines[$index] = $encoded; } } return $lines; } /** * What readRecentForSupport() will look at, whether or not any of it * exists, for ABJ_404_Solution_DiagnosticCollectionManifest. The candidate * list is the reader's own, so the manifest can never describe a different * set of files than the one that was actually read. * * The directory is reported even when it turned out to be unusable: which * path this channel tried is exactly the fact a wrong-node or unwritable * uploads directory is diagnosed from. * * The fixed fallback paths remain available even when the trace directory * cannot be resolved or created. * * @return array{channel: string, directory: string, usable: bool, paths: array} */ public static function supportCollectionSource(): array { $fallbackPaths = class_exists('ABJ_404_Solution_CheckpointIntentStore') ? ABJ_404_Solution_CheckpointIntentStore::paths() : array(); try { $directory = self::journalDirectory(); $journalUsable = $directory !== ''; $paths = $journalUsable ? array_merge( $fallbackPaths, self::supportExcerptPaths($directory), class_exists('ABJ_404_Solution_ActiveOperationBreadcrumbs') ? array(ABJ_404_Solution_ActiveOperationBreadcrumbs::path($directory)) : array() ) : $fallbackPaths; return array( 'channel' => 'ajax_checkpoints', 'directory' => $journalUsable ? $directory : ABJ_404_Solution_AjaxCheckpointLogger::resolveDirectoryPath(), 'usable' => $journalUsable || $fallbackPaths !== array(), 'paths' => array_values(array_unique($paths)), ); } catch (Throwable $e) { self::reportFailure('AJAX checkpoint support source resolution failed: ' . $e->getMessage()); return array('channel' => 'ajax_checkpoints', 'directory' => '', 'usable' => $fallbackPaths !== array(), 'paths' => $fallbackPaths); } } /** * Rotated file then current journal: oldest first, the order the excerpt * reader breaks mtime ties on. File names come from the writer, the one * owner of the journal's on-disk contract. * * @param string $directory With a trailing separator. * @return array */ private static function supportExcerptPaths(string $directory): array { return array( $directory . ABJ_404_Solution_CheckpointJournalWriter::ROTATED_FILE, $directory . ABJ_404_Solution_CheckpointJournalWriter::CHECKPOINT_FILE, ); } /** * Existing journal files, for a channel that carries them WHOLE. * * The support excerpt is bounded by a byte budget and a ranking, and a * budget decision must never again be the single point of loss for a * session we only get once. The developer log archive has no such bound, * so it carries both journals in full alongside the debug logs. * * @return array */ public static function supportArchivePaths(): array { $directory = self::journalDirectory(); if ($directory === '') { return array(); } $paths = array(); foreach (array(ABJ_404_Solution_CheckpointJournalWriter::CHECKPOINT_FILE, ABJ_404_Solution_CheckpointJournalWriter::ROTATED_FILE) as $name) { $path = $directory . $name; $fileResult = ABJ_404_Solution_HostFilesystemCall::isFile($path); if ($fileResult['warning'] !== null) { self::reportFailure('AJAX checkpoint archive path check warning: ' . $path . ' (' . $fileResult['warning'] . ')'); continue; } if ($fileResult['result']) { $paths[] = $path; } } return $paths; } /** * The journal directory via the writer's resolution, or '' when the * writer class itself is unreachable. A corrupt install can be missing * any plugin file (the safe autoloader returns silently for a missing * class; see the error-18 work), and the read side must degrade to * "nothing to read" rather than fatal the support request. */ private static function journalDirectory(): string { return class_exists('ABJ_404_Solution_AjaxCheckpointLogger') ? ABJ_404_Solution_AjaxCheckpointLogger::resolveDirectory() : ''; } private static function reportFailure(string $message): void { // Unconditional: abj404_logPhpFallback() is defined at plugin entry // (404-solution.php), before any class here can be autoloaded, so a // raw error_log() second sink would be unreachable dead weight. abj404_logPhpFallback('ajax-checkpoint', $message); } }