| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* The support-payload section for the per-failing-session diagnostics (Bruno |
| 9 |
* timeout gap-hunt iteration 5, Opus gap 4). |
| 10 |
* |
| 11 |
* ABJ_404_Solution_FailingSessionEvidence owns the domain question -- which |
| 12 |
* sessions the condemned requests belong to, and each one's detach A/B verdict |
| 13 |
* and encoded-size basis. This class owns the SUPPORT-PAYLOAD job around it: |
| 14 |
* gathering the inputs that question needs (the condemned-request index built |
| 15 |
* from both journals plus the clicking tab's own drained-buffer failures), and |
| 16 |
* rendering the resulting record into a byte-bounded, self-describing block |
| 17 |
* that fits the report contract. Keeping the two apart is the same layer split |
| 18 |
* the rest of this subsystem follows: the evidence class reads nothing about |
| 19 |
* the payload's byte budget, and this section makes no decision about session |
| 20 |
* attribution. |
| 21 |
* |
| 22 |
* The failing-request index is rebuilt here rather than shared with the two |
| 23 |
* journal excerpts' own index (ABJ_404_Solution_SupportEvidenceExcerpt:: |
| 24 |
* collectChannels): both are derived deterministically from the same |
| 25 |
* supportCollectionSource() paths and the same failureIndex() pass plus the |
| 26 |
* same client outcomes, so they agree by construction, and a support request |
| 27 |
* is a one-shot admin action where a second bounded journal pass is cheap |
| 28 |
* next to the certainty of a single source of the attribution rule. |
| 29 |
*/ |
| 30 |
final class ABJ_404_Solution_FailingSessionSupportSection { |
| 31 |
|
| 32 |
/** |
| 33 |
* Hard cap on the rendered block. A bounded number of failing sessions, |
| 34 |
* each a compact verdict plus an encoded-size basis, so it is small by |
| 35 |
* construction; the cap keeps it so regardless of how many sessions a busy |
| 36 |
* site put failures into, and render() sheds the per-session id lists to |
| 37 |
* fit rather than being cut mid-record. Reclaimed from the checkpoint |
| 38 |
* excerpt budget so the section sum stays inside the report contract -- see |
| 39 |
* ABJ_404_Solution_CheckpointJournalReader::MAX_SUPPORT_EXCERPT_BYTES and |
| 40 |
* SupportExcerptBudgetContractTest. |
| 41 |
*/ |
| 42 |
const MAX_FAILING_SESSION_DIAG_BYTES = 4096; |
| 43 |
|
| 44 |
/** The one JSON key the block hangs under, so a reader can grep for it. */ |
| 45 |
const FAILING_SESSION_DIAG_KEY = 'abj404_failing_session_diag'; |
| 46 |
|
| 47 |
/** |
| 48 |
* The whole section, ready to join into the support payload. Never throws: |
| 49 |
* a support request is the last thing that may be blocked by its own |
| 50 |
* diagnostics, so a journal read or a missing class degrades to a stated |
| 51 |
* reason rather than a fatal in the request the admin is waiting on. |
| 52 |
* |
| 53 |
* @param array{status: string, ids: array<int, string>, records: int, outcomes: array<string, bool>} $clientAttempts |
| 54 |
*/ |
| 55 |
public static function compose(array $clientAttempts, string $clickSessionId): string { |
| 56 |
if (!class_exists('ABJ_404_Solution_FailingSessionEvidence')) { |
| 57 |
return 'Failing-session diagnostics unavailable: ABJ_404_Solution_FailingSessionEvidence' |
| 58 |
. ' could not be loaded on this install, so per-session verdicts were not computed here.'; |
| 59 |
} |
| 60 |
try { |
| 61 |
$clientFailingIds = self::clientFailingIds($clientAttempts); |
| 62 |
$failingIds = self::failingRequestIndex(self::diagnosticSources(), $clientFailingIds); |
| 63 |
return self::render( |
| 64 |
ABJ_404_Solution_FailingSessionEvidence::forSupport( |
| 65 |
$failingIds, $clientFailingIds, $clickSessionId)); |
| 66 |
} catch (Throwable $e) { |
| 67 |
return 'Failing-session diagnostics could not be computed: ' . substr($e->getMessage(), 0, 200); |
| 68 |
} |
| 69 |
} |
| 70 |
|
| 71 |
/** |
| 72 |
* The two durable diagnostic journals' candidate paths, each guarded the |
| 73 |
* way the rest of assembly is (a corrupt install can be missing any plugin |
| 74 |
* file; see the safe-autoloader work for error 18). |
| 75 |
* |
| 76 |
* @return array<int, array{channel: string, directory: string, usable: bool, paths: array<int, string>}> |
| 77 |
*/ |
| 78 |
private static function diagnosticSources(): array { |
| 79 |
$sources = array(); |
| 80 |
if (class_exists('ABJ_404_Solution_AjaxRequestTrace')) { |
| 81 |
$sources[] = ABJ_404_Solution_AjaxTraceJournal::supportCollectionSource(); |
| 82 |
} |
| 83 |
if (class_exists('ABJ_404_Solution_CheckpointJournalReader')) { |
| 84 |
$sources[] = ABJ_404_Solution_CheckpointJournalReader::supportCollectionSource(); |
| 85 |
} |
| 86 |
return $sources; |
| 87 |
} |
| 88 |
|
| 89 |
/** |
| 90 |
* The request ids the browser condemned in its OWN drained buffer. These |
| 91 |
* belong to the clicking tab by construction -- the browser holds only its |
| 92 |
* own tab's transport buffer -- so the evidence class can attribute an |
| 93 |
* untraced one to the click session instead of dropping it. |
| 94 |
* |
| 95 |
* @param array{status: string, ids: array<int, string>, records: int, outcomes: array<string, bool>} $clientAttempts |
| 96 |
* @return array<string, bool> |
| 97 |
*/ |
| 98 |
private static function clientFailingIds(array $clientAttempts): array { |
| 99 |
$outcomes = isset($clientAttempts['outcomes']) && is_array($clientAttempts['outcomes']) |
| 100 |
? $clientAttempts['outcomes'] : array(); |
| 101 |
$failing = array(); |
| 102 |
foreach ($outcomes as $requestId => $healthy) { |
| 103 |
if ($healthy === false) { |
| 104 |
$failing[(string)$requestId] = true; |
| 105 |
} |
| 106 |
} |
| 107 |
return $failing; |
| 108 |
} |
| 109 |
|
| 110 |
/** |
| 111 |
* Every request id condemned anywhere: unioned across both journals' |
| 112 |
* failure indexes and the clicking tab's drained-buffer failures. Matches, |
| 113 |
* by construction, the index the journal excerpts rank on. |
| 114 |
* |
| 115 |
* @param array<int, array{channel: string, directory: string, usable: bool, paths: array<int, string>}> $sources |
| 116 |
* @param array<string, bool> $clientFailingIds |
| 117 |
* @return array<string, bool> |
| 118 |
*/ |
| 119 |
private static function failingRequestIndex(array $sources, array $clientFailingIds): array { |
| 120 |
$failingIds = array(); |
| 121 |
if (class_exists('ABJ_404_Solution_DiagnosticJournalExcerpt')) { |
| 122 |
foreach ($sources as $source) { |
| 123 |
$failingIds += ABJ_404_Solution_DiagnosticJournalExcerpt::failureIndex($source['paths']); |
| 124 |
} |
| 125 |
} |
| 126 |
foreach ($clientFailingIds as $id => $present) { |
| 127 |
$failingIds[$id] = true; |
| 128 |
} |
| 129 |
return $failingIds; |
| 130 |
} |
| 131 |
|
| 132 |
/** |
| 133 |
* The record as a scannable header line plus one JSON record. |
| 134 |
* |
| 135 |
* Over-budget input sheds the per-session id lists and the unresolved list |
| 136 |
* first -- the reducible detail -- then falls back to the relationship and |
| 137 |
* counts alone, rather than being cut at a byte offset. A record cut |
| 138 |
* mid-JSON is unreadable by machine and misleading to a human. |
| 139 |
* |
| 140 |
* @param array<string, mixed> $record ABJ_404_Solution_FailingSessionEvidence::forSupport(). |
| 141 |
*/ |
| 142 |
private static function render(array $record): string { |
| 143 |
$header = 'Failing-session diagnostics -- ' . self::summary($record) . " (JSON):\n"; |
| 144 |
|
| 145 |
$reduced = $record; |
| 146 |
$reducedSessions = array(); |
| 147 |
foreach (is_array($record['sessions'] ?? null) ? $record['sessions'] : array() as $session) { |
| 148 |
if (is_array($session)) { |
| 149 |
unset($session['failing_request_ids']); |
| 150 |
$session['failing_request_ids_reduced'] = 'over_budget'; |
| 151 |
} |
| 152 |
$reducedSessions[] = $session; |
| 153 |
} |
| 154 |
$reduced['sessions'] = $reducedSessions; |
| 155 |
$reduced['unresolved_failing_request_ids'] = array(); |
| 156 |
|
| 157 |
$minimal = array( |
| 158 |
'status' => self::textOf($record, 'status'), |
| 159 |
'click_session_key' => self::textOf($record, 'click_session_key'), |
| 160 |
'click_vs_failing' => self::textOf($record, 'click_vs_failing'), |
| 161 |
'failing_request_count' => self::countOf($record, 'failing_request_count'), |
| 162 |
'sessions_resolved' => self::countOf($record, 'sessions_resolved'), |
| 163 |
'reduced' => 'over_budget', |
| 164 |
); |
| 165 |
|
| 166 |
foreach (array($record, $reduced, $minimal) as $candidate) { |
| 167 |
$line = json_encode(array(self::FAILING_SESSION_DIAG_KEY => $candidate)); |
| 168 |
if (is_string($line) |
| 169 |
&& strlen($header) + strlen($line) <= self::MAX_FAILING_SESSION_DIAG_BYTES) { |
| 170 |
return $header . $line; |
| 171 |
} |
| 172 |
} |
| 173 |
return $header . 'The failing-session diagnostics record could not be encoded for this payload.'; |
| 174 |
} |
| 175 |
|
| 176 |
/** |
| 177 |
* The one-line version: the relationship first, then how much failing |
| 178 |
* evidence it was drawn from. 'foreign_sessions_only' over four failing |
| 179 |
* requests and 'no_failing_sessions' over zero are different findings, so |
| 180 |
* the counts are part of the summary rather than decoration. |
| 181 |
* |
| 182 |
* @param array<string, mixed> $record |
| 183 |
*/ |
| 184 |
private static function summary(array $record): string { |
| 185 |
return self::textOf($record, 'status') . ': ' . self::textOf($record, 'click_vs_failing') . '; ' |
| 186 |
. self::countOf($record, 'failing_request_count') . ' failing request(s), ' |
| 187 |
. self::countOf($record, 'sessions_resolved') . ' session(s) resolved, ' |
| 188 |
. self::countOf($record, 'unresolved_failing_request_count') . ' unattributed'; |
| 189 |
} |
| 190 |
|
| 191 |
/** |
| 192 |
* One record field as a string, or '' when it is absent or not scalar. |
| 193 |
* |
| 194 |
* @param array<string, mixed> $record |
| 195 |
*/ |
| 196 |
private static function textOf(array $record, string $field): string { |
| 197 |
$value = $record[$field] ?? null; |
| 198 |
return is_scalar($value) ? (string)$value : ''; |
| 199 |
} |
| 200 |
|
| 201 |
/** |
| 202 |
* One record field as an integer, or 0 when it is absent or not scalar. |
| 203 |
* |
| 204 |
* @param array<string, mixed> $record |
| 205 |
*/ |
| 206 |
private static function countOf(array $record, string $field): int { |
| 207 |
$value = $record[$field] ?? null; |
| 208 |
return is_scalar($value) ? (int)$value : 0; |
| 209 |
} |
| 210 |
} |
| 211 |
|