Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4153951
Add string compression using CompressionStream API for URL Metric pay…
b1ink0 Mar 4, 2025
5347657
Add decompression for REST API request body in URL Metrics endpoint
b1ink0 Mar 4, 2025
29fff3c
Fix failing test
b1ink0 Mar 5, 2025
36bc475
Remove redundant comment and improve doc comment
b1ink0 Mar 6, 2025
a4632d1
Keep XPath for matching the required pattern
b1ink0 Mar 6, 2025
14409a6
Remove `isDebug` conditions and improve JSDoc comments for consistency
b1ink0 Mar 6, 2025
2f81ec0
Add REST API request body decompression and related tests
b1ink0 Mar 6, 2025
90aca14
Add test for `rest_pre_dispatch` hook and cover annotations
b1ink0 Mar 6, 2025
eeb066d
Merge branch 'trunk' into add/url-metrics-compression
b1ink0 Mar 6, 2025
8b84ed3
Suppress unused default export error
westonruter Mar 10, 2025
c59c3ed
Add ext-zlib to composer.json
westonruter Mar 10, 2025
130277a
Switch ext-zlip from require to suggest
westonruter Mar 10, 2025
cbfba12
Run composer update
westonruter Mar 10, 2025
0ca808f
Fix misspelling
westonruter Mar 10, 2025
e1d4b1e
Keep timestamps for URL Metrics current in fixture
westonruter Mar 10, 2025
af1d1dc
Compress URL metrics only when `gzdecode` is available
b1ink0 Mar 12, 2025
97c7836
Merge branch 'trunk' into add/url-metrics-compression
b1ink0 Mar 12, 2025
82fe8e9
Always pass `gzdecodeAvailable` to client
b1ink0 Mar 12, 2025
1984db0
Add max URL metrics size constraints
b1ink0 Mar 13, 2025
5f0be29
Add tests for max URL metrics size filter in REST API
b1ink0 Mar 13, 2025
f9d7fe1
Ensure `od_get_max_url_metric_size` returns a valid positive value wi…
b1ink0 Mar 13, 2025
2a11bf5
Improve DOC comment for filter
b1ink0 Mar 13, 2025
ba383c6
Merge branch 'trunk' of https://github.com/WordPress/performance into…
westonruter Mar 14, 2025
746a410
Use maximum instead of max in PHP; add test coverage
westonruter Mar 14, 2025
d41da3c
Move od_get_maximum_url_metric_size to storage/data.php
westonruter Mar 14, 2025
ad6d264
Fix passing filter name to _doing_it_wrong()
westonruter Mar 14, 2025
c0e3291
Rename string to jsonString to avoid potential type confusion
westonruter Mar 14, 2025
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
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
},
"suggest": {
"ext-imagick": "Required to use Modern Image Format's Dominant_Color_Image_Editor_Imagick class",
"ext-gd": "Required to use Modern Image Format's Dominant_Color_Image_Editor_GD class"
"ext-gd": "Required to use Modern Image Format's Dominant_Color_Image_Editor_GD class",
"ext-zlib": "Required for compression of URL Metric data submitted to the REST API for storage in Optimization Detective"
},
"require-dev": {
"phpcompatibility/php-compatibility": "^9.3",
Expand Down
52 changes: 26 additions & 26 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

108 changes: 66 additions & 42 deletions plugins/optimization-detective/detect.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// noinspection JSUnusedGlobalSymbols
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this because in PhpStorm because otherwise there is a warning:

image

The reason the IDE doesn't know the export is used is because it is used in PHP and via a dynamic export. See also https://stackoverflow.com/questions/54687009/export-default-unused-in-webstorm

This noinspection annotation is used in Gutenberg once as well: https://github.com/WordPress/gutenberg/blob/8b88ada2fd675509bf0c39a55c23a75cc67987cb/packages/components/src/mobile/link-settings/test/edit.native.js#L1


/**
* @typedef {import("web-vitals").LCPMetric} LCPMetric
* @typedef {import("web-vitals").LCPMetricWithAttribution} LCPMetricWithAttribution
Expand Down Expand Up @@ -214,7 +216,7 @@ function getCurrentTime() {
/**
* Recursively freezes an object to prevent mutation.
*
* @param {Object} obj Object to recursively freeze.
* @param {Object} obj - Object to recursively freeze.
*/
function recursiveFreeze( obj ) {
for ( const prop of Object.getOwnPropertyNames( obj ) ) {
Expand Down Expand Up @@ -293,7 +295,7 @@ const reservedElementPropertyKeys = new Set( [
/**
* Gets element data.
*
* @param {string} xpath XPath.
* @param {string} xpath - XPath.
* @return {ElementData|null} Element data, or null if no element for the XPath exists.
*/
function getElementData( xpath ) {
Expand All @@ -309,8 +311,8 @@ function getElementData( xpath ) {
/**
* Extends element data.
*
* @param {string} xpath XPath.
* @param {ExtendedElementData} properties Properties.
* @param {string} xpath - XPath.
* @param {ExtendedElementData} properties - Properties.
*/
function extendElementData( xpath, properties ) {
if ( ! elementsByXPath.has( xpath ) ) {
Expand All @@ -327,6 +329,23 @@ function extendElementData( xpath, properties ) {
Object.assign( elementData, properties );
}

/**
* Compresses a string using CompressionStream API.
*
* @param {string} string - String to compress.
* @return {Promise<Blob>} Compressed data.
*/
async function compress( string ) {
const encodedData = new TextEncoder().encode( string );
const compressedDataStream = new Blob( [ encodedData ] )
.stream()
.pipeThrough( new CompressionStream( 'gzip' ) );
const compressedDataBuffer = await new Response(
compressedDataStream
).arrayBuffer();
return new Blob( [ compressedDataBuffer ], { type: 'application/gzip' } );
}

/**
* @typedef {{timestamp: number, creationDate: Date}} UrlMetricDebugData
* @typedef {{groups: Array<{url_metrics: Array<UrlMetricDebugData>}>}} CollectionDebugData
Expand All @@ -335,23 +354,25 @@ function extendElementData( xpath, properties ) {
/**
* Detects the LCP element, loaded images, client viewport and store for future optimizations.
*
* @param {Object} args Args.
* @param {string[]} args.extensionModuleUrls URLs for extension script modules to import.
* @param {number} args.minViewportAspectRatio Minimum aspect ratio allowed for the viewport.
* @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.
* @param {Object} args - Args.
* @param {string[]} args.extensionModuleUrls - URLs for extension script modules to import.
* @param {number} args.minViewportAspectRatio - Minimum aspect ratio allowed for the viewport.
* @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 {boolean} args.gzdecodeAvailable - Whether application/gzip can be sent to the REST API.
* @param {number} args.maxUrlMetricSize - Maximum size of the URL Metric to send.
* @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.
*/
export default async function detect( {
minViewportAspectRatio,
Expand All @@ -360,6 +381,8 @@ export default async function detect( {
extensionModuleUrls,
restApiEndpoint,
restApiNonce,
gzdecodeAvailable,
maxUrlMetricSize,
currentETag,
currentUrl,
urlMetricSlug,
Expand Down Expand Up @@ -670,9 +693,7 @@ export default async function detect( {
for ( const elementIntersection of elementIntersections ) {
const xpath = breadcrumbedElementsMap.get( elementIntersection.target );
if ( ! xpath ) {
if ( isDebug ) {
error( 'Unable to look up XPath for element' );
}
warn( 'Unable to look up XPath for element' );
continue;
}

Expand Down Expand Up @@ -795,26 +816,34 @@ export default async function detect( {
const maxBodyLengthKiB = 64;
const maxBodyLengthBytes = maxBodyLengthKiB * 1024;

// TODO: Consider adding replacer to reduce precision on numbers in DOMRect to reduce payload size.
const jsonBody = JSON.stringify( urlMetric );
if ( jsonBody.length > maxUrlMetricSize ) {
error(
`URL Metric is ${ jsonBody.length.toLocaleString() } bytes, exceeding the maximum size of ${ maxUrlMetricSize.toLocaleString() } bytes:`,
urlMetric
);
return;
}

const payloadBlob = gzdecodeAvailable
? await compress( jsonBody )
: new Blob( [ jsonBody ], { type: 'application/json' } );
const percentOfBudget =
( jsonBody.length / ( maxBodyLengthKiB * 1000 ) ) * 100;
( payloadBlob.size / ( maxBodyLengthKiB * 1000 ) ) * 100;

/*
* According to the fetch() spec:
* "If the sum of contentLength and inflightKeepaliveBytes is greater than 64 kibibytes, then return a network error."
* This is what browsers also implement for navigator.sendBeacon(). Therefore, if the size of the JSON is greater
* than the maximum, we should avoid even trying to send it.
*/
if ( jsonBody.length > maxBodyLengthBytes ) {
if ( isDebug ) {
error(
`Unable to send URL Metric because it is ${ jsonBody.length.toLocaleString() } bytes, ${ Math.round(
percentOfBudget
) }% of ${ maxBodyLengthKiB } KiB limit:`,
urlMetric
);
}
if ( payloadBlob.size > maxBodyLengthBytes ) {
error(
`Unable to send URL Metric because it is ${ payloadBlob.size.toLocaleString() } bytes, ${ Math.round(
percentOfBudget
) }% of ${ maxBodyLengthKiB } KiB limit:`,
urlMetric
);
return;
}

Expand All @@ -830,7 +859,7 @@ export default async function detect( {
);
}

const message = `Sending URL Metric (${ jsonBody.length.toLocaleString() } bytes, ${ Math.round(
const message = `Sending URL Metric (${ payloadBlob.size.toLocaleString() } bytes, ${ Math.round(
percentOfBudget
) }% of ${ maxBodyLengthKiB } KiB limit):`;

Expand All @@ -854,12 +883,7 @@ export default async function detect( {
);
}
url.searchParams.set( 'hmac', urlMetricHMAC );
navigator.sendBeacon(
url,
new Blob( [ jsonBody ], {
type: 'application/json',
} )
);
navigator.sendBeacon( url, payloadBlob );

// Clean up.
breadcrumbedElementsMap.clear();
Expand Down
2 changes: 2 additions & 0 deletions plugins/optimization-detective/detection.php
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ static function ( OD_URL_Metric_Group $group ): array {
'storageLockTTL' => OD_Storage_Lock::get_ttl(),
'freshnessTTL' => od_get_url_metric_freshness_ttl(),
'webVitalsLibrarySrc' => $web_vitals_lib_src,
'gzdecodeAvailable' => function_exists( 'gzdecode' ),
'maxUrlMetricSize' => od_get_max_url_metric_size(),
);
if ( is_user_logged_in() ) {
$detect_args['restApiNonce'] = wp_create_nonce( 'wp_rest' );
Expand Down
20 changes: 20 additions & 0 deletions plugins/optimization-detective/helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,23 @@ function od_get_asset_path( string $src_path, ?string $min_path = null ): string

return $min_path;
}

/**
* Gets the maximum allowed size in bytes for a URL Metric serialized to JSON.
*
* @since n.e.x.t
* @access private
*
* @return int Maximum allowed byte size.
*/
function od_get_max_url_metric_size(): int {
/**
* Filters the maximum allowed size in bytes for a URL Metric serialized to JSON.
*
* @since n.e.x.t
*
* @param int $max_size Maximum allowed byte size.
* @return int Filtered maximum allowed byte size.
*/
return (int) apply_filters( 'od_max_url_metric_size', MB_IN_BYTES );
}
1 change: 1 addition & 0 deletions plugins/optimization-detective/hooks.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@
add_filter( 'site_status_tests', 'od_add_rest_api_availability_test' );
add_action( 'admin_init', 'od_maybe_run_rest_api_health_check' );
add_action( 'after_plugin_row_meta', 'od_render_rest_api_health_check_admin_notice_in_plugin_row', 30 );
add_filter( 'rest_pre_dispatch', 'od_decompress_rest_request_body', 10, 3 );
// @codeCoverageIgnoreEnd
Loading
Loading