Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"test-e2e": "wp-scripts test-playwright --config tools/e2e/playwright.config.ts",
"test-e2e:debug": "wp-scripts test-playwright --config tools/e2e/playwright.config.ts --ui",
"test-e2e:auto-sizes": "wp-scripts test-playwright --config tools/e2e/playwright.config.ts --project=auto-sizes",
"test-e2e:performance-lab": "wp-scripts test-playwright --config tools/e2e/playwright.config.ts --project=performance-lab",
"lint-php": "composer lint:all",
"test-php": "wp-env --config=.wp-env.test.json run wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test:plugins",
"test-php-watch": "./bin/test-php-watch.sh",
Expand Down
133 changes: 133 additions & 0 deletions plugins/performance-lab/includes/devtools/devtools-discovery.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Chrome DevTools third-party tools integration for Performance Lab.
*
* Registers a group of read-only tools with Chrome DevTools for agents, via its experimental
* third-party tools API, so that an AI agent can inspect WordPress server-side performance state
* (Server-Timing metrics, database queries, and environment info) while debugging a page.
*
* Tool names and descriptions are deliberately not internationalized since they are consumed by
* AI agents rather than shown to users.
*
* @since n.e.x.t
*
* @see https://developer.chrome.com/docs/devtools/agents/use-cases/third-party-tools
*/

/**
* Event dispatched by Chrome DevTools for agents to discover third-party tools.
*
* This is a new, experimental, Chrome-only browser API not yet reflected in TypeScript's DOM lib.
*
* @typedef {Event & { respondWith: (toolGroup: Object) => void }} DevtoolsToolDiscoveryEvent
*/

/**
* Database query captured via the SAVEQUERIES constant.
*
* @typedef {Object} DevtoolsDatabaseQuery
* @property {string} sql SQL statement.
* @property {number} timeMs Query duration in milliseconds.
* @property {string} caller Comma-separated call stack of the calling functions.
*/

/**
* Data printed by perflab_devtools_print_data() in PHP.
*
* @typedef {Object} DevtoolsData
* @property {Object<string, *>} environment Environment info.
* @property {null|{ count: number, totalTimeMs: number, queries: DevtoolsDatabaseQuery[] }} dbQueries Database queries, or null if SAVEQUERIES is not enabled.
*/

/**
* Data captured server-side for the current page load, or null if unavailable.
*
* @type {DevtoolsData|null}
*/
let data = null;

const dataScript = document.getElementById( 'perflab-devtools-data' );
if ( dataScript instanceof HTMLScriptElement ) {
try {
data = JSON.parse( dataScript.text );
} catch {
// Tolerate malformed data; the tools will report it as unavailable.
}
}

window.addEventListener( 'devtoolstooldiscovery', ( event ) => {
const discoveryEvent = /** @type {DevtoolsToolDiscoveryEvent} */ ( event );
discoveryEvent.respondWith( {
name: 'WordPress Performance Lab',
description:
'Inspect WordPress server-side performance state for the current page load, including Server-Timing metrics, database queries, and environment info.',
tools: [
{
name: 'get_server_timing_metrics',
description:
'Returns the Server-Timing metrics which WordPress sent in the HTML response for the current page load, as name/duration (milliseconds)/description entries. Metrics are managed by the Performance Lab plugin and configured in WP Admin under Settings > Performance; plugins can register additional metrics via the Performance Lab Server-Timing PHP API.',
inputSchema: { type: 'object', properties: {} },
execute: async () => {
const [ navigationEntry ] =
/** @type {PerformanceNavigationTiming[]} */ (
performance.getEntriesByType( 'navigation' )
);
if ( ! navigationEntry ) {
return [];
}
return navigationEntry.serverTiming.map(
( { name, duration, description } ) => ( {
name,
duration,
description,
} )
);
},
},
{
name: 'get_environment_info',
description:
'Returns information about the WordPress environment which served the current page: WordPress and PHP versions, active theme, whether a persistent object cache is in use, whether the WP_DEBUG and SAVEQUERIES constants are enabled, whether it is a multisite, and the list of active plugins.',
inputSchema: { type: 'object', properties: {} },
execute: async () => ( data ? data.environment : null ),
},
{
name: 'get_database_queries',
description:
'Returns the SQL database queries which WordPress executed while rendering the current page (up until the end of wp_footer), including each query’s SQL, duration in milliseconds, and calling function stack, along with the total query count and cumulative duration. Requires the SAVEQUERIES constant to be defined as true in wp-config.php.',
inputSchema: {
type: 'object',
properties: {
limit: {
type: 'integer',
description:
'Optional maximum number of queries to return. When provided, the slowest queries are returned first.',
},
},
},
execute: async (
/** @type {{ limit?: number }} */ { limit } = {}
) => {
if ( ! data || ! data.dbQueries ) {
return {
available: false,
reason: 'Database queries were not captured. Define the SAVEQUERIES constant as true in wp-config.php to capture them.',
};
}
const { count, totalTimeMs, queries } = data.dbQueries;
let results = queries;
if ( typeof limit === 'number' && limit > 0 ) {
results = [ ...queries ]
.sort( ( a, b ) => b.timeMs - a.timeMs )
.slice( 0, limit );
}
return {
available: true,
count,
totalTimeMs,
queries: results,
};
},
},
],
} );
} );
204 changes: 204 additions & 0 deletions plugins/performance-lab/includes/devtools/helper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
<?php
/**
* Helper functions for the Chrome DevTools third-party tools integration.
*
* @package performance-lab
* @since n.e.x.t
*/

declare( strict_types = 1 );

// @codeCoverageIgnoreStart
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
// @codeCoverageIgnoreEnd

/**
* Gets the capability required to access the Chrome DevTools third-party tools integration.
*
* @since n.e.x.t
*
* @return string Capability.
*/
function perflab_devtools_get_capability(): string {
/**
* Filters the capability required to access the Chrome DevTools third-party tools integration.
*
* The exposed data includes database queries and environment details, so it is restricted
* to administrators by default.
*
* @since n.e.x.t
*
* @param string $capability Capability. Default 'manage_options'.
*/
$capability = apply_filters( 'perflab_devtools_capability', 'manage_options' );
if ( ! is_string( $capability ) || '' === $capability ) {
$capability = 'manage_options';
}
return $capability;
}

/**
* Gets the path to the DevTools discovery script, relative to the plugin directory.
*
* The unminified source is used when SCRIPT_DEBUG is enabled or when the minified copy has not
* been built (e.g. in a development checkout).
*
* @since n.e.x.t
*
* @return string Script path relative to the plugin directory.
*/
function perflab_devtools_get_asset_path(): string {
$src_path = 'includes/devtools/devtools-discovery.js';
$min_path = 'includes/devtools/devtools-discovery.min.js';
if (
( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ||
! file_exists( PERFLAB_PLUGIN_DIR_PATH . $min_path )
) {
return $src_path;
}
return $min_path;
}

/**
* Enqueues the script module which registers the third-party tools with Chrome DevTools.
*
* The module is only served to users with the required capability, since the tools expose
* internal state such as database queries.
*
* @since n.e.x.t
*/
function perflab_devtools_enqueue_script_module(): void {
if ( ! current_user_can( perflab_devtools_get_capability() ) ) {
return;
}
wp_enqueue_script_module(
'perflab-devtools-discovery',
plugins_url( perflab_devtools_get_asset_path(), PERFLAB_MAIN_FILE ),
array(),
PERFLAB_VERSION
);
}

/**
* Prints the data consumed by the DevTools discovery script module as a JSON script tag.
*
* This runs at the end of {@see 'wp_footer'} so that as much of the request as possible is
* captured; database queries executed after this point are not included.
*
* @since n.e.x.t
*/
function perflab_devtools_print_data(): void {
if ( ! current_user_can( perflab_devtools_get_capability() ) ) {
return;
}

$data = array(
'environment' => perflab_devtools_get_environment_info(),
'dbQueries' => perflab_devtools_get_database_queries(),
);

$json_flags = JSON_HEX_TAG | JSON_UNESCAPED_SLASHES;
if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) {
$json_flags |= JSON_PRETTY_PRINT;
}
wp_print_inline_script_tag(
(string) wp_json_encode( $data, $json_flags ),
array(
'type' => 'application/json',
'id' => 'perflab-devtools-data',
)
);
}

/**
* Gets information about the WordPress environment serving the current page.
*
* Only non-sensitive, high-level information is included, and the data is only ever exposed to
* users with the capability returned by {@see perflab_devtools_get_capability()}.
*
* @since n.e.x.t
*
* @return array<string, mixed> Environment info.
*/
function perflab_devtools_get_environment_info(): array {
$theme = wp_get_theme();

return array(
'wpVersion' => get_bloginfo( 'version' ),
'phpVersion' => phpversion(),
'theme' => array(
'name' => $theme->get( 'Name' ),
'stylesheet' => $theme->get_stylesheet(),
'version' => $theme->get( 'Version' ),
),
'usingExternalObjectCache' => (bool) wp_using_ext_object_cache(),
'wpDebug' => defined( 'WP_DEBUG' ) && WP_DEBUG,
'saveQueries' => defined( 'SAVEQUERIES' ) && SAVEQUERIES,
'isMultisite' => is_multisite(),
'activePlugins' => array_values( (array) get_option( 'active_plugins', array() ) ),
);
}

/**
* Gets the database queries executed during the current request.
*
* Requires the SAVEQUERIES constant to be defined as true, since otherwise WordPress does not
* collect queries. Individual SQL strings are truncated to a reasonable length to keep the
* payload size in check.
*
* @since n.e.x.t
*
* @return array{ count: int, totalTimeMs: float, queries: array<int, array{ sql: string, timeMs: float, caller: string }> }|null Queries data, or null if SAVEQUERIES is not enabled.
*/
function perflab_devtools_get_database_queries(): ?array {
if ( ! defined( 'SAVEQUERIES' ) || ! SAVEQUERIES ) {
return null;
}

// If no queries have been run yet, $wpdb->queries will be null, which is valid (0 queries).
$saved_queries = $GLOBALS['wpdb']->queries ?? array();
if ( ! is_array( $saved_queries ) ) {
return null;
}

return perflab_devtools_map_database_queries( $saved_queries );
}

/**
* Maps queries saved by wpdb via the SAVEQUERIES constant to the shape exposed to DevTools.
*
* @since n.e.x.t
*
* @param array<int, mixed> $saved_queries Queries as saved in wpdb::$queries, where each entry is expected to be
* an array containing the SQL, the time taken in seconds, and the caller.
* @return array{ count: int, totalTimeMs: float, queries: array<int, array{ sql: string, timeMs: float, caller: string }> } Queries data.
*/
function perflab_devtools_map_database_queries( array $saved_queries ): array {
$max_sql_length = 2000;
$queries = array();
$total_time_ms = 0.0;
foreach ( $saved_queries as $saved_query ) {
if ( ! is_array( $saved_query ) || ! isset( $saved_query[0], $saved_query[1], $saved_query[2] ) ) {
continue;
}
$sql = trim( (string) $saved_query[0] );
if ( strlen( $sql ) > $max_sql_length ) {
$sql = substr( $sql, 0, $max_sql_length ) . '…';
}
$time_ms = (float) $saved_query[1] * 1000.0;
$total_time_ms += $time_ms;
$queries[] = array(
'sql' => $sql,
'timeMs' => round( $time_ms, 3 ),
'caller' => (string) $saved_query[2],
);
}

return array(
'count' => count( $queries ),
'totalTimeMs' => round( $total_time_ms, 3 ),
'queries' => $queries,
);
}
18 changes: 18 additions & 0 deletions plugins/performance-lab/includes/devtools/hooks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php
/**
* Hook callbacks used for the Chrome DevTools third-party tools integration.
*
* @package performance-lab
* @since n.e.x.t
*/

declare( strict_types = 1 );

// @codeCoverageIgnoreStart
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}

add_action( 'wp_enqueue_scripts', 'perflab_devtools_enqueue_script_module' );
add_action( 'wp_footer', 'perflab_devtools_print_data', PHP_INT_MAX );
// @codeCoverageIgnoreEnd
23 changes: 23 additions & 0 deletions plugins/performance-lab/includes/devtools/load.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php
/**
* Chrome DevTools third-party tools integration.
*
* Exposes read-only WordPress performance and debugging state to AI agents via the experimental
* Chrome DevTools third-party tools API.
*
* @package performance-lab
* @since n.e.x.t
*
* @link https://developer.chrome.com/docs/devtools/agents/use-cases/third-party-tools
*/

declare( strict_types = 1 );

// @codeCoverageIgnoreStart
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
// @codeCoverageIgnoreEnd

require_once __DIR__ . '/helper.php';
require_once __DIR__ . '/hooks.php';
3 changes: 3 additions & 0 deletions plugins/performance-lab/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
// Load site health checks.
require_once PERFLAB_PLUGIN_DIR_PATH . 'includes/site-health/load.php';

// Load Chrome DevTools third-party tools integration.
require_once PERFLAB_PLUGIN_DIR_PATH . 'includes/devtools/load.php';

// Only load admin integration when in admin.
if ( is_admin() ) {
require_once PERFLAB_PLUGIN_DIR_PATH . 'includes/admin/load.php';
Expand Down
Loading
Loading