Skip to content

Commit 14db8d1

Browse files
authored
Merge pull request #1705 from ShyamGadde/add/mark-url-metrics-stale-on-tag-visitor-change
Mark existing URL Metrics as stale when a new tag visitor is registered
2 parents d7bd6fa + 6960a28 commit 14db8d1

21 files changed

+622
-112
lines changed

plugins/embed-optimizer/tests/test-optimization-detective.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ public function set_up(): void {
1818
if ( ! defined( 'OPTIMIZATION_DETECTIVE_VERSION' ) ) {
1919
$this->markTestSkipped( 'Optimization Detective is not active.' );
2020
}
21+
22+
// Normalize the data for computing the current URL Metrics ETag to work around the issue where there is no
23+
// global variable storing the OD_Tag_Visitor_Registry instance along with any registered tag visitors, so
24+
// during set up we do not know what the ETag will look like. The current ETag is only established when
25+
// the output begins to be processed by od_optimize_template_output_buffer().
26+
add_filter( 'od_current_url_metrics_etag_data', '__return_empty_array' );
2127
}
2228

2329
/**

plugins/image-prioritizer/tests/test-helper.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,19 @@
1010
class Test_Image_Prioritizer_Helper extends WP_UnitTestCase {
1111
use Optimization_Detective_Test_Helpers;
1212

13+
/**
14+
* Runs the routine before each test is executed.
15+
*/
16+
public function set_up(): void {
17+
parent::set_up();
18+
19+
// Normalize the data for computing the current URL Metrics ETag to work around the issue where there is no
20+
// global variable storing the OD_Tag_Visitor_Registry instance along with any registered tag visitors, so
21+
// during set up we do not know what the ETag will look like. The current ETag is only established when
22+
// the output begins to be processed by od_optimize_template_output_buffer().
23+
add_filter( 'od_current_url_metrics_etag_data', '__return_empty_array' );
24+
}
25+
1326
/**
1427
* @return array<string, array<string, mixed>>
1528
*/

plugins/optimization-detective/class-od-url-metric-group-collection.php

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ final class OD_URL_Metric_Group_Collection implements Countable, IteratorAggrega
3535
*/
3636
private $groups;
3737

38+
/**
39+
* The current ETag.
40+
*
41+
* @since n.e.x.t
42+
* @var non-empty-string
43+
*/
44+
private $current_etag;
45+
3846
/**
3947
* Breakpoints in max widths.
4048
*
@@ -93,12 +101,27 @@ final class OD_URL_Metric_Group_Collection implements Countable, IteratorAggrega
93101
*
94102
* @throws InvalidArgumentException When an invalid argument is supplied.
95103
*
96-
* @param OD_URL_Metric[] $url_metrics URL Metrics.
97-
* @param int[] $breakpoints Breakpoints in max widths.
98-
* @param int $sample_size Sample size for the maximum number of viewports in a group between breakpoints.
99-
* @param int $freshness_ttl Freshness age (TTL) for a given URL Metric.
104+
* @param OD_URL_Metric[] $url_metrics URL Metrics.
105+
* @param non-empty-string $current_etag The current ETag.
106+
* @param int[] $breakpoints Breakpoints in max widths.
107+
* @param int $sample_size Sample size for the maximum number of viewports in a group between breakpoints.
108+
* @param int $freshness_ttl Freshness age (TTL) for a given URL Metric.
100109
*/
101-
public function __construct( array $url_metrics, array $breakpoints, int $sample_size, int $freshness_ttl ) {
110+
public function __construct( array $url_metrics, string $current_etag, array $breakpoints, int $sample_size, int $freshness_ttl ) {
111+
// Set current ETag.
112+
if ( 1 !== preg_match( '/^[a-f0-9]{32}\z/', $current_etag ) ) {
113+
throw new InvalidArgumentException(
114+
esc_html(
115+
sprintf(
116+
/* translators: %s is the invalid ETag */
117+
__( 'The current ETag must be a valid MD5 hash, but provided: %s', 'optimization-detective' ),
118+
$current_etag
119+
)
120+
)
121+
);
122+
}
123+
$this->current_etag = $current_etag;
124+
102125
// Set breakpoints.
103126
sort( $breakpoints );
104127
$breakpoints = array_values( array_unique( $breakpoints, SORT_NUMERIC ) );
@@ -160,6 +183,17 @@ public function __construct( array $url_metrics, array $breakpoints, int $sample
160183
}
161184
}
162185

186+
/**
187+
* Gets the current ETag.
188+
*
189+
* @since n.e.x.t
190+
*
191+
* @return non-empty-string Current ETag.
192+
*/
193+
public function get_current_etag(): string {
194+
return $this->current_etag;
195+
}
196+
163197
/**
164198
* Gets the first URL Metric group.
165199
*
@@ -613,6 +647,7 @@ public function count(): int {
613647
* @since 0.3.1
614648
*
615649
* @return array{
650+
* current_etag: non-empty-string,
616651
* breakpoints: positive-int[],
617652
* freshness_ttl: 0|positive-int,
618653
* sample_size: positive-int,
@@ -631,6 +666,7 @@ public function count(): int {
631666
*/
632667
public function jsonSerialize(): array {
633668
return array(
669+
'current_etag' => $this->current_etag,
634670
'breakpoints' => $this->breakpoints,
635671
'freshness_ttl' => $this->freshness_ttl,
636672
'sample_size' => $this->sample_size,

plugins/optimization-detective/class-od-url-metric-group.php

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ final class OD_URL_Metric_Group implements IteratorAggregate, Countable, JsonSer
6363
/**
6464
* Collection that this instance belongs to.
6565
*
66-
* @var OD_URL_Metric_Group_Collection|null
66+
* @var OD_URL_Metric_Group_Collection
6767
*/
6868
private $collection;
6969

@@ -82,16 +82,19 @@ final class OD_URL_Metric_Group implements IteratorAggregate, Countable, JsonSer
8282
/**
8383
* Constructor.
8484
*
85+
* This class should never be directly constructed. It should only be constructed by the {@see OD_URL_Metric_Group_Collection::create_groups()}.
86+
*
87+
* @access private
8588
* @throws InvalidArgumentException If arguments are invalid.
8689
*
87-
* @param OD_URL_Metric[] $url_metrics URL Metrics to add to the group.
88-
* @param int $minimum_viewport_width Minimum possible viewport width for the group. Must be zero or greater.
89-
* @param int $maximum_viewport_width Maximum possible viewport width for the group. Must be greater than zero and the minimum viewport width.
90-
* @param int $sample_size Sample size for the maximum number of viewports in a group between breakpoints.
91-
* @param int $freshness_ttl Freshness age (TTL) for a given URL Metric.
92-
* @param OD_URL_Metric_Group_Collection|null $collection Collection that this instance belongs to. Optional.
90+
* @param OD_URL_Metric[] $url_metrics URL Metrics to add to the group.
91+
* @param int $minimum_viewport_width Minimum possible viewport width for the group. Must be zero or greater.
92+
* @param int $maximum_viewport_width Maximum possible viewport width for the group. Must be greater than zero and the minimum viewport width.
93+
* @param int $sample_size Sample size for the maximum number of viewports in a group between breakpoints.
94+
* @param int $freshness_ttl Freshness age (TTL) for a given URL Metric.
95+
* @param OD_URL_Metric_Group_Collection $collection Collection that this instance belongs to.
9396
*/
94-
public function __construct( array $url_metrics, int $minimum_viewport_width, int $maximum_viewport_width, int $sample_size, int $freshness_ttl, ?OD_URL_Metric_Group_Collection $collection = null ) {
97+
public function __construct( array $url_metrics, int $minimum_viewport_width, int $maximum_viewport_width, int $sample_size, int $freshness_ttl, OD_URL_Metric_Group_Collection $collection ) {
9598
if ( $minimum_viewport_width < 0 ) {
9699
throw new InvalidArgumentException(
97100
esc_html__( 'The minimum viewport width must be at least zero.', 'optimization-detective' )
@@ -135,12 +138,8 @@ public function __construct( array $url_metrics, int $minimum_viewport_width, in
135138
);
136139
}
137140
$this->freshness_ttl = $freshness_ttl;
138-
139-
if ( ! is_null( $collection ) ) {
140-
$this->collection = $collection;
141-
}
142-
143-
$this->url_metrics = $url_metrics;
141+
$this->collection = $collection;
142+
$this->url_metrics = $url_metrics;
144143
}
145144

146145
/**
@@ -191,9 +190,7 @@ public function add_url_metric( OD_URL_Metric $url_metric ): void {
191190
}
192191

193192
$this->result_cache = array();
194-
if ( ! is_null( $this->collection ) ) {
195-
$this->collection->clear_cache();
196-
}
193+
$this->collection->clear_cache();
197194

198195
$url_metric->set_group( $this );
199196
$this->url_metrics[] = $url_metric;
@@ -220,6 +217,8 @@ static function ( OD_URL_Metric $a, OD_URL_Metric $b ): int {
220217
* A group is complete if it has the full sample size of URL Metrics
221218
* and all of these URL Metrics are fresh.
222219
*
220+
* @since n.e.x.t If the current environment's generated ETag does not match the URL Metric's ETag, the URL Metric is considered stale.
221+
*
223222
* @return bool Whether complete.
224223
*/
225224
public function is_complete(): bool {
@@ -233,9 +232,20 @@ public function is_complete(): bool {
233232
}
234233
$current_time = microtime( true );
235234
foreach ( $this->url_metrics as $url_metric ) {
235+
// The URL Metric is too old to be fresh.
236236
if ( $current_time > $url_metric->get_timestamp() + $this->freshness_ttl ) {
237237
return false;
238238
}
239+
240+
// The ETag is not populated yet, so this is stale. Eventually this will be required.
241+
if ( $url_metric->get_etag() === null ) {
242+
return false;
243+
}
244+
245+
// The ETag of the URL Metric does not match the current ETag for the collection, so it is stale.
246+
if ( ! hash_equals( $url_metric->get_etag(), $this->collection->get_current_etag() ) ) {
247+
return false;
248+
}
239249
}
240250

241251
return true;

plugins/optimization-detective/class-od-url-metric.php

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
* }
3939
* @phpstan-type Data array{
4040
* uuid: non-empty-string,
41+
* etag?: non-empty-string,
4142
* url: non-empty-string,
4243
* timestamp: float,
4344
* viewport: ViewportRect,
@@ -155,6 +156,7 @@ public function set_group( OD_URL_Metric_Group $group ): void {
155156
* Gets JSON schema for URL Metric.
156157
*
157158
* @since 0.1.0
159+
* @since n.e.x.t Added the 'etag' property to the schema.
158160
*
159161
* @todo Cache the return value?
160162
*
@@ -208,6 +210,15 @@ public static function get_json_schema(): array {
208210
'required' => true,
209211
'readonly' => true, // Omit from REST API.
210212
),
213+
'etag' => array(
214+
'description' => __( 'The ETag for the URL Metric.', 'optimization-detective' ),
215+
'type' => 'string',
216+
'pattern' => '^[0-9a-f]{32}\z',
217+
'minLength' => 32,
218+
'maxLength' => 32,
219+
'required' => false, // To be made required in a future release.
220+
'readonly' => true, // Omit from REST API.
221+
),
211222
'url' => array(
212223
'description' => __( 'The URL for which the metric was obtained.', 'optimization-detective' ),
213224
'type' => 'string',
@@ -309,7 +320,7 @@ public static function get_json_schema(): array {
309320
$schema['properties']['elements']['items']['properties'] = self::extend_schema_with_optional_properties(
310321
$schema['properties']['elements']['items']['properties'],
311322
$additional_properties,
312-
'od_url_metric_schema_root_additional_properties'
323+
'od_url_metric_schema_element_item_additional_properties'
313324
);
314325
}
315326

@@ -417,6 +428,18 @@ public function get_uuid(): string {
417428
return $this->data['uuid'];
418429
}
419430

431+
/**
432+
* Gets ETag.
433+
*
434+
* @since n.e.x.t
435+
*
436+
* @return non-empty-string|null ETag.
437+
*/
438+
public function get_etag(): ?string {
439+
// Since the ETag is optional for now, return null for old URL Metrics that do not have one.
440+
return $this->data['etag'] ?? null;
441+
}
442+
420443
/**
421444
* Gets URL.
422445
*

plugins/optimization-detective/detect.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ function extendElementData( xpath, properties ) {
237237
* @param {number} args.maxViewportAspectRatio Maximum aspect ratio allowed for the viewport.
238238
* @param {boolean} args.isDebug Whether to show debug messages.
239239
* @param {string} args.restApiEndpoint URL for where to send the detection data.
240+
* @param {string} args.currentETag Current ETag.
240241
* @param {string} args.currentUrl Current URL.
241242
* @param {string} args.urlMetricSlug Slug for URL Metric.
242243
* @param {number|null} args.cachePurgePostId Cache purge post ID.
@@ -252,6 +253,7 @@ export default async function detect( {
252253
isDebug,
253254
extensionModuleUrls,
254255
restApiEndpoint,
256+
currentETag,
255257
currentUrl,
256258
urlMetricSlug,
257259
cachePurgePostId,
@@ -539,6 +541,7 @@ export default async function detect( {
539541

540542
const url = new URL( restApiEndpoint );
541543
url.searchParams.set( 'slug', urlMetricSlug );
544+
url.searchParams.set( 'current_etag', currentETag );
542545
if ( typeof cachePurgePostId === 'number' ) {
543546
url.searchParams.set(
544547
'cache_purge_post_id',

plugins/optimization-detective/detection.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,16 +85,20 @@ function od_get_detection_script( string $slug, OD_URL_Metric_Group_Collection $
8585
$cache_purge_post_id = od_get_cache_purge_post_id();
8686

8787
$current_url = od_get_current_url();
88+
89+
$current_etag = $group_collection->get_current_etag();
90+
8891
$detect_args = array(
8992
'minViewportAspectRatio' => od_get_minimum_viewport_aspect_ratio(),
9093
'maxViewportAspectRatio' => od_get_maximum_viewport_aspect_ratio(),
9194
'isDebug' => WP_DEBUG,
9295
'extensionModuleUrls' => $extension_module_urls,
9396
'restApiEndpoint' => rest_url( OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE ),
97+
'currentETag' => $current_etag,
9498
'currentUrl' => $current_url,
9599
'urlMetricSlug' => $slug,
96100
'cachePurgePostId' => od_get_cache_purge_post_id(),
97-
'urlMetricHMAC' => od_get_url_metrics_storage_hmac( $slug, $current_url, $cache_purge_post_id ),
101+
'urlMetricHMAC' => od_get_url_metrics_storage_hmac( $slug, $current_etag, $current_url, $cache_purge_post_id ),
98102
'urlMetricGroupStatuses' => array_map(
99103
static function ( OD_URL_Metric_Group $group ): array {
100104
return array(

plugins/optimization-detective/optimization.php

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -195,16 +195,6 @@ function od_optimize_template_output_buffer( string $buffer ): string {
195195
$slug = od_get_url_metrics_slug( od_get_normalized_query_vars() );
196196
$post = OD_URL_Metrics_Post_Type::get_post( $slug );
197197

198-
$group_collection = new OD_URL_Metric_Group_Collection(
199-
$post instanceof WP_Post ? OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ) : array(),
200-
od_get_breakpoint_max_widths(),
201-
od_get_url_metrics_breakpoint_sample_size(),
202-
od_get_url_metric_freshness_ttl()
203-
);
204-
205-
// Whether we need to add the data-od-xpath attribute to elements and whether the detection script should be injected.
206-
$needs_detection = ! $group_collection->is_every_group_complete();
207-
208198
$tag_visitor_registry = new OD_Tag_Visitor_Registry();
209199

210200
/**
@@ -216,10 +206,22 @@ function od_optimize_template_output_buffer( string $buffer ): string {
216206
*/
217207
do_action( 'od_register_tag_visitors', $tag_visitor_registry );
218208

209+
$current_etag = od_get_current_url_metrics_etag( $tag_visitor_registry );
210+
$group_collection = new OD_URL_Metric_Group_Collection(
211+
$post instanceof WP_Post ? OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ) : array(),
212+
$current_etag,
213+
od_get_breakpoint_max_widths(),
214+
od_get_url_metrics_breakpoint_sample_size(),
215+
od_get_url_metric_freshness_ttl()
216+
);
219217
$link_collection = new OD_Link_Collection();
220218
$tag_visitor_context = new OD_Tag_Visitor_Context( $processor, $group_collection, $link_collection );
221219
$current_tag_bookmark = 'optimization_detective_current_tag';
222220
$visitors = iterator_to_array( $tag_visitor_registry );
221+
222+
// Whether we need to add the data-od-xpath attribute to elements and whether the detection script should be injected.
223+
$needs_detection = ! $group_collection->is_every_group_complete();
224+
223225
do {
224226
$tracked_in_url_metrics = false;
225227
$processor->set_bookmark( $current_tag_bookmark ); // TODO: Should we break if this returns false?

plugins/optimization-detective/readme.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,14 @@ add_filter(
238238

239239
See also [example usage](https://github.com/WordPress/performance/blob/6bb8405c5c446e3b66c2bfa3ae03ba61b188bca2/plugins/embed-optimizer/hooks.php#L128-L144) in Embed Optimizer. Note in particular the structure of the plugin’s [detect.js](https://github.com/WordPress/performance/blob/trunk/plugins/embed-optimizer/detect.js) script module, how it exports `initialize` and `finalize` functions which Optimization Detective then calls when the page loads and when the page unloads, at which time the URL Metric is constructed and sent to the server for storage. Refer also to the [TypeScript type definitions](https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/types.ts).
240240

241+
**Filter:** `od_current_url_metrics_etag_data` (default: array with `tag_visitors` key)
242+
243+
Filters the data that goes into computing the current ETag for URL Metrics.
244+
245+
The ETag is a unique identifier that changes whenever the underlying data used to generate it changes. By default, the ETag calculation includes the names of registered tag visitors. This ensures that when a new Optimization Detective-dependent plugin is activated (like Image Prioritizer or Embed Optimizer), any existing URL Metrics are immediately considered stale. This happens because the newly registered tag visitors alter the ETag calculation, making it different from the stored ones.
246+
247+
When the ETag for URL Metrics in a complete viewport group no longer matches the current environment's ETag, new URL Metrics will then begin to be collected until there are no more stored URL Metrics with the old ETag. These new URL Metrics will include data relevant to the newly activated plugins and their tag visitors.
248+
241249
**Action:** `od_url_metric_stored` (argument: `OD_URL_Metric_Store_Request_Context`)
242250

243251
Fires whenever a URL Metric was successfully stored.

plugins/optimization-detective/storage/class-od-url-metrics-post-type.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,18 @@ public static function store_url_metric( string $slug, OD_URL_Metric $new_url_me
217217
$url_metrics = array();
218218
}
219219

220+
$etag = $new_url_metric->get_etag();
221+
if ( null === $etag ) {
222+
// This case actually will never occur in practice because the store_url_metric function is only called
223+
// in the REST API endpoint where the ETag parameter is required. It is here exclusively for the sake of
224+
// PHPStan's static analysis. This entire condition can be removed in a future release when the 'etag'
225+
// property becomes required.
226+
return new WP_Error( 'missing_etag' );
227+
}
228+
220229
$group_collection = new OD_URL_Metric_Group_Collection(
221230
$url_metrics,
231+
$etag,
222232
od_get_breakpoint_max_widths(),
223233
od_get_url_metrics_breakpoint_sample_size(),
224234
od_get_url_metric_freshness_ttl()

0 commit comments

Comments
 (0)