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

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

211 lines 9.6 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 /**
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