*/ public static function verdictForSession(string $sessionId): array { $sessionKey = ABJ_404_Solution_DetachAbExperiment::sessionKey($sessionId); $record = self::emptyRecord($sessionKey); try { if ($sessionKey === '') { $record['status'] = self::STATUS_NO_SESSION; return $record; } if (!ABJ_404_Solution_DetachAbExperiment::isEnabled()) { $record['status'] = self::STATUS_NOT_ARMED; return $record; } $source = ABJ_404_Solution_CheckpointJournalReader::supportCollectionSource(); $lines = ABJ_404_Solution_DiagnosticJournalExcerpt::readAllLines($source['paths']); return self::decide($record, self::attemptsIn($lines, $sessionKey), count($lines)); } catch (Throwable $e) { $record['status'] = self::STATUS_ERROR; $record['error'] = substr($e->getMessage(), 0, 200); return $record; } } /** * The record every path starts from: a complete shape with a zero-evidence * verdict already in it. * * The verdict is produced by the real rule rather than hand-written as a * literal, so a "nothing to decide" record can never drift out of step with * the shape a decided one has. * * @param string $sessionKey ABJ_404_Solution_DetachAbExperiment::sessionKey(). * @return array */ private static function emptyRecord(string $sessionKey): array { return array( 'status' => self::STATUS_COMPUTED, 'session_key' => $sessionKey, 'build_channel' => ABJ_404_Solution_PluginReleaseChannel::currentChannel(), 'attempts' => array(), 'attempts_with_mode' => 0, 'attempts_resolved' => 0, 'attempts_unresolved' => 0, 'journal_lines_scanned' => 0, 'verdict' => ABJ_404_Solution_DetachAbVerdict::fromAttempts(array()), ); } /** * Apply the decision rule to a completed join and fill in the accounting. * * Only attempts the browser actually resolved reach the rule; the rest are * counted, listed with a null outcome, and otherwise ignored. * * @param array $record * @param array{attempts: array, unresolved: int, * with_mode: int} $joined * @return array */ private static function decide(array $record, array $joined, int $linesScanned): array { $resolved = array(); $onRecord = array(); foreach ($joined['attempts'] as $attempt) { if ($attempt->isResolved()) { $resolved[] = $attempt; } if (count($onRecord) < self::MAX_ATTEMPTS_ON_RECORD) { $onRecord[] = $attempt->toJournalArray(); } } $record['attempts'] = $onRecord; $record['attempts_with_mode'] = $joined['with_mode']; $record['attempts_resolved'] = count($resolved); $record['attempts_unresolved'] = $joined['unresolved']; $record['journal_lines_scanned'] = $linesScanned; $record['verdict'] = ABJ_404_Solution_DetachAbVerdict::fromAttempts($resolved); return $record; } /** * One session's A/B attempts, each paired with the browser's verdict on it. * * Pure and side-effect free: it takes journal lines rather than reading * them, so every join case (a foreign session, an unrecognised mode, a * silent attempt, a duplicate report) is directly assertable without a * journal on disk that happens to contain it. * * Attempt outcomes come from ABJ_404_Solution_DiagnosticClientVerdict, the * one extractor that already resolves a browser report to the journal key * its attempt was recorded under. Deriving a second keying here is exactly * the defect that made the browser's verdicts land on orphan placeholder * groups for an entire release. * * @param array $lines JSONL lines, oldest first. * @param string $sessionKey ABJ_404_Solution_DetachAbExperiment::sessionKey(). * @return array{attempts: array, unresolved: int, * with_mode: int} */ public static function attemptsIn(array $lines, string $sessionKey): array { $parsed = self::modesInSession($lines, $sessionKey); $outcomes = ABJ_404_Solution_DiagnosticClientVerdict::reportedOutcomesIn($lines); $attempts = array(); $unresolved = 0; foreach ($parsed as $requestId => $attempt) { $requestId = (string)$requestId; $resolved = array_key_exists($requestId, $outcomes); if (!$resolved) { $unresolved++; } // null, never false: "the browser has not said" and "the browser // said it failed" are opposite findings, and only one of them // belongs in the tally. $attempts[] = $attempt->withOutcome($resolved ? (bool)$outcomes[$requestId] : null); } return array('attempts' => $attempts, 'unresolved' => $unresolved, 'with_mode' => count($parsed)); } /** * Request id to parsed attempt for one session, in journal order. * * This class owns which records belong to the session; what one record * MEANS belongs to ABJ_404_Solution_DetachAbAttempt, which is why the * mode vocabulary and the ordinal parse are not repeated here. Records * that are not measurements (MODE_INERT, MODE_DEFAULT) are dropped by that * parse rather than by a second copy of the rule. * * @param array $lines * @return array */ private static function modesInSession(array $lines, string $sessionKey): array { $modes = array(); foreach ($lines as $line) { // Lines that cannot possibly match are rejected before the JSON // decoder sees them: this pass runs over whole journals, and the // mode records are a few dozen lines out of tens of thousands. if (strpos($line, self::MODE_EVENT) === false) { continue; } $record = json_decode($line, true); if (!is_array($record) || ($record['event'] ?? '') !== self::MODE_EVENT) { continue; } if (self::scalarField($record, 'session_key') !== $sessionKey) { continue; } $attempt = ABJ_404_Solution_DetachAbAttempt::fromJournalRecord($record); if ($attempt === null) { continue; } $modes[$attempt->requestId()] = $attempt; } return $modes; } /** * One record field as a string, or '' when it is absent or not scalar. * * @param array $record */ private static function scalarField(array $record, string $field): string { $value = $record[$field] ?? null; return is_scalar($value) ? (string)$value : ''; } }