Skip to content

Add Dev Monitor — 14 collector implementations (parent) #20

Description

@AnuragVasanwala

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:

# Collector What it captures Implements
1 Query_Collector DB queries, slow-query flag, duplicate-query flag Collector, Renderable, Issue_Provider, AI_Context_Provider
2 Hook_Collector Action / filter invocations, attached callbacks, dropped returns Collector, Renderable, AI_Context_Provider
3 HTTP_Collector Outbound HTTP calls (wp_remote_*), request / response timings Collector, Renderable, Issue_Provider, Timeline_Event_Provider
4 Cache_Collector wp_cache_* hits / misses, group breakdowns Collector, Renderable, AI_Context_Provider
5 Transient_Collector Transient hits / misses, expirations Collector, Renderable, AI_Context_Provider
6 Cron_Collector Scheduled cron events, last run, drift Collector, Renderable, Issue_Provider
7 Asset_Collector Enqueued styles / scripts, dependencies, sizes Collector, Renderable, Issue_Provider, AI_Context_Provider
8 Block_Collector Server-side rendered blocks, render times Collector, Renderable, Timeline_Event_Provider
9 REST_Collector REST API requests served, response times, payload sizes Collector, Renderable, AI_Context_Provider
10 Capability_Collector Permission checks (current_user_can), denied results Collector, Renderable, Issue_Provider
11 Mail_Collector wp_mail calls, success / failure Collector, Renderable, Issue_Provider
12 Conditional_Collector is_admin, is_singular, etc., values seen during render Collector, Renderable
13 Rewrite_Collector Matched rewrite rules, query var bindings Collector, Renderable, Issue_Provider
14 Translation_Collector Loaded textdomains, missing translations Collector, Renderable, Issue_Provider

Expected Outcome

14 sub-issues, one per collector, each with the same structure:

  • File: src/Dev/Collectors/<Name>_Collector.php
  • Test: tests/Dev/Collectors/<Name>_CollectorTest.php
  • Implements the interfaces listed above
  • Hooks itself in setup() (registered by Dev_Loader)
  • Returns metadata-only data from ai_context() (Sanitizer is the safety net, but each collector filters at source)
  • PHPCS + PHPStan level 5 clean
  • One unit test per public method

This parent issue is closed when all 14 sub-issues are merged.

Sub-issues to open

  • Query_Collector
  • Hook_Collector
  • HTTP_Collector
  • Cache_Collector
  • Transient_Collector
  • Cron_Collector
  • Asset_Collector
  • Block_Collector
  • REST_Collector
  • Capability_Collector
  • Mail_Collector
  • Conditional_Collector
  • Rewrite_Collector
  • Translation_Collector

Implementation guidance

Self-contained brief. This is a parent issue tracking 14 sub-issues. Each sub-issue follows the same skeleton.

Per-collector skeleton

Every <Name>_Collector follows the same shape:

<?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' ) ) ),
        ];
    }
}

Step-by-step (per sub-issue)

  1. Create src/Dev/Collectors/<Name>_Collector.php using the skeleton above.
  2. Implement setup() to wire the relevant WP hooks.
  3. Implement render() returning escaped HTML for the panel tab.
  4. Implement ai_context() returning counts, timings, pattern names ONLY (run mental check: would I be comfortable seeing this data leave the laptop?).
  5. Register the collector in Dev_Loader from the host plugin.
  6. Tests: at least one per public method, plus one verifying ai_context() returns no string longer than 100 chars.
  7. PHPCS, PHPStan, PHPUnit green.

Hook reference for each collector

Collector Primary hooks
Query query, shutdown (with SAVEQUERIES)
Hook all (use sparingly, sample only when DEV_MODE)
HTTP pre_http_request, http_response
Cache wp_cache_* are functions, not hooks — wrap via runtime stats reading
Transient set_transient, delete_transient, get_transient (no hook for get; use the transient API key namespace)
Cron cron_schedules, read _get_cron_array() on shutdown
Asset wp_print_scripts, wp_print_styles, parse global $wp_scripts / $wp_styles
Block pre_render_block, render_block
REST rest_pre_dispatch, rest_post_dispatch
Capability user_has_cap, map_meta_cap
Mail wp_mail, wp_mail_failed
Conditional Read on template_redirect for frontend, current_screen for admin
Rewrite do_parse_request, read $wp->query_vars
Translation override_load_textdomain, load_textdomain

Complete 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.

<?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 ) );
    }
}

Edge cases (general)

  • Every setup() must check is_enabled() first and return early if false. No hooks register otherwise.
  • Memory: keep $events capped (e.g. last 500 entries) to avoid OOM on long requests.
  • On REST / AJAX requests, do not render — the panel is an HTML output. Skip render in wp_doing_ajax() / defined('REST_REQUEST').

Pre-PR self-check (per sub-issue)

  • Implements at least Collector_Interface, Renderable, and one of (AI_Context_Provider, Issue_Provider, Timeline_Event_Provider)
  • is_enabled() is honoured before any hook registration
  • ai_context() returns metadata only (no SQL, errors, user data)
  • render() output is fully escaped
  • PHPCS, PHPStan, PHPUnit green
  • Sub-issue checkbox above ticked on the parent

Notes

Metadata

Metadata

Assignees

No one assigned

    Labels

    Priority: P1High — sprint commitmentScope: Dev MonitorDev_Monitor panel + collectors (in src/Dev/, dev-mode-only)Type: EpicLarge initiative spanning multiple issues

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions