This is the parent issue tracking the 14 collector classes that implement the interfaces shipped in #17. Each collector is independent of the others and is parallelisable across engineers.
This parent issue is closed when all 14 sub-issues are merged.
<?php
declare(strict_types=1);
namespace RtCamp\WPToolkit\Dev\Collectors;
use RtCamp\WPToolkit\Dev\Interfaces\Collector_Interface;
use RtCamp\WPToolkit\Dev\Interfaces\Renderable;
use RtCamp\WPToolkit\Dev\Interfaces\AI_Context_Provider;
use RtCamp\WPToolkit\Traits\Singleton;
class Query_Collector implements Collector_Interface, Renderable, AI_Context_Provider {
use Singleton;
private array $events = [];
public function id(): string { return 'queries'; }
public function label(): string { return __( 'Queries', 'wp-toolkit' ); }
public function is_enabled(): bool {
return Feature_Selector::get_instance()->is_feature_enabled( 'dev-monitor-queries' );
}
public function setup(): void {
if ( ! $this->is_enabled() ) {
return;
}
$this->add_filter( 'query', [ $this, 'capture_query' ] );
}
public function capture_query( string $sql ): string {
// Capture METADATA only: count, timing, table touched. NO raw SQL.
$this->events[] = [
'table' => $this->extract_table( $sql ),
'duration' => 0, // populated on shutdown via SAVEQUERIES
'is_select' => 0 === stripos( ltrim( $sql ), 'SELECT' ),
];
return $sql;
}
public function render(): string { /* HTML */ }
public function ai_context(): array {
return [
'count' => count( $this->events ),
'select_count' => count( array_filter( $this->events, fn($e) => $e['is_select'] ) ),
'unique_tables' => count( array_unique( array_column( $this->events, 'table' ) ) ),
];
}
}
This is the full implementation of one of the 14 collectors. The other 13 follow the same shape with different hooks.
<?php
declare(strict_types=1);
namespace RtCamp\WPToolkit\Dev\Collectors;
use RtCamp\WPToolkit\Dev\Interfaces\Collector_Interface;
use RtCamp\WPToolkit\Dev\Interfaces\Renderable;
use RtCamp\WPToolkit\Dev\Interfaces\Issue_Provider;
use RtCamp\WPToolkit\Dev\Interfaces\Timeline_Event_Provider;
use RtCamp\WPToolkit\Dev\Interfaces\AI_Context_Provider;
use RtCamp\WPToolkit\Traits\Singleton;
use RtCamp\WPToolkit\Utilities\Feature_Selector;
class HTTP_Collector implements
Collector_Interface,
Renderable,
Issue_Provider,
Timeline_Event_Provider,
AI_Context_Provider {
use Singleton;
private const MAX_EVENTS = 500;
private const SLOW_THRESHOLD_MS = 1000;
/** @var array<int, array{ts_ms: int, url_host: string, method: string, status: int, duration_ms: float, body_size: int}> */
private array $events = [];
/** @var array<int, float> Map of request id (spl_object_id of args) → start microtime */
private array $start_times = [];
public function id(): string { return 'http'; }
public function label(): string { return __( 'HTTP', 'wp-toolkit' ); }
public function is_enabled(): bool {
return Feature_Selector::get_instance()->is_feature_enabled( 'dev-monitor-http' );
}
public function setup(): void {
if ( ! $this->is_enabled() ) {
return;
}
add_filter( 'pre_http_request', [ $this, 'capture_start' ], 10, 3 );
add_action( 'http_api_debug', [ $this, 'capture_end' ], 10, 5 );
}
/**
* @param mixed $preempt Filtered response or false.
* @param array<string, mixed> $args
* @param string $url
* @return mixed
*/
public function capture_start( mixed $preempt, array $args, string $url ): mixed {
$this->start_times[ self::request_key( $args, $url ) ] = microtime( true );
return $preempt;
}
/**
* @param mixed $response WP_HTTP_Requests_Response | array | WP_Error
* @param string $type
* @param string $class
* @param array<string, mixed> $args
* @param string $url
*/
public function capture_end( mixed $response, string $type, string $class, array $args, string $url ): void {
$key = self::request_key( $args, $url );
$start = $this->start_times[ $key ] ?? microtime( true );
unset( $this->start_times[ $key ] );
$duration_ms = ( microtime( true ) - $start ) * 1000;
if ( count( $this->events ) >= self::MAX_EVENTS ) {
array_shift( $this->events );
}
// METADATA only — host, method, status, timing. NEVER the URL path / query / body.
$this->events[] = [
'ts_ms' => (int) ( microtime( true ) * 1000 ),
'url_host' => (string) wp_parse_url( $url, PHP_URL_HOST ),
'method' => (string) ( $args['method'] ?? 'GET' ),
'status' => is_array( $response ) ? (int) wp_remote_retrieve_response_code( $response ) : 0,
'duration_ms' => round( $duration_ms, 1 ),
'body_size' => is_array( $response ) ? strlen( (string) wp_remote_retrieve_body( $response ) ) : 0,
];
}
public function render(): string {
ob_start();
?>
<table class="dev-monitor-http">
<thead><tr>
<th><?php esc_html_e( 'Host', 'wp-toolkit' ); ?></th>
<th><?php esc_html_e( 'Method', 'wp-toolkit' ); ?></th>
<th><?php esc_html_e( 'Status', 'wp-toolkit' ); ?></th>
<th><?php esc_html_e( 'Duration (ms)', 'wp-toolkit' ); ?></th>
<th><?php esc_html_e( 'Body size', 'wp-toolkit' ); ?></th>
</tr></thead>
<tbody>
<?php foreach ( $this->events as $e ) : ?>
<tr <?php echo $e['duration_ms'] >= self::SLOW_THRESHOLD_MS ? 'class="slow"' : ''; ?>>
<td><?php echo esc_html( $e['url_host'] ); ?></td>
<td><?php echo esc_html( $e['method'] ); ?></td>
<td><?php echo esc_html( (string) $e['status'] ); ?></td>
<td><?php echo esc_html( (string) $e['duration_ms'] ); ?></td>
<td><?php echo esc_html( (string) $e['body_size'] ); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php
return (string) ob_get_clean();
}
/** @return array<int, array{severity: string, message: string, context: array<string, mixed>}> */
public function issues(): array {
$out = [];
foreach ( $this->events as $e ) {
if ( $e['duration_ms'] >= self::SLOW_THRESHOLD_MS ) {
$out[] = [
'severity' => 'warning',
'message' => sprintf( 'Slow HTTP call to %s (%dms)', $e['url_host'], (int) $e['duration_ms'] ),
'context' => [ 'host' => $e['url_host'], 'method' => $e['method'], 'duration_ms' => $e['duration_ms'] ],
];
}
if ( $e['status'] >= 500 ) {
$out[] = [
'severity' => 'error',
'message' => sprintf( 'HTTP %d from %s', $e['status'], $e['url_host'] ),
'context' => [ 'host' => $e['url_host'], 'status' => $e['status'] ],
];
}
}
return $out;
}
/** @return array<int, array{ts_ms: int, label: string, phase: string, meta: array<string, mixed>}> */
public function timeline_events(): array {
return array_map(
static fn ( $e ) => [
'ts_ms' => $e['ts_ms'] - (int) $e['duration_ms'],
'label' => $e['method'] . ' ' . $e['url_host'],
'phase' => 'http',
'meta' => [ 'duration_ms' => $e['duration_ms'], 'status' => $e['status'] ],
],
$this->events,
);
}
/** @return array<string, mixed> METADATA ONLY — counts, timings, host names. */
public function ai_context(): array {
$hosts = array_count_values( array_column( $this->events, 'url_host' ) );
$slow = array_filter( $this->events, fn ( $e ) => $e['duration_ms'] >= self::SLOW_THRESHOLD_MS );
$errors = array_filter( $this->events, fn ( $e ) => $e['status'] >= 500 );
return [
'count' => count( $this->events ),
'unique_hosts' => count( $hosts ),
'slow_count' => count( $slow ),
'error_count' => count( $errors ),
'total_time_ms' => (int) array_sum( array_column( $this->events, 'duration_ms' ) ),
];
}
/** @param array<string, mixed> $args */
private static function request_key( array $args, string $url ): int {
return crc32( $url . wp_json_encode( $args ) );
}
}
Context
This is the parent issue tracking the 14 collector classes that implement the interfaces shipped in #17. Each collector is independent of the others and is parallelisable across engineers.
The 14 collectors:
Query_CollectorHook_CollectorHTTP_Collectorwp_remote_*), request / response timingsCache_Collectorwp_cache_*hits / misses, group breakdownsTransient_CollectorCron_CollectorAsset_CollectorBlock_CollectorREST_CollectorCapability_Collectorcurrent_user_can), denied resultsMail_Collectorwp_mailcalls, success / failureConditional_Collectoris_admin,is_singular, etc., values seen during renderRewrite_CollectorTranslation_CollectorExpected Outcome
14 sub-issues, one per collector, each with the same structure:
src/Dev/Collectors/<Name>_Collector.phptests/Dev/Collectors/<Name>_CollectorTest.phpsetup()(registered byDev_Loader)ai_context()(Sanitizer is the safety net, but each collector filters at source)This parent issue is closed when all 14 sub-issues are merged.
Sub-issues to open
Implementation guidance
Per-collector skeleton
Every
<Name>_Collectorfollows the same shape:Step-by-step (per sub-issue)
src/Dev/Collectors/<Name>_Collector.phpusing the skeleton above.setup()to wire the relevant WP hooks.render()returning escaped HTML for the panel tab.ai_context()returning counts, timings, pattern names ONLY (run mental check: would I be comfortable seeing this data leave the laptop?).Dev_Loaderfrom the host plugin.ai_context()returns no string longer than 100 chars.Hook reference for each collector
query,shutdown(with SAVEQUERIES)all(use sparingly, sample only when DEV_MODE)pre_http_request,http_responsewp_cache_*are functions, not hooks — wrap via runtime stats readingset_transient,delete_transient,get_transient(no hook for get; use the transient API key namespace)cron_schedules, read_get_cron_array()on shutdownwp_print_scripts,wp_print_styles, parse global$wp_scripts/$wp_stylespre_render_block,render_blockrest_pre_dispatch,rest_post_dispatchuser_has_cap,map_meta_capwp_mail,wp_mail_failedtemplate_redirectfor frontend,current_screenfor admindo_parse_request, read$wp->query_varsoverride_load_textdomain,load_textdomainComplete sample: HTTP_Collector end-to-end
This is the full implementation of one of the 14 collectors. The other 13 follow the same shape with different hooks.
Edge cases (general)
setup()must checkis_enabled()first and return early if false. No hooks register otherwise.$eventscapped (e.g. last 500 entries) to avoid OOM on long requests.wp_doing_ajax()/defined('REST_REQUEST').Pre-PR self-check (per sub-issue)
Collector_Interface,Renderable, and one of (AI_Context_Provider,Issue_Provider,Timeline_Event_Provider)is_enabled()is honoured before any hook registrationai_context()returns metadata only (no SQL, errors, user data)render()output is fully escapedNotes
release/v1.0.0. Branch:v1.0.0/task/dev-monitor-collectors-parent. Commit subject:feat(dev): track Dev Monitor collector implementations.