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

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

138 lines 6.5 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 carrying the detach A/B experiment's verdict for
9 * the session that clicked "send".
10 *
11 * The experiment's two halves are produced in different places and never in the
12 * same record: the server chose each table request's detach mode, and the
13 * browser later said whether that request completed. Joining them is
14 * ABJ_404_Solution_DetachAbEvidence's job, and until support assembly called it
15 * the only trigger was the canary ladder -- which runs ONLY after a foreground
16 * table failure. That covers a session where the OFF attempt hung and leaves the
17 * primary question uncovered: when a beta session goes WELL, nothing fails, no
18 * ladder runs, and the developer receives the raw halves and has to join them by
19 * hand. Support-request assembly is the one moment that happens in healthy and
20 * failing sessions alike.
21 *
22 * ABJ_404_Solution_DetachAbEvidence owns deciding the verdict; this class owns
23 * only the payload job of asking for it and rendering the answer inside a byte
24 * budget, which is the same contract every other *SupportSection here honours.
25 */
26 final class ABJ_404_Solution_DetachAbVerdictSupportSection {
27
28 /**
29 * Hard cap on the rendered block. It is a decision plus the bounded list of
30 * attempts it was decided from, so it is small by construction; the cap is
31 * what keeps it small no matter what a long-lived session put in the
32 * journal, and render() sheds the attempt list to fit rather than being cut
33 * mid-record. Reclaimed from the checkpoint excerpt budget so the section sum
34 * stays inside the report contract -- see
35 * ABJ_404_Solution_CheckpointJournalReader::MAX_SUPPORT_EXCERPT_BYTES and
36 * SupportExcerptBudgetContractTest.
37 */
38 const MAX_DETACH_AB_VERDICT_BYTES = 2048;
39
40 /** The one JSON key the verdict hangs under, so a reader can grep for it. */
41 const DETACH_AB_VERDICT_KEY = 'abj404_detach_ab_verdict';
42
43 /**
44 * The whole section, ready to join into the support payload.
45 *
46 * Guarded twice, because a support request is the last thing that may be
47 * blocked by its own diagnostics: a partially recovered install can be
48 * missing any plugin file (see the safe-autoloader work for error 18), and a
49 * journal read that throws must degrade to a stated reason rather than to a
50 * fatal in the request the admin is waiting on.
51 */
52 public static function compose(string $sessionId): string {
53 if (!class_exists('ABJ_404_Solution_DetachAbEvidence')) {
54 return 'Detach A/B verdict unavailable: ABJ_404_Solution_DetachAbEvidence could not be'
55 . ' loaded on this install, so the experiment could not be decided here.';
56 }
57 try {
58 return self::render(ABJ_404_Solution_DetachAbEvidence::verdictForSession($sessionId));
59 } catch (Throwable $e) {
60 return 'Detach A/B verdict could not be computed: ' . substr($e->getMessage(), 0, 200);
61 }
62 }
63
64 /**
65 * The verdict as a scannable header line plus one JSON record.
66 *
67 * Over-budget input sheds the attempt LIST -- the one reducible part -- and
68 * then falls back to the decision alone, rather than being cut at a byte
69 * offset: a record cut mid-JSON is unreadable by machine and misleading to a
70 * human, which is the same failure the drained client buffer already taught
71 * the composer (see SupportEvidenceExcerpt::appendClientTransportTelemetry).
72 *
73 * @param array<string, mixed> $record ABJ_404_Solution_DetachAbEvidence::verdictForSession().
74 */
75 private static function render(array $record): string {
76 $header = 'Detach A/B verdict -- ' . self::summary($record) . " (JSON):\n";
77 $reduced = $record;
78 $reduced['attempts'] = array();
79 $reduced['attempts_reduced'] = 'over_budget';
80 $minimal = array(
81 'status' => self::textOf($record, 'status'),
82 'session_key' => self::textOf($record, 'session_key'),
83 'verdict' => $record['verdict'] ?? array(),
84 'reduced' => 'over_budget',
85 );
86 foreach (array($record, $reduced, $minimal) as $candidate) {
87 $line = json_encode(array(self::DETACH_AB_VERDICT_KEY => $candidate));
88 if (is_string($line) && strlen($header) + strlen($line) <= self::MAX_DETACH_AB_VERDICT_BYTES) {
89 return $header . $line;
90 }
91 }
92 return $header . 'The verdict record could not be encoded for this payload.';
93 }
94
95 /**
96 * The one-line version, so the first thing a reader sees is the decision and
97 * how much evidence it was drawn from. The counts are part of the summary
98 * rather than decoration: a verdict of 'inconclusive' over zero attempts and
99 * one over six attempts are entirely different findings.
100 *
101 * @param array<string, mixed> $record
102 */
103 private static function summary(array $record): string {
104 $verdict = isset($record['verdict']) && is_array($record['verdict'])
105 ? $record['verdict'] : array();
106 // Read straight off the discriminant the rule decides. This used to
107 // loop the boolean flags to recover the one word they encode, which
108 // meant the renderer carried its own copy of the precedence order and
109 // would have disagreed with the rule if either side gained an outcome.
110 $named = isset($verdict['verdict']) && is_string($verdict['verdict']) && $verdict['verdict'] !== ''
111 ? $verdict['verdict'] : ABJ_404_Solution_DetachAbVerdict::VERDICT_INCONCLUSIVE;
112 return self::textOf($record, 'status') . ': ' . $named . '; '
113 . self::countOf($record, 'attempts_with_mode') . ' attempt(s) with a mode, '
114 . self::countOf($record, 'attempts_resolved') . ' resolved by the browser, '
115 . self::countOf($record, 'attempts_unresolved') . ' still unreported';
116 }
117
118 /**
119 * One record field as a string, or '' when it is absent or not scalar.
120 *
121 * @param array<string, mixed> $record
122 */
123 private static function textOf(array $record, string $field): string {
124 $value = $record[$field] ?? null;
125 return is_scalar($value) ? (string)$value : '';
126 }
127
128 /**
129 * One record field as an integer, or 0 when it is absent or not scalar.
130 *
131 * @param array<string, mixed> $record
132 */
133 private static function countOf(array $record, string $field): int {
134 $value = $record[$field] ?? null;
135 return is_scalar($value) ? (int)$value : 0;
136 }
137 }
138