Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Disable URL Metric storage locking by default for administrators #1835

Merged
merged 13 commits into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 104 additions & 18 deletions plugins/optimization-detective/detect.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,33 +96,65 @@ function error( ...message ) {
}

/**
* Checks whether the URL Metric(s) for the provided viewport width is needed.
* Gets the status for the URL Metric group for the provided viewport width.
*
* The comparison logic here corresponds with the PHP logic in `OD_URL_Metric_Group::is_viewport_width_in_range()`.
* This function is also similar to the PHP logic in `\OD_URL_Metric_Group_Collection::get_group_for_viewport_width()`.
*
* @param {number} viewportWidth - Current viewport width.
* @param {URLMetricGroupStatus[]} urlMetricGroupStatuses - Viewport group statuses.
* @return {boolean} Whether URL Metrics are needed.
* @return {URLMetricGroupStatus} The URL metric group for the viewport width.
*/
function isViewportNeeded( viewportWidth, urlMetricGroupStatuses ) {
if ( viewportWidth === 0 ) {
return false;
}

for ( const {
minimumViewportWidth,
maximumViewportWidth,
complete,
} of urlMetricGroupStatuses ) {
function getGroupForViewportWidth( viewportWidth, urlMetricGroupStatuses ) {
for ( const urlMetricGroupStatus of urlMetricGroupStatuses ) {
if (
viewportWidth > minimumViewportWidth &&
( null === maximumViewportWidth ||
viewportWidth <= maximumViewportWidth )
viewportWidth > urlMetricGroupStatus.minimumViewportWidth &&
( null === urlMetricGroupStatus.maximumViewportWidth ||
viewportWidth <= urlMetricGroupStatus.maximumViewportWidth )
) {
return ! complete;
return urlMetricGroupStatus;
westonruter marked this conversation as resolved.
Show resolved Hide resolved
}
}
return false;
throw new Error(
`${ consoleLogPrefix } Unexpectedly unable to locate group for the current viewport width.`
);
}

/**
* Gets the sessionStorage key for keeping track of whether the current client session already submitted a URL Metric.
*
* @param {string} currentETag - Current ETag.
* @param {string} currentUrl - Current URL.
* @param {URLMetricGroupStatus} urlMetricGroupStatus - URL Metric group status.
* @return {Promise<string>} Session storage key.
*/
async function getAlreadySubmittedSessionStorageKey(
currentETag,
currentUrl,
urlMetricGroupStatus
) {
const message = [
currentETag,
currentUrl,
urlMetricGroupStatus.minimumViewportWidth,
urlMetricGroupStatus.maximumViewportWidth || '',
].join( '-' );

/*
* Note that the components are hashed for a couple of reasons:
*
* 1. It results in a consistent length string devoid of any special characters that could cause problems.
* 2. Since the key includes the URL, hashing it avoids potential privacy concerns where the sessionStorage is
* examined to see which URLs the client went to.
*
* The SHA-1 algorithm is chosen since it is the fastest and there is no need for cryptographic security.
*/
const msgBuffer = new TextEncoder().encode( message );
const hashBuffer = await crypto.subtle.digest( 'SHA-1', msgBuffer );
const hashHex = Array.from( new Uint8Array( hashBuffer ) )
.map( ( b ) => b.toString( 16 ).padStart( 2, '0' ) )
.join( '' );
return `odSubmitted-${ hashHex }`;
}

/**
Expand Down Expand Up @@ -264,13 +296,15 @@ function extendElementData( xpath, properties ) {
* @param {number} args.maxViewportAspectRatio Maximum aspect ratio allowed for the viewport.
* @param {boolean} args.isDebug Whether to show debug messages.
* @param {string} args.restApiEndpoint URL for where to send the detection data.
* @param {string} [args.restApiNonce] Nonce for the REST API when the user is logged-in.
* @param {string} args.currentETag Current ETag.
* @param {string} args.currentUrl Current URL.
* @param {string} args.urlMetricSlug Slug for URL Metric.
* @param {number|null} args.cachePurgePostId Cache purge post ID.
* @param {string} args.urlMetricHMAC HMAC for URL Metric storage.
* @param {URLMetricGroupStatus[]} args.urlMetricGroupStatuses URL Metric group statuses.
* @param {number} args.storageLockTTL The TTL (in seconds) for the URL Metric storage lock.
* @param {number} args.freshnessTTL The freshness age (TTL) for a given URL Metric.
* @param {string} args.webVitalsLibrarySrc The URL for the web-vitals library.
* @param {CollectionDebugData} [args.urlMetricGroupCollection] URL Metric group collection, when in debug mode.
*/
Expand All @@ -280,13 +314,15 @@ export default async function detect( {
isDebug,
extensionModuleUrls,
restApiEndpoint,
restApiNonce,
currentETag,
currentUrl,
urlMetricSlug,
cachePurgePostId,
urlMetricHMAC,
urlMetricGroupStatuses,
storageLockTTL,
freshnessTTL,
webVitalsLibrarySrc,
urlMetricGroupCollection,
} ) {
Expand All @@ -308,14 +344,55 @@ export default async function detect( {
);
}

if ( win.innerWidth === 0 || win.innerHeight === 0 ) {
if ( isDebug ) {
log(
'Window must have non-zero dimensions for URL Metric collection.'
);
}
return;
}

// Abort if the current viewport is not among those which need URL Metrics.
if ( ! isViewportNeeded( win.innerWidth, urlMetricGroupStatuses ) ) {
const urlMetricGroupStatus = getGroupForViewportWidth(
win.innerWidth,
urlMetricGroupStatuses
);
if ( urlMetricGroupStatus.complete ) {
if ( isDebug ) {
log( 'No need for URL Metrics from the current viewport.' );
}
return;
}

// Abort if the client already submitted a URL Metric for this URL and viewport group.
// TODO: Remove the console timing.
console.time( 'sha1' ); // eslint-disable-line no-console
const alreadySubmittedSessionStorageKey =
await getAlreadySubmittedSessionStorageKey(
currentETag,
currentUrl,
urlMetricGroupStatus
);
console.timeEnd( 'sha1' ); // eslint-disable-line no-console
westonruter marked this conversation as resolved.
Show resolved Hide resolved
if ( alreadySubmittedSessionStorageKey in sessionStorage ) {
const previousVisitTime = parseInt(
sessionStorage.getItem( alreadySubmittedSessionStorageKey ),
10
);
if (
! isNaN( previousVisitTime ) &&
( getCurrentTime() - previousVisitTime ) / 1000 < freshnessTTL
) {
if ( isDebug ) {
log(
'The current client session already submitted a fresh URL Metric for this URL so a new one will not be collected now.'
);
return;
}
}
}

// Abort if the viewport aspect ratio is not in a common range.
const aspectRatio = win.innerWidth / win.innerHeight;
if (
Expand Down Expand Up @@ -670,11 +747,20 @@ export default async function detect( {
// because we can't look at the response when sending a beacon.
setStorageLock( getCurrentTime() );

// Remember that the URL Metric was submitted for this URL to avoid having multiple entries submitted by the same client.
sessionStorage.setItem(
alreadySubmittedSessionStorageKey,
String( getCurrentTime() )
);

if ( isDebug ) {
log( 'Sending URL Metric:', urlMetric );
}

const url = new URL( restApiEndpoint );
if ( typeof restApiNonce === 'string' ) {
url.searchParams.set( '_wpnonce', restApiNonce );
}
url.searchParams.set( 'slug', urlMetricSlug );
url.searchParams.set( 'current_etag', currentETag );
if ( typeof cachePurgePostId === 'number' ) {
Expand Down
4 changes: 4 additions & 0 deletions plugins/optimization-detective/detection.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,12 @@ static function ( OD_URL_Metric_Group $group ): array {
iterator_to_array( $group_collection )
),
'storageLockTTL' => OD_Storage_Lock::get_ttl(),
'freshnessTTL' => od_get_url_metric_freshness_ttl(),
'webVitalsLibrarySrc' => $web_vitals_lib_src,
);
if ( is_user_logged_in() ) {
$detect_args['restApiNonce'] = wp_create_nonce( 'wp_rest' );
}
if ( WP_DEBUG ) {
$detect_args['urlMetricGroupCollection'] = $group_collection;
}
Expand Down
8 changes: 6 additions & 2 deletions plugins/optimization-detective/docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,16 +102,20 @@ add_filter( 'od_url_metrics_breakpoint_sample_size', function (): int {
} );
```

### Filter: `od_url_metric_storage_lock_ttl` (default: 1 minute in seconds)
### Filter: `od_url_metric_storage_lock_ttl` (default: 60 seconds, except 0 for authorized logged-in users)

Filters how long a given IP is locked from submitting another metric-storage REST API request. Filtering the TTL to zero will disable any metric storage locking. This is useful, for example, to disable locking when a user is logged-in with code like the following:
Filters how long the current IP is locked from submitting another URL metric storage REST API request.

Filtering the TTL to zero will disable any URL Metric storage locking. This is useful, for example, to disable locking when a user is logged-in with code like the following:

```php
add_filter( 'od_metrics_storage_lock_ttl', function ( int $ttl ): int {
return is_user_logged_in() ? 0 : $ttl;
} );
```

By default, the TTL is zero (0) for authorized users and sixty (60) for everyone else. Whether the current user is authorized is determined by whether the user has the `od_store_url_metric_now` capability. This meta capability by default maps to the `manage_options` primitive capability via the `map_meta_cap` filter.

During development this is useful to set to zero so you can quickly collect new URL Metrics by reloading the page without having to wait for the storage lock to release:

```php
Expand Down
1 change: 1 addition & 0 deletions plugins/optimization-detective/hooks.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
add_action( 'init', 'od_initialize_extensions', PHP_INT_MAX );
add_filter( 'template_include', 'od_buffer_output', PHP_INT_MAX );
OD_URL_Metrics_Post_Type::add_hooks();
OD_Storage_Lock::add_hooks();
add_action( 'wp', 'od_maybe_add_template_output_buffer_filter' );
add_action( 'wp_head', 'od_render_generator_meta_tag' );
add_filter( 'site_status_tests', 'od_add_rest_api_availability_test' );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,50 @@
*/
final class OD_Storage_Lock {

/**
* Capability for being able to store a URL Metric now.
*
* @since n.e.x.t
* @access private
* @var string
*/
const STORE_URL_METRIC_NOW_CAPABILITY = 'od_store_url_metric_now';

/**
* Adds hooks.
*
* @since n.e.x.t
* @access private
*/
public static function add_hooks(): void {
add_filter( 'map_meta_cap', array( __CLASS__, 'filter_map_meta_cap' ), 10, 2 );
}

/**
* Filters map_meta_cap to grant the `od_store_url_metric_now` capability to `manage_options` by default.
*
* @since n.e.x.t
* @access private
*
* @param string[]|mixed $caps Primitive capabilities required of the user.
* @param string $cap Capability being checked.
* @return string[] Primitive capabilities required of the user.
*/
public static function filter_map_meta_cap( $caps, string $cap ): array {
if ( ! is_array( $caps ) ) {
$caps = array();
}

$primitive_cap = 'manage_options';
if ( self::STORE_URL_METRIC_NOW_CAPABILITY === $cap ) {
$i = array_search( self::STORE_URL_METRIC_NOW_CAPABILITY, $caps, true );
if ( false !== $i ) {
$caps[ $i ] = $primitive_cap;
}
}
return $caps;
}

/**
* Gets the TTL (in seconds) for the URL Metric storage lock.
*
Expand All @@ -29,22 +73,28 @@ final class OD_Storage_Lock {
* @return int<0, max> TTL in seconds, greater than or equal to zero. A value of zero means that the storage lock should be disabled and thus that transients must not be used.
*/
public static function get_ttl(): int {
$ttl = current_user_can( self::STORE_URL_METRIC_NOW_CAPABILITY ) ? 0 : MINUTE_IN_SECONDS;

/**
* Filters how long a given IP is locked from submitting another metric-storage REST API request.
* Filters how long the current IP is locked from submitting another URL metric storage REST API request.
*
* Filtering the TTL to zero will disable any metric storage locking. This is useful, for example, to disable
* Filtering the TTL to zero will disable any URL Metric storage locking. This is useful, for example, to disable
* locking when a user is logged-in with code like the following:
*
* add_filter( 'od_metrics_storage_lock_ttl', static function ( int $ttl ): int {
* return is_user_logged_in() ? 0 : $ttl;
* } );
*
* By default, the TTL is zero (0) for authorized users and sixty (60) for everyone else. Whether the current
* user is authorized is determined by whether the user has the `od_store_url_metric_now` capability. This
* meta capability by default maps to the `manage_options` primitive capability via the `map_meta_cap` filter.
*
* @since 0.1.0
* @since 1.0.0 This now defaults to zero (0) for administrator users.
*
* @param int $ttl TTL.
* @param int $ttl TTL. Defaults to 0 for administrators, and 60 for everyone else.
*/
$ttl = (int) apply_filters( 'od_url_metric_storage_lock_ttl', MINUTE_IN_SECONDS );
$ttl = (int) apply_filters( 'od_url_metric_storage_lock_ttl', $ttl );
return max( 0, $ttl );
}

Expand Down
Loading
Loading