Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c27d533
Initial plan
Copilot Nov 7, 2025
a7f209a
Add wp profile requests command
Copilot Nov 7, 2025
9c3cf1d
Update test to include requests command in usage output
Copilot Nov 7, 2025
69f6d56
Avoid adding 'all' hook when profiling requests
Copilot Nov 7, 2025
8418ad2
Fix hook parameter signatures to match WordPress
Copilot Nov 7, 2025
49258a1
Lint fixes
swissspidy Nov 7, 2025
9ae6961
Improve test with mu-plugin making HTTP requests
Copilot Nov 10, 2025
721c27a
Add HTTP mocking to test to avoid real requests
Copilot Nov 10, 2025
a31e12b
Update src/Profiler.php
swissspidy Nov 10, 2025
dbbb4f1
Update src/Profiler.php
swissspidy Nov 10, 2025
64c6426
Handle preempted HTTP requests by resetting properties
Copilot Nov 10, 2025
d20638b
Merge branch 'main' into copilot/introduce-wp-profile-requests
swissspidy Jan 16, 2026
2f5632b
Lint fix
swissspidy Jan 16, 2026
b601c03
Fix http_api_debug hook registration to pass all 5 parameters
Copilot Jan 16, 2026
9b66b2c
Handle preempted/mocked HTTP requests in wp_request_begin
Copilot Jan 16, 2026
5f2f176
Fix PHP warning by checking if value is numeric before summing
Copilot Jan 17, 2026
fe1eeed
Undo change again
swissspidy Jan 17, 2026
6275bc2
Merge branch 'main' into copilot/introduce-wp-profile-requests
swissspidy Feb 9, 2026
2a709ee
Merge branch 'main' into copilot/introduce-wp-profile-requests
swissspidy Mar 22, 2026
f8afe79
Merge branch 'main' into copilot/introduce-wp-profile-requests
swissspidy Apr 14, 2026
48b010f
Merge branch 'main' into copilot/introduce-wp-profile-requests
swissspidy Apr 14, 2026
07dbf41
fix test
swissspidy Apr 14, 2026
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 composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"profile hook",
"profile eval",
"profile eval-file",
"profile requests",
"profile queries"
],
"readme": {
Expand Down
51 changes: 51 additions & 0 deletions features/profile-requests.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
Feature: Profile HTTP requests

Scenario: Profile HTTP requests during WordPress load
Given a WP install
And that HTTP requests to https://www.apple.com/ will respond with:
"""
HTTP/1.1 200
Content-Type: text/plain

Hello world
"""
And that HTTP requests to https://www.example.com/ will respond with:
"""
HTTP/1.1 201
Content-Type: application/json

{"status":"created"}
"""
And a wp-content/mu-plugins/http-requests.php file:
"""
<?php
add_action( 'muplugins_loaded', function() {
wp_remote_get( 'https://www.apple.com/' );
wp_remote_post( 'https://www.example.com/', array( 'body' => 'test' ) );
});
"""

When I run `wp profile requests --fields=method,url`
Then STDOUT should be a table containing rows:
| method | url |
| GET | https://www.apple.com/ |
| POST | https://www.example.com/ |
And STDOUT should contain:
"""
total (2)
"""

Scenario: Profile shows no requests when none are made
Given a WP install
And a wp-content/mu-plugins/no-requests.php file:
"""
<?php
// Don't make any HTTP requests
add_filter( 'pre_http_request', '__return_false', 1 );
"""

When I run `wp profile requests --fields=method,url`
Then STDOUT should contain:
"""
total (0)
"""
1 change: 1 addition & 0 deletions features/profile.feature
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Feature: Basic profile usage
or: wp profile eval-file <file> [--hook[=<hook>]] [--fields=<fields>] [--format=<format>] [--order=<order>] [--orderby=<fields>]
or: wp profile hook [<hook>] [--all] [--spotlight] [--url=<url>] [--fields=<fields>] [--format=<format>] [--order=<order>] [--orderby=<fields>] [--search=<pattern>]
or: wp profile queries [--url=<url>] [--hook=<hook>] [--callback=<callback>] [--time_threshold=<seconds>] [--fields=<fields>] [--format=<format>] [--order=<order>] [--orderby=<fields>]
or: wp profile requests [--url=<url>] [--fields=<fields>] [--format=<format>] [--order=<order>] [--orderby=<fields>]
or: wp profile stage [<stage>] [--all] [--spotlight] [--url=<url>] [--fields=<fields>] [--format=<format>] [--order=<order>] [--orderby=<fields>]

See 'wp help profile <command>' for more information on a specific command.
Expand Down
76 changes: 76 additions & 0 deletions src/Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,82 @@ public function hook( $args, $assoc_args ) {
$formatter->display_items( $loggers, true, $order, $orderby );
}

/**
* Profile HTTP requests made during the WordPress load process.
*
* Monitors all HTTP requests made during the WordPress load process,
* displaying information about each request including URL, method,
* execution time, and response code.
*
* ## OPTIONS
*
* [--url=<url>]
* : Execute a request against a specified URL. Defaults to the home URL.
*
* [--fields=<fields>]
* : Limit the output to specific fields. Default is all fields.
*
* [--format=<format>]
* : Render output in a particular format.
* ---
* default: table
* options:
* - table
* - json
* - yaml
* - csv
* ---
*
* [--order=<order>]
* : Ascending or Descending order.
* ---
* default: ASC
* options:
* - ASC
* - DESC
* ---
*
* [--orderby=<fields>]
* : Set orderby which field.
*
* ## EXAMPLES
*
* # List all HTTP requests during page load
* $ wp profile requests
* +-----------+----------------------------+----------+---------+
* | method | url | status | time |
* +-----------+----------------------------+----------+---------+
* | GET | https://api.example.com | 200 | 0.2341s |
* | POST | https://api.example.com | 201 | 0.1653s |
* +-----------+----------------------------+----------+---------+
* | total (2) | | | 0.3994s |
* +-----------+----------------------------+----------+---------+
* @skipglobalargcheck
* @when before_wp_load
*
* @param array<string> $args Positional arguments. Unused.
* @param array{url?: string, fields?: string, format: string, order: string, orderby?: string} $assoc_args Associative arguments.
* @return void
*/
public function requests( $args, $assoc_args ) {
$order = Utils\get_flag_value( $assoc_args, 'order', 'ASC' );
$orderby = Utils\get_flag_value( $assoc_args, 'orderby', null );

$profiler = new Profiler( 'request', false );
$profiler->run();

$fields = array(
'method',
'url',
'status',
'time',
);
$formatter = new Formatter( $assoc_args, $fields );
$loggers = $profiler->get_loggers();

$formatter->display_items( $loggers, true, $order, $orderby );
}

/**
* Profile arbitrary code execution.
*
Expand Down
94 changes: 86 additions & 8 deletions src/Profiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ class Profiler {
/** @var int|null */
private $tick_cache_miss_offset = null;

/** @var float|null */
private $request_start_time = null;
/** @var array<string, mixed>|null */
private $request_args = null;
/** @var bool */
private $is_admin_request = false;

Expand Down Expand Up @@ -175,10 +179,10 @@ function () {
) {
$start_hook = substr( $this->focus, 0, -6 );
WP_CLI::add_wp_hook( $start_hook, array( $this, 'wp_tick_profile_begin' ), 9999 );
} else {
} elseif ( 'request' !== $this->type ) {
WP_CLI::add_wp_hook( 'all', array( $this, 'wp_hook_begin' ) );
}
WP_CLI::add_wp_hook( 'pre_http_request', array( $this, 'wp_request_begin' ) );
WP_CLI::add_wp_hook( 'pre_http_request', array( $this, 'wp_request_begin' ), 10, 3 );
WP_CLI::add_wp_hook( 'http_api_debug', array( $this, 'wp_request_end' ) );
$this->load_wordpress_with_template();
}
Expand Down Expand Up @@ -490,27 +494,101 @@ public function handle_function_tick() {
/**
* Profiling request time for any active Loggers
*
* @param mixed $filter_value
* @param mixed $preempt Whether to preempt the request.
* @param array<string, mixed>|null $parsed_args Request arguments.
* @param string|null $url Request URL.
* @return mixed
*/
public function wp_request_begin( $filter_value = null ) {
public function wp_request_begin( $preempt = null, $parsed_args = null, $url = null ) {
foreach ( Logger::$active_loggers as $logger ) {
$logger->start_request_timer();
}
return $filter_value;

// For request profiling, capture details of each HTTP request
if ( 'request' === $this->type ) {
// Reset properties first to handle cases where previous request was preempted
$this->request_start_time = null;
$this->request_args = null;

// Now capture the new request details
$this->request_start_time = microtime( true );
$this->request_args = array(
'url' => $url,
'method' => ( is_array( $parsed_args ) && isset( $parsed_args['method'] ) ) ? $parsed_args['method'] : 'GET',
);

// If request is preempted (mocked), log it now since http_api_debug won't fire
if ( false !== $preempt && ! is_null( $preempt ) ) {
$request_time = 0; // Preempted requests happen instantly
$status = '';

// Extract status code from preempted response
if ( is_wp_error( $preempt ) ) {
$status = 'Error';
} elseif ( is_array( $preempt ) && isset( $preempt['response'] ) && is_array( $preempt['response'] ) && isset( $preempt['response']['code'] ) ) {
$status = $preempt['response']['code'];
}

$logger = new Logger(
array(
'method' => $this->request_args['method'],
'url' => $this->request_args['url'],
'status' => $status,
)
);
$logger->time = $request_time;

$this->loggers[] = $logger;

// Reset for next request
$this->request_start_time = null;
$this->request_args = null;
}
}

return $preempt;
}

/**
* Profiling request time for any active Loggers
*
* @param mixed $filter_value
* @param array<string, mixed>|\WP_Error|null $response Response array or WP_Error.
* @return mixed
*/
public function wp_request_end( $filter_value = null ) {
public function wp_request_end( $response = null ) {
foreach ( Logger::$active_loggers as $logger ) {
$logger->stop_request_timer();
}
return $filter_value;

// For request profiling, log individual request
if ( 'request' === $this->type && ! is_null( $this->request_start_time ) && is_array( $this->request_args ) ) {
$request_time = microtime( true ) - $this->request_start_time;
$status = '';

// Extract status code from response
if ( is_wp_error( $response ) ) {
$status = 'Error';
} elseif ( is_array( $response ) && isset( $response['response'] ) && is_array( $response['response'] ) && isset( $response['response']['code'] ) ) {
$status = $response['response']['code'];
}

$logger = new Logger(
array(
'method' => isset( $this->request_args['method'] ) ? $this->request_args['method'] : 'GET',
'url' => isset( $this->request_args['url'] ) ? $this->request_args['url'] : '',
'status' => $status,
)
);
$logger->time = $request_time;

$this->loggers[] = $logger;

// Reset for next request
$this->request_start_time = null;
$this->request_args = null;
}

return $response;
}

/**
Expand Down