-
Notifications
You must be signed in to change notification settings - Fork 146
Add ability to prime URL metrics #1850
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
base: trunk
Are you sure you want to change the base?
Changes from 56 commits
57f9958
71cd706
da1d275
bfed250
6ec82e3
af0172e
bff9973
c155fc1
7640ef7
f05f6c0
0631daf
5d93fdc
3118fb3
d8cfa02
8879f60
72b18fd
f5bba48
b0422eb
db1fd81
63564c4
4fd36b6
82a16b5
a50f23e
9201c86
163d24e
57d77ea
2bc9f49
d17dede
97b888f
931861b
0a7c1f1
969f6b5
c1e245d
b7eeef7
41f5085
42bfb98
7487ae8
49f9eab
fed3012
591a013
17e24c4
32f1e9e
53cc462
f9fd604
8de014a
ce69218
7908b89
3df2f36
14f8be3
fdcb05f
a2ebf17
3a795e9
4f93e14
ff6ad0f
ec5dcd7
2550f96
5e64b39
7d42cac
d21ac27
3b41a12
d6a07b1
b201dbe
5c7dca6
d38e5ca
ab9ce29
26dda1d
aa7391b
619dd8d
5335d2a
661dcf1
c934b86
78c838d
d73842a
5b7a26c
fd94d9d
4943768
7afe854
372690f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -43,7 +43,8 @@ | |||||
| * url: non-empty-string, | ||||||
| * timestamp: float, | ||||||
| * viewport: ViewportRect, | ||||||
| * elements: ElementData[] | ||||||
| * elements: ElementData[], | ||||||
| * source?: non-empty-string, | ||||||
| * } | ||||||
| * @phpstan-type JSONSchema array{ | ||||||
| * type: string|string[], | ||||||
|
|
@@ -310,6 +311,16 @@ public static function get_json_schema(): array { | |||||
| 'additionalProperties' => true, | ||||||
| ), | ||||||
| ), | ||||||
| 'source' => array( | ||||||
| 'description' => __( 'The source of the URL Metric.', 'optimization-detective' ), | ||||||
| 'type' => 'string', | ||||||
| 'required' => false, | ||||||
| 'enum' => array( | ||||||
| 'visitor', | ||||||
| 'user', | ||||||
| 'synthetic', | ||||||
| ), | ||||||
| ), | ||||||
| ), | ||||||
| // Additional root properties may be added to the schema via the od_url_metric_schema_root_additional_properties filter. | ||||||
| // Therefore, additionalProperties is set to true so that additional properties defined in the extended schema may persist | ||||||
|
|
@@ -526,6 +537,17 @@ function ( array $element ): OD_Element { | |||||
| return $this->elements; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Gets the source of the URL Metric. | ||||||
| * | ||||||
| * @since n.e.x.t | ||||||
| * | ||||||
| * @return non-empty-string|null Source. | ||||||
|
||||||
| * @return non-empty-string|null Source. | |
| * @return 'visitor'|'user'|'synthetic'|null Source. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -65,6 +65,14 @@ const storageLockTimeSessionKey = 'odStorageLockTime'; | |
| */ | ||
| const compressionDebounceWaitDuration = 1000; | ||
|
|
||
| /** | ||
| * Verification token for skipping the storage lock check while priming URL Metrics. | ||
| * | ||
| * @see {detect} | ||
| * @type {?string} | ||
| */ | ||
| let odPrimeUrlMetricsVerificationToken = null; | ||
|
|
||
| /** | ||
| * Checks whether storage is locked. | ||
| * | ||
|
|
@@ -73,6 +81,10 @@ const compressionDebounceWaitDuration = 1000; | |
| * @return {boolean} Whether storage is locked. | ||
| */ | ||
| function isStorageLocked( currentTime, storageLockTTL ) { | ||
| if ( odPrimeUrlMetricsVerificationToken ) { | ||
| return false; | ||
| } | ||
|
|
||
| if ( storageLockTTL === 0 ) { | ||
| return false; | ||
| } | ||
|
|
@@ -503,6 +515,116 @@ function debounceCompressUrlMetric() { | |
| }, compressionDebounceWaitDuration ); | ||
| } | ||
|
|
||
| /** | ||
| * Forces immediate compression of the URL Metric, bypassing debounce and idle callbacks. | ||
| * | ||
| * @return {Promise<void>} A promise that resolves when the compression is complete. | ||
| */ | ||
| async function forceCompressUrlMetric() { | ||
| if ( ! compressionEnabled ) { | ||
| return null; | ||
| } | ||
| if ( null !== recompressionTimeout ) { | ||
| clearTimeout( recompressionTimeout ); | ||
| recompressionTimeout = null; | ||
| } | ||
| if ( | ||
| null !== idleCallbackHandle && | ||
| typeof cancelIdleCallback === 'function' | ||
| ) { | ||
| cancelIdleCallback( idleCallbackHandle ); | ||
| idleCallbackHandle = null; | ||
| } | ||
|
|
||
| try { | ||
| compressedPayload = await compress( JSON.stringify( urlMetric ) ); | ||
| } catch ( err ) { | ||
| const { error } = createLogger( false, consoleLogPrefix ); | ||
| error( | ||
| 'Failed to compress URL Metric falling back to sending uncompressed data:', | ||
| err | ||
| ); | ||
| compressionEnabled = false; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Notifies about the URL Metric request status. | ||
| * | ||
| * @param {Object} status - The status details. | ||
| * @param {boolean} status.success - Indicates if the request succeeded. | ||
| * @param {string} [status.error] - An error message if the request failed. | ||
| * @param {Object} [options] - Options for where to dispatch the message. | ||
| * @param {boolean} [options.toParent=true] - Whether to send the message to the parent window. | ||
| * @param {boolean} [options.toLocal=true] - Whether to dispatch a custom event locally. | ||
| */ | ||
| function notifyStatus( status, options = { toParent: true, toLocal: true } ) { | ||
| const message = { | ||
| type: 'OD_PRIME_URL_METRICS_REQUEST_STATUS', | ||
| success: status.success, | ||
| ...( status.error && { error: status.error } ), | ||
| }; | ||
|
|
||
| // This will be used when URL metrics are primed using a IFRAME. | ||
| if ( options.toParent && window.parent && window.parent !== window ) { | ||
| window.parent.postMessage( message, '*' ); | ||
| } | ||
|
|
||
| // This will be used when URL metrics are primed using Puppeteer script. | ||
| if ( options.toLocal ) { | ||
| document.dispatchEvent( | ||
| new CustomEvent( message.type, { | ||
| detail: { ...status }, | ||
| } ) | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Scrolls to the bottom of the page. | ||
| * | ||
| * @return {Promise<void>} A promise that resolves when the scroll is complete. | ||
| */ | ||
| async function scrollToBottomOfPage() { | ||
| return new Promise( ( resolve ) => { | ||
| const viewportHeight = window.innerHeight; | ||
| const maxScrollAttempts = 20; | ||
| const maxHeightChangeAmount = viewportHeight * 0.5; | ||
| const maxHeightChangeAttempts = 3; | ||
|
|
||
| let scrollAttempts = 0; | ||
| let heightChangeAttempts = 0; | ||
| let lastScrollPosition = 0; | ||
| let lastHeight = document.documentElement.scrollHeight; | ||
|
|
||
| function scroll() { | ||
| const newHeight = document.documentElement.scrollHeight; | ||
| if ( | ||
| window.innerHeight + window.scrollY >= newHeight - 10 || | ||
| scrollAttempts >= maxScrollAttempts || | ||
| heightChangeAttempts >= maxHeightChangeAttempts | ||
| ) { | ||
| resolve(); | ||
| return; | ||
| } | ||
|
|
||
| lastScrollPosition += viewportHeight; | ||
| window.scrollTo( { top: lastScrollPosition, behavior: 'smooth' } ); | ||
|
|
||
| const heightChange = newHeight - lastHeight; | ||
| if ( heightChange >= maxHeightChangeAmount ) { | ||
| lastHeight = newHeight; | ||
| heightChangeAttempts++; | ||
| } | ||
|
|
||
| scrollAttempts++; | ||
| setTimeout( scroll, 300 ); | ||
| } | ||
|
|
||
| scroll(); | ||
| } ); | ||
| } | ||
|
|
||
| /** | ||
| * @typedef {{timestamp: number, creationDate: Date}} UrlMetricDebugData | ||
| * @typedef {{groups: Array<{url_metrics: Array<UrlMetricDebugData>}>}} CollectionDebugData | ||
|
|
@@ -595,6 +717,15 @@ export default async function detect( { | |
| return; | ||
| } | ||
|
|
||
| // Retrieve verification token from the URL hash for priming URL Metrics. | ||
| // Presence of the token indicates that the URL Metric is being primed | ||
| // through the Puppeteer script or WordPress admin dashboard. | ||
| if ( '' !== window.location.hash ) { | ||
| odPrimeUrlMetricsVerificationToken = new URLSearchParams( | ||
| window.location.hash.slice( 1 ) | ||
| ).get( 'odPrimeUrlMetricsVerificationToken' ); | ||
| } | ||
|
|
||
| // Abort if the client already submitted a URL Metric for this URL and viewport group. | ||
| const alreadySubmittedSessionStorageKey = | ||
| await getAlreadySubmittedSessionStorageKey( | ||
|
|
@@ -604,6 +735,7 @@ export default async function detect( { | |
| logger | ||
| ); | ||
| if ( | ||
| ! odPrimeUrlMetricsVerificationToken && | ||
| null !== alreadySubmittedSessionStorageKey && | ||
| alreadySubmittedSessionStorageKey in sessionStorage | ||
| ) { | ||
|
|
@@ -787,8 +919,13 @@ export default async function detect( { | |
| height: win.innerHeight, | ||
| }, | ||
| elements: [], | ||
| source: restApiNonce ? 'user' : 'visitor', | ||
| }; | ||
|
|
||
| if ( odPrimeUrlMetricsVerificationToken ) { | ||
| urlMetric.source = 'synthetic'; | ||
| } | ||
|
||
|
|
||
| const lcpMetric = lcpMetricCandidates.at( -1 ); | ||
|
|
||
| // Populate the elements in the URL Metric. | ||
|
|
@@ -921,19 +1058,25 @@ export default async function detect( { | |
| debounceCompressUrlMetric(); | ||
|
|
||
| // Wait for the page to be hidden. | ||
| await new Promise( ( resolve ) => { | ||
| win.addEventListener( 'pagehide', resolve, { once: true } ); | ||
| win.addEventListener( 'pageswap', resolve, { once: true } ); | ||
| doc.addEventListener( | ||
| 'visibilitychange', | ||
| () => { | ||
| if ( document.visibilityState === 'hidden' ) { | ||
| // TODO: This will fire even when switching tabs. | ||
| resolve(); | ||
| } | ||
| }, | ||
| { once: true } | ||
| ); | ||
| await new Promise( async ( resolve ) => { | ||
| if ( ! odPrimeUrlMetricsVerificationToken ) { | ||
| win.addEventListener( 'pagehide', resolve, { once: true } ); | ||
| win.addEventListener( 'pageswap', resolve, { once: true } ); | ||
| doc.addEventListener( | ||
| 'visibilitychange', | ||
| () => { | ||
| if ( document.visibilityState === 'hidden' ) { | ||
| // TODO: This will fire even when switching tabs. | ||
| resolve(); | ||
| } | ||
| }, | ||
| { once: true } | ||
| ); | ||
| } else { | ||
| await scrollToBottomOfPage(); | ||
| await forceCompressUrlMetric(); | ||
| resolve(); | ||
| } | ||
| } ); | ||
|
|
||
| // Only proceed with submitting the URL Metric if viewport stayed the same size. Changing the viewport size (e.g. due | ||
|
|
@@ -1092,6 +1235,12 @@ export default async function detect( { | |
| ); | ||
| } | ||
| url.searchParams.set( 'hmac', urlMetricHMAC ); | ||
| if ( odPrimeUrlMetricsVerificationToken ) { | ||
| url.searchParams.set( | ||
| 'prime_url_metrics_verification_token', | ||
| odPrimeUrlMetricsVerificationToken | ||
| ); | ||
| } | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Authentication for REST API
In #1835 PR, WP nonces are introduced for REST API requests for logged-in users. This may allow us to eliminate the custom token authentication if URL metrics are collected exclusively from logged-in users. |
||
|
|
||
| const headers = { | ||
| 'Content-Type': 'application/json', | ||
|
|
@@ -1104,7 +1253,22 @@ export default async function detect( { | |
| method: 'POST', | ||
| body: payloadBlob, | ||
| headers, | ||
| keepalive: true, // This makes fetch() behave the same as navigator.sendBeacon(). | ||
| keepalive: odPrimeUrlMetricsVerificationToken ? false : true, // Setting keepalive to true makes fetch() behave the same as navigator.sendBeacon(). | ||
| } ); | ||
| await fetch( request ); | ||
|
|
||
| if ( ! odPrimeUrlMetricsVerificationToken ) { | ||
| await fetch( request ); | ||
| } else { | ||
| try { | ||
| const response = await fetch( request ); | ||
| if ( ! response.ok ) { | ||
| throw new Error( | ||
| `Failed to send URL Metric. Status: ${ response.status }` | ||
| ); | ||
| } | ||
| notifyStatus( { success: true } ); | ||
| } catch ( err ) { | ||
| notifyStatus( { success: false, error: err.message } ); | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should also be
readonly, populated in the endpoint callback here:performance/plugins/optimization-detective/storage/class-od-rest-url-metrics-store-endpoint.php
Lines 214 to 217 in ff8b62b
The value can be
syntheticif theprime_url_metrics_verification_tokenparam is present, or elseuserifis_user_logged_in(). Otherwise, it can bevisitor.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in 5e64b39