Inspector

The Canvas Inspector is a debugging tool that provides insights into your application's performance, database queries, and request processing. After your controller executes and returns a Response, the Inspector injects a debug toolbar into HTML responses.

Enabling the Inspector

Configure the Inspector in config/inspector.php:

<?php
// config/inspector.php
return [
    'enabled' => true  // Set to true to enable debug toolbar injection
];

Production Warning: Always disable the Inspector in production. It exposes sensitive information including database queries, request parameters, file paths, execution times, and memory usage. Set enabled to false in your production configuration files.

Screenshot

How the Inspector Works

Request Lifecycle Integration

The Inspector integrates into Canvas's request handling through these steps:

  1. Initialization: When Kernel::handle() starts processing a request, it creates an EventCollector instance if config/inspector.php has 'enabled' => true
  2. Event Collection: Throughout request processing, components emit debug signals (e.g., debug.objectquel.query, debug.canvas.query). The EventCollector automatically captures all signals starting with debug.
  3. Controller Execution: Your application processes normally - routing, controller methods, database queries, etc.
  4. Performance Signal: After your controller returns, Kernel emits a debug.canvas.query signal containing route info, execution time, and memory usage
  5. Injection: The Inspector::inject() method checks if the Response is HTML and injects the debug toolbar before the closing </body> tag
  6. Response Sent: The modified Response (with debug toolbar) is sent to the browser

HTML Detection and Injection

The Inspector only modifies HTML responses:

  • HTML responses (Content-Type: text/html): Debug toolbar is injected before </body>, or before </html> if no body tag exists
  • JSON/XML/other responses: Passed through unmodified - no debugging overhead for API endpoints
  • Malformed HTML: If HTML lacks proper structure, the Inspector wraps content in complete HTML document with the debug toolbar

Signal-Based Event System

The Inspector uses Canvas's SignalHub system to collect debugging data without tight coupling between components. SignalHub is based on Qt's signals and slots pattern, providing type-safe event handling.

How Signals Work

  1. Signal Definition: Components create named signals like debug.cache.hit using Signal class
  2. Registration: Signals are registered with the SignalHub, a centralized registry that uses WeakMap for automatic memory management
  3. Connection: The EventCollector automatically connects to all signals matching the pattern debug.*
  4. Emission: When an operation occurs (e.g., database query), the component emits its signal with data payload
  5. Collection: EventCollector's connected callback stores the event with signal name, data, and high-precision timestamp
  6. Panel Access: Inspector panels retrieve relevant events by filtering on signal patterns

Emitting Debug Signals

To add debugging for your own operations, emit signals following the debug.* naming convention:

<?php
use Quellabs\SignalHub\HasSignals;
use Quellabs\SignalHub\SignalHubLocator;

class CacheService {
    use HasSignals;

    public function __construct() {
        // Connect to the application's SignalHub
        $this->setSignalHub(SignalHubLocator::getInstance());

        // Create a signal for cache hits/misses
        // Signal name must start with 'debug.' to be collected by Inspector
        $this->createSignal(['array'], 'debug.cache.operation');
    }

    public function get(string $key): mixed {
        $start = microtime(true);
        $hit = /* ... check cache ... */;
        $duration = microtime(true) - $start;

        // Emit the signal with event data
        $this->getSignal('debug.cache.operation')->emit([
            'key'               => $key,
            'hit'               => $hit,
            'execution_time_ms' => $duration * 1000
        ]);

        return /* ... cached value ... */;
    }
}

Event Structure

Each collected event contains:

  • signal (string): The signal name (e.g., 'debug.cache.operation')
  • data (array): The payload emitted with the signal (e.g., ['key' => 'user:123', 'hit' => true])
  • timestamp (float): High-precision timestamp from microtime(true)
// Example event structure from EventCollector::getEvents()
[
    [
        'signal' => 'debug.cache.operation',
        'data' => [
            'key' => 'user:123',
            'hit' => true,
            'execution_time_ms' => 0.42
        ],
        'timestamp' => 1735000123.4567
    ],
    // ... more events
]

Built-in Debug Panels

Request Panel

Displays HTTP request information captured from the debug.canvas.query signal:

  • Route Information: Controller class name, method name, route pattern (e.g., /users/{id}), route parameters
  • Request Details: HTTP method (GET/POST/PUT/PATCH/DELETE), full URI, client IP address, user agent string
  • POST Data: All form fields submitted via POST/PUT/PATCH
  • File Uploads: Uploaded file names, sizes, MIME types, temporary paths
  • Cookies: All cookies sent with the request (names and values)
  • Legacy Indicator: Whether the request was handled by a legacy PHP file or Canvas controller

Query Panel

Displays database operations (requires database layer to emit debug.objectquel.query or similar signals):

  • SQL Statements: Complete SQL queries (ObjectQuel generated or raw SQL)
  • Execution Time: Time in milliseconds for each individual query
  • Bound Parameters: Values bound to prepared statement placeholders
  • Query Count: Total number of database queries executed during the request
  • Total Time: Cumulative time spent on all database operations

Creating Custom Inspector Panels

Extend the Inspector with custom panels by implementing InspectorPanelInterface:

<?php
namespace App\Inspector\Panels;

use Quellabs\Canvas\Inspector\EventCollector;
use Quellabs\Contracts\Inspector\InspectorPanelInterface;
use Symfony\Component\HttpFoundation\Request;

class CachePanel implements InspectorPanelInterface {

    private array $cacheEvents = [];
    private EventCollector $collector;

    public function __construct(EventCollector $collector) {
        $this->collector = $collector;
    }

    /**
     * Return signal patterns this panel wants to receive
     * Supports wildcards: 'debug.cache.*' matches all cache signals
     */
    public function getSignalPatterns(): array {
        return ['debug.cache.*'];
    }

    /**
     * Called by Inspector to process collected events
     * Retrieve events matching your patterns from the collector
     */
    public function processEvents(): void {
        $this->cacheEvents = $this->collector->getEventsBySignals($this->getSignalPatterns());
    }

    /**
     * Return unique panel identifier (used in DOM IDs)
     */
    public function getName(): string {
        return 'cache';
    }

    /**
     * Return tab label text (can include dynamic counts)
     */
    public function getTabLabel(): string {
        $hits = count(array_filter($this->cacheEvents, fn($e) => $e['data']['hit'] ?? false));
        $total = count($this->cacheEvents);
        return "Cache ({$hits}/{$total})";
    }

    /**
     * Return emoji or icon for the tab (optional)
     */
    public function getIcon(): string {
        return '🗂️';
    }

    /**
     * Return data to pass to the JavaScript template
     * This data becomes available in your JS template as the 'data' variable
     */
    public function getData(Request $request): array {
        return [
            'events' => $this->cacheEvents,
            'hit_ratio' => $this->calculateHitRatio(),
            'total_time_ms' => $this->calculateTotalTime()
        ];
    }

    /**
     * Return statistics for the header bar (optional)
     * These appear in the collapsed debug bar
     */
    public function getStats(): array {
        return [
            'cache_hits' => $this->countHits(),
            'cache_misses' => $this->countMisses()
        ];
    }

    /**
     * Return JavaScript template that renders the panel content
     * Receives data from getData() method
     * Available helper functions: escapeHtml(), formatTimeBadge()
     */
    public function getJsTemplate(): string {
        return <<<'JS'
const events = data.events.map(event => `
    
${event.data.hit ? '✅ HIT' : '❌ MISS'} ${formatTimeBadge(event.data.execution_time_ms || 0)}
Key: ${escapeHtml(event.data.key)}
`).join(''); return `

Cache Operations (Hit Ratio: ${data.hit_ratio}%)

${events || '

No cache operations recorded

'}

Total Time: ${data.total_time_ms.toFixed(2)}ms

`; JS; } /** * Return custom CSS for this panel (optional) */ public function getCss(): string { return <<<'CSS' .canvas-debug-item .cache-hit { color: #28a745; } .canvas-debug-item .cache-miss { color: #dc3545; } CSS; } // Helper methods private function calculateHitRatio(): float { $total = count($this->cacheEvents); if ($total === 0) return 0.0; $hits = count(array_filter($this->cacheEvents, fn($e) => $e['data']['hit'] ?? false)); return round(($hits / $total) * 100, 1); } private function calculateTotalTime(): float { return array_reduce($this->cacheEvents, fn($sum, $e) => $sum + ($e['data']['execution_time_ms'] ?? 0), 0.0 ); } private function countHits(): int { return count(array_filter($this->cacheEvents, fn($e) => $e['data']['hit'] ?? false)); } private function countMisses(): int { return count($this->cacheEvents) - $this->countHits(); } }

Registering Custom Panels

Add your custom panel to the Inspector configuration:

<?php
// config/inspector.php
return [
    'enabled' => true,
    'panels' => [
        'cache' => \App\Inspector\Panels\CachePanel::class,
        'email' => \App\Inspector\Panels\EmailPanel::class,
        'api'   => \App\Inspector\Panels\ApiPanel::class
    ]
];

Panel Lifecycle

Understanding when panel methods are called:

  1. Construction: Panel is instantiated with EventCollector injected via constructor
  2. Event Collection: During request processing, EventCollector captures signals matching debug.*
  3. Process Events: After response is created, processEvents() is called to let panels filter their events
  4. Get Data: getData() is called to prepare data for JavaScript template
  5. Render: JavaScript template receives data and renders HTML
  6. Inject: Rendered HTML is injected into the response before </body>

JavaScript Template Helpers

Available functions in getJsTemplate():

escapeHtml(str)

Escapes HTML special characters to prevent XSS:

escapeHtml('<script>alert("xss")</script>')
// Returns: '<script>alert("xss")</script>'

formatTimeBadge(milliseconds)

Formats execution time as a styled badge:

formatTimeBadge(1.234)   // Fast: green badge
formatTimeBadge(25.5)    // Medium: yellow badge
formatTimeBadge(150.0)   // Slow: red badge

Inspector Interface

The debug toolbar appears at the bottom of your browser window with these features:

Collapsed State

  • Header Statistics: Execution time, query count, total query time, memory usage
  • Panel Tabs: Quick access to each panel (shows counts/icons)
  • Minimize/Expand Toggle: Click to expand for detailed view

Expanded State

  • Tab Navigation: Switch between panels
  • Panel Content: Full debugging information rendered by each panel
  • Persistent State: Stays expanded across page loads (browser localStorage)
  • Responsive Layout: Adjusts for desktop and mobile screens

Signal Naming Conventions

Follow these patterns for consistency:

// Component naming: debug.{component}.{action}
'debug.cache.hit'           // Cache hit occurred
'debug.cache.miss'          // Cache miss occurred
'debug.cache.write'         // Cache write operation

// Wildcards in panels match multiple signals
'debug.cache.*'             // Matches all cache signals
'debug.email.*'             // Matches all email signals
'debug.api.request.*'       // Matches all API request signals

Performance Considerations

Overhead When Enabled

  • EventCollector stores all debug.* signals in memory during the request
  • Panels process events and generate HTML after controller executes
  • Response content is parsed to find </body> tag for injection
  • Typical overhead: 5-20ms and 1-5MB memory depending on event count

Overhead When Disabled

  • Zero overhead - EventCollector is not instantiated
  • Signal emissions still occur but have no connected receivers
  • No memory used for event collection
  • No response parsing or HTML injection

Best Practices

Signal Design

  • Start with 'debug.' - Only signals with this prefix are collected
  • Use namespacing: debug.module.action (e.g., debug.email.sent)
  • Include timing: Add execution_time_ms for performance tracking
  • Keep data small: Don't emit large payloads (full response bodies, images, etc.)

Panel Development

  • Use specific patterns: Match only relevant signals, not debug.* which matches everything
  • Handle missing data: Use null coalescing (??) when accessing event data
  • Escape output: Always use escapeHtml() in JavaScript templates
  • Show empty states: Display helpful message when no events collected

Production Safety

  • Never enable in production: Use environment-based config
  • Don't emit sensitive data: Passwords, API keys, session tokens should not be in debug signals
  • Test panel performance: Ensure panels don't slow down debugging with expensive operations