300.0, // ms 'TBT' => 100.0, // ms 'CLS' => 0.05, // score 'TTFB' => 150.0, // ms ); private IncidentRepository $incidents; public function __construct( private SampleRepository $samples ) { $this->incidents = new IncidentRepository(); } public function run(): void { $yesterday = Util::days_ago( 1 ); $baseline_start = Util::days_ago( 29 ); foreach ( Options::tracked_urls() as $url ) { foreach ( Options::METRICS as $metric ) { $sample = $this->samples->latest_for_day( $url, $metric, $yesterday ); if ( null === $sample ) { continue; } $p75 = $sample['value']; $budget = Options::budget( $metric, $url ); // Baseline is built from the same source as the reading, so a // URL-level p75 is never held against an origin-level average. $history = $this->samples->baseline( $url, $metric, $baseline_start, $yesterday, $sample['source'] ); $comparable = null !== $history && $history['days'] >= self::MIN_BASELINE_DAYS; $baseline = $comparable ? $history['value'] : null; $over_budget = $budget > 0 && $p75 > $budget; $over_baseline = null !== $baseline && $baseline > 0 && $p75 > $baseline * self::REGRESSION_FACTOR; $this->apply( $url, $metric, $p75, $baseline, $budget, $over_budget || $over_baseline, IncidentRepository::SOURCE_FIELD ); } } } /** * Judge the lab reading just stored for one URL. Called right after a lab * check, so a regression opens an incident the same day it is measured. * The baseline stops at the start of today: today's readings are what is * being judged. * * @param int[] $trigger_ids Ledger events a change check was re-testing. */ public function run_lab( string $url, array $trigger_ids = array() ): void { $today = gmdate( 'Y-m-d' ); $baseline_start = Util::days_ago( 28 ); foreach ( Options::LAB_METRICS as $metric ) { $series = $this->samples->lab_series( $url, $metric, 0 ); if ( empty( $series ) ) { continue; } $value = end( $series )['value']; $history = $this->samples->baseline( $url, $metric, $baseline_start, $today, SampleRepository::SOURCE_LAB ); if ( null === $history || $history['days'] < self::MIN_BASELINE_DAYS || $history['value'] <= 0 ) { continue; } $baseline = $history['value']; $regressed = $value > $baseline * self::REGRESSION_FACTOR && ( $value - $baseline ) >= self::LAB_MIN_DELTA[ $metric ]; $this->apply( $url, $metric, $value, $baseline, Options::budget( $metric, $url ), $regressed, IncidentRepository::SOURCE_LAB, $trigger_ids ); } } /** * Open an incident for a new breach, or resolve the open one on recovery. * * @param int[] $trigger_ids Ledger events a change check was re-testing. */ private function apply( string $url, string $metric, float $value, ?float $baseline, float $budget, bool $breached, string $source, array $trigger_ids = array() ): void { $open_incident = $this->incidents->find_open( $url, $metric, $source ); if ( $breached ) { if ( $open_incident ) { return; // Already tracking this regression. } $incident_id = $this->incidents->open( $url, $metric, $baseline, $value, $budget, $source, $trigger_ids ); if ( $incident_id ) { // The attribution engine ships only in the premium build; the // free build stores no ranked causes. if ( class_exists( Attribution::class ) ) { $causes = ( new Attribution() )->rank( $url, Util::now(), $trigger_ids ); $this->incidents->set_causes( $incident_id, $causes ); } $incident = $this->incidents->find( $incident_id ); /** * Fires when a new performance incident is opened. * * @param array $incident Incident row as an associative array. */ do_action( 'aftercare_incident_opened', $incident ); } } elseif ( $open_incident ) { // Back under budget and baseline: auto-resolve. $this->incidents->set_status( (int) $open_incident['id'], 'resolved' ); /** * Fires when an incident recovers and is auto-resolved. * * @param array $incident Incident row before resolution. */ do_action( 'aftercare_incident_resolved', $open_incident ); } } }