*/ public static function forSession(string $sessionId): array { $sessionKey = ABJ_404_Solution_DetachAbExperiment::sessionKey($sessionId); try { if ($sessionId === '') { $record = self::emptyRecord($sessionKey); $record['status'] = self::STATUS_NO_SESSION; return $record; } // ONE read of the checkpoint journal, shared by both halves of the // comparison. Reading it twice let a record appended in between // pair an emitted size from one snapshot with a delivery record // from another -- a comparison neither read observed, reported with // the confidence of a real one. $source = ABJ_404_Solution_CheckpointJournalReader::supportCollectionSource(); $checkpointLines = ABJ_404_Solution_DiagnosticJournalExcerpt::readAllLines($source['paths']); $traceSource = ABJ_404_Solution_AjaxTraceJournalReader::supportCollectionSource(); $traceLines = ABJ_404_Solution_DiagnosticJournalExcerpt::readAllLines($traceSource['paths']); return self::fromLines( $checkpointLines, $sessionKey, ABJ_404_Solution_EncodedTableResponseSize::fromLines( $traceLines, $checkpointLines, $sessionId) ); } catch (Throwable $e) { $record = self::emptyRecord($sessionKey); $record['status'] = self::STATUS_ERROR; $record['error'] = substr($e->getMessage(), 0, 200); return $record; } } /** * The same record built from journal lines a caller already holds. * * ABJ_404_Solution_CanaryReceiptEvidence reconstructs the ladder matrix * offline, from exactly these receipts, when the live `interpret` * response never reached the browser -- which is the case this whole * diagnostic exists for. Recomputing the matrix there WITHOUT this join * would silently drop the one verdict that names the incident from the * support payload, so both paths go through one implementation rather * than through two that can disagree. * * @param array $lines Checkpoint JSONL lines, oldest first. * @param string $sessionKey ABJ_404_Solution_DetachAbExperiment::sessionKey(). * @param array{bytes: int|null, source: string, request_id: string} $realRequest * @return array */ public static function fromLines(array $lines, string $sessionKey, array $realRequest): array { $record = self::emptyRecord($sessionKey); $joined = self::comparisonsIn($lines, $sessionKey, $realRequest); $record['comparisons'] = $joined['comparisons']; $record['stream_flush_reached_sapi'] = $joined['stream_flush_reached_sapi']; $record['journal_lines_scanned'] = count($lines); $record['verdict'] = ABJ_404_Solution_ResponseBodyRewriteVerdict::fromComparisons( $joined['comparisons'], self::MAX_REPORTED_STEP_CHARS); return $record; } /** * The record every path starts from: a complete shape with no evidence in * it yet, so "nothing to join" can never drift out of step with the shape * a joined one has. * * @return array */ private static function emptyRecord(string $sessionKey): array { return array( 'status' => self::STATUS_COMPUTED, 'session_key' => $sessionKey, 'comparisons' => array(), // null, never false: "this session journaled no stream step" and // "the stream step ran and reached nobody" are opposite findings. 'stream_flush_reached_sapi' => null, 'journal_lines_scanned' => 0, // Produced by the real rule rather than hand-written as a // literal, so a "nothing to join" record can never drift out of // step with the shape a decided one has. 'verdict' => ABJ_404_Solution_ResponseBodyRewriteVerdict::fromComparisons(array()), ); } /** * One session's emitted-against-delivered comparisons, in ladder order. * * Pure and side-effect free: it takes journal lines rather than reading * them, so every join case (a foreign session, a step with no receipt, a * receipt with no Resource Timing, a response the server never sized) is * directly assertable without a journal on disk that happens to contain * it. * * The static-asset probe is deliberately absent from the result. It never * reaches PHP, so there is no encoded response to compare against and its * emitted half is not merely unknown but nonexistent; listing it would put * a permanent `emitted_unknown` row in every record. * * @param array $lines JSONL lines, oldest first. * @param string $sessionKey ABJ_404_Solution_DetachAbExperiment::sessionKey(). * @param array{bytes: int|null, source: string, request_id: string} $realRequest * ABJ_404_Solution_EncodedTableResponseSize::forSession(). Unknown fields * are tolerated; only `bytes` and `request_id` are read. * @return array{comparisons: array>, * stream_flush_reached_sapi: bool|null} */ public static function comparisonsIn(array $lines, string $sessionKey, array $realRequest): array { $receipts = ABJ_404_Solution_BodyDeliveryObservations::receiptsInSession($lines, $sessionKey); $encoded = ABJ_404_Solution_BodyDeliveryObservations::encodedSizesIn($lines); $streamFlush = ABJ_404_Solution_BodyDeliveryObservations::streamFlushOutcomesIn($lines); // Scoped by the record's OWN session first, so "did this session's // stream step reach the wire" survives a lost browser receipt: that // receipt travels on the delivery channel under diagnosis, and making // the answer depend on it is the cross-channel join that already cost // this investigation a week. The receipt-keyed lookup below is the // fallback for records written before the stamp existed. $streamReachedSapi = null; foreach ($streamFlush as $outcome) { if ($sessionKey !== '' && $outcome['session_key'] === $sessionKey) { $streamReachedSapi = $outcome['reached_sapi']; } } $comparisons = array(); foreach (ABJ_404_Solution_CanaryLadderStep::DISPATCHED as $step) { if (!isset($receipts[$step])) { continue; } $requestId = $receipts[$step]['request_id']; $flush = $streamFlush[$requestId] ?? null; if ($streamReachedSapi === null && $step === ABJ_404_Solution_CanaryLadderStep::STREAM && $flush !== null) { $streamReachedSapi = $flush['reached_sapi']; } $comparisons[] = self::comparison(array( 'step' => $step, 'request_id' => $requestId, 'emitted' => self::emittedBytes($encoded[$requestId] ?? null, $flush), 'delivered' => $receipts[$step]['delivered_bytes'], 'timing_state' => $receipts[$step]['resource_timing_state'], )); } $realId = ABJ_404_Solution_AjaxRequestLedger::normalizeId($realRequest['request_id'] ?? null, ''); // Through the same rule the receipt sizes use: two copies would // eventually disagree about zero and the -1 sentinel, and both halves // of a comparison must treat "unknown" identically. $realEmitted = ABJ_404_Solution_MeasuredBodyBytes::disclosed($realRequest['bytes'] ?? null); if ($realId !== '' || $realEmitted !== null) { $reported = ABJ_404_Solution_DeliveredTableResponseSize::forRequest($lines, $realId); $comparisons[] = self::comparison(array( 'step' => self::REAL_REQUEST_STEP, 'request_id' => $realId, 'emitted' => $realEmitted === null ? array('bytes' => null, 'source' => ABJ_404_Solution_MeasuredBodyBytes::SOURCE_UNAVAILABLE) : array('bytes' => $realEmitted, 'source' => ABJ_404_Solution_MeasuredBodyBytes::SOURCE_ENCODE), 'delivered' => $reported['bytes'], 'timing_state' => $reported['resource_timing_state'], )); } return array('comparisons' => $comparisons, 'stream_flush_reached_sapi' => $streamReachedSapi); } /** * One comparison row. The known-ness of each half is NAMED here; whether a * known pair actually differs is a decision and stays in * ABJ_404_Solution_CanaryLadderInterpretation::interpret(). * * Takes one keyed row rather than positional arguments. `step`, * `request_id` and `timing_state` are all strings, so in positional form a * caller could transpose any two of them and produce a perfectly typed row * that attributes one step's delivery evidence to another. Nothing * downstream could detect it: every field would still be a plausible * string. Keys make the transposition unwriteable, and PHPStan checks the * shape at each call site. (PHP 7.4 is the floor here, so named arguments * are not available and a positional value-object constructor would move * the same hazard rather than remove it.) * * @param array{step: string, request_id: string, emitted: array{bytes: int|null, source: string}, delivered: int|null, timing_state: string} $row * @return array */ private static function comparison(array $row): array { $emitted = $row['emitted']; $delivered = $row['delivered']; if ($emitted['bytes'] === null && $delivered === null) { $state = self::STATE_BOTH_UNKNOWN; } else if ($emitted['bytes'] === null) { $state = self::STATE_EMITTED_UNKNOWN; } else if ($delivered === null) { $state = self::STATE_DELIVERED_UNKNOWN; } else { $state = self::STATE_COMPARABLE; } return array( 'step' => $row['step'], 'request_id' => $row['request_id'], 'emitted_bytes' => $emitted['bytes'], 'emitted_source' => $emitted['source'], 'delivered_bytes' => $delivered, 'delivered_source' => $delivered === null ? ABJ_404_Solution_MeasuredBodyBytes::SOURCE_UNAVAILABLE : ABJ_404_Solution_MeasuredBodyBytes::SOURCE_RESOURCE_TIMING, 'resource_timing_state' => $row['timing_state'], 'state' => $state, ); } /** * Every body byte the server wrote for one response, or a named absence. * * A stream step whose whitespace count is UNKNOWN yields an unknown total, * not the encode count alone: the step put a prefix on the wire, and * understating that is indistinguishable from a body rewritten in transit. * * @param int|null $encodedBytes The `json_encode` record's own count. * @param array{reached_sapi: bool|null, whitespace_bytes: int|null}|null $flush * @return array{bytes: int|null, source: string} */ private static function emittedBytes(?int $encodedBytes, ?array $flush): array { if ($encodedBytes === null) { return array('bytes' => null, 'source' => ABJ_404_Solution_MeasuredBodyBytes::SOURCE_UNAVAILABLE); } if ($flush !== null && $flush['whitespace_bytes'] === null) { return array('bytes' => null, 'source' => ABJ_404_Solution_MeasuredBodyBytes::SOURCE_UNAVAILABLE); } $whitespace = $flush === null ? 0 : max(0, $flush['whitespace_bytes']); if ($whitespace === 0) { return array('bytes' => $encodedBytes, 'source' => ABJ_404_Solution_MeasuredBodyBytes::SOURCE_ENCODE); } return array( 'bytes' => $encodedBytes + $whitespace, 'source' => ABJ_404_Solution_MeasuredBodyBytes::SOURCE_ENCODE_PLUS_STREAM, ); } }