Skip to content

Debugging

Precious Lyson edited this page Nov 13, 2026 · 2 revisions

The Machinjiri Debugging System provides two complementary classes:

Class Purpose
Debugger Framework-aware facade for logging, timing, memory tracking, backtraces, and events.
Dumper Standalone, beautifully formatted variable inspection for CLI and browser.

Key Features

  • ✅ Environment-aware — auto-disables in production
  • ✅ Structured logging — integrates with Logger and EventListener
  • ✅ Timing utilities — start/stop timers, measure callables
  • ✅ Memory profiling — snapshots, diffs, and formatting
  • ✅ Rich variable dumps — HTML with dark mode + CLI formatting
  • ✅ Security-first — automatic redaction of passwords, tokens, API keys
  • ✅ Circular reference protection — safe to dump complex object graphs
  • ✅ Zero-exit testing — dd() can be intercepted in tests
  • ✅ Event hooks — listen to every debug action

Quick Start

use Mlangeni\Machinjiri\Core\Debug\Debugger;
use Mlangeni\Machinjiri\Core\Debug\Dumper;

// Simple logging
Debugger::getInstance()->info('Application booted');

// Dump a variable
Dumper::dump($user);

// Time an operation
$debug = Debugger::getInstance();
$debug->startTimer('db_query');
$results = $db->query('SELECT * FROM users');
$elapsed = $debug->stopTimer('db_query');

// Dump and die
Dumper::dd($results);

Core Components

Debugger

A singleton-style facade that wraps framework services and provides high-level debugging utilities.

Responsibilities

  • Environment detection (auto-enable in dev, disable in prod)
  • Message logging with caller context
  • Timer and memory profiling
  • Query log collection
  • Event broadcasting (debug.log, debug.dump, debug.timer.start, etc.)
  • Convenience forwarding to logger levels (->info(), ->error(), …)

Lifecycle

$debug = new Debugger($container);   // explicit
$debug = Debugger::getInstance();    // singleton (uses Container if present)

On construction, Debugger:

  1. Resolves the current Container (or accepts one).
  2. Auto-detects development mode via $app->isDevelopment().
  3. Tries to resolve a Logger and EventListener from the container.
  4. Falls back to LoggerFactory::system("debugger", "debug", false).

Dumper

A stateless helper (all methods are static) that renders variables in human-readable form.

Features

  • CLI output — aligned, indented, colored (via ANSI where supported)
  • HTML output — modern, accessible "cozy dump" card with dark mode
  • Reflection-based — shows private/protected properties from ancestors
  • Redaction — filters sensitive keys automatically
  • Cycle-safe — tracks seen object IDs
  • Truncation — bounded by maxDepth and maxItems
  • Special-cases — DateTimeInterface, Closure, enums

Beginner Guide

1. Log a Message

$debug = Debugger::getInstance();
$debug->info('User logged in', ['user_id' => 42]);

Available convenience levels (via __call):

$debug->debug(...);
$debug->info(...);
$debug->notice(...);
$debug->warning(...);
$debug->error(...);
$debug->critical(...);
$debug->alert(...);
$debug->emergency(...);

2. Inspect a Variable

Dumper::dump($user);

In CLI, you'll see:

App\Models\User {
  public id => 42
  public name => "Alice"
  private password => "***REDACTED***"
}

In browser, you'll see a styled card with syntax coloring.

3. Dump and Die

Dumper::dd($response);

In production mode this renders a generic error page instead of leaking data.

4. Time an Operation

$debug->startTimer('report');
generateReport();
$elapsed = $debug->stopTimer('report');
echo "Took {$elapsed}s";

5. Measure Without Boilerplate

$rows = $debug->measure(fn() => $db->fetchAll('users'), 'fetch_users');

Intermediate Guide

Using Caller Context

Debugger::log() automatically attaches file, line, and function from the caller:

// In App/Services/UserService.php line 58
$debug->info('Registered user');
// Logs: { message: "Registered user", context: { file: "UserService.php", line: 58, ... } }

Memory Snapshots

$debug->memorySnapshot('before_import');
$importer->run($file);
$debug->memorySnapshot('after_import');

$diff = $debug->memoryDiff('before_import', 'after_import');
echo "Import used " . round($diff / 1024, 2) . " KB";

Backtraces

// Log a backtrace
$trace = $debug->backtrace(10);

// Or print it beautifully
$debug->printBacktrace(5);

Subscribing to Debug Events

Every action fires an event you can listen to:

$debug->on('debug.log', function ($payload) {
    // $payload = ['message' => ..., 'level' => ..., 'context' => ...]
    file_put_contents('/tmp/audit.log', $payload['message'] . PHP_EOL, FILE_APPEND);
});

$debug->on('debug.timer.stop', function ($e) {
    if ($e['elapsed'] > 1.0) {
        error_log("Slow operation: {$e['name']} took {$e['elapsed']}s");
    }
});

Query Logging

Enable it and feed SQL through logQuery():

$debug->enableQueryLog();

// In your DB layer:
$start = microtime(true);
$pdo->query($sql);
$debug->logQuery($sql, $bindings, microtime(true) - $start);

// Later:
foreach ($debug->getQueryLog() as $q) {
    printf("[%sms] %s\n", round($q['time'] * 1000, 2), $q['query']);
}

Custom Dumper Configuration

Dumper::configure([
    'maxDepth'      => 8,
    'maxItems'      => 200,
    'sensitiveKeys' => ['password', 'token', 'ssn', 'credit_card'],
    'redact'        => '[HIDDEN]',
]);

Format Without Emitting

Useful for logging or test assertions:

$string = Dumper::format($user);          // CLI-style text
$html   = Dumper::format($user, true);    // HTML fragment
$logger->debug("State: " . $string);

Advanced Guide

Replacing the Logger

use Mlangeni\Machinjiri\Core\Artisans\Logging\Logger;

$debug->setLogger($myCustomLogger);

Replacing the Event Dispatcher

$debug->setEventListener($myCustomDispatcher);

Intercepting dd() in Tests

Dumper::$exitHandler lets you turn a fatal dump into a catchable exception:

use PHPUnit\Framework\Assert;

protected function setUp(): void
{
    Dumper::$exitHandler = function (int $code) {
        throw new \RuntimeException("dd() called with code {$code}");
    };
}

public function testHandlesBadInput(): void
{
    $this->expectException(\RuntimeException::class);
    processBadInput(); // Contains a dd()
}

⚠️ Important: Always reset $exitHandler = null in tearDown().

Configuring Dumper::$exitHandler Per-Test

You can capture the dumped output by combining format() with the exit handler:

$captured = null;
Dumper::$exitHandler = function (int $code) use (&$captured) {
    throw new \RuntimeException('dd() intercepted');
};

try {
    Dumper::dd($payload);
} catch (\RuntimeException $e) {
    // Assert output separately via format()
    $captured = Dumper::format($payload, true);
}

$this->assertStringContainsString('machinjiri-dump', $captured);

Building a Debug Dashboard

Because every operation emits an event, you can aggregate metrics:

$stats = ['timers' => [], 'logs' => 0];

$debug->on('debug.timer.stop', function ($e) use (&$stats) {
    $stats['timers'][] = $e;
});
$debug->on('debug.log', function () use (&$stats) {
    $stats['logs']++;
});

// ... run app ...

render_dashboard($stats);

Safe Dumping of Sensitive Objects

Objects often hold credentials. Override the sensitive key list globally or locally:

Dumper::configure([
    'sensitiveKeys' => array_merge(
        ['password', 'secret', 'token', 'api_key'],
        ['db_dsn', 'stripe_live_key', 'webhook_secret']
    ),
]);

Keys are matched case-insensitively via str_contains, so "UserPassword", "ACCESS_TOKEN", and "db_dsn" are all caught.

Working With Circular References

Dumper tracks spl_object_id per dump session. This is safe:

$a = new stdClass();
$b = new stdClass();
$a->peer = $b;
$b->peer = $a;

Dumper::dump($a); // Second visit to $a shows { circular ref }

Enumerations and Closures

enum Status: string { case Active = 'active'; }

Dumper::dump(Status::Active);   // App\Enums\Status::Active

$fn = fn($x) => $x * 2;
Dumper::dump($fn);              // Closure(/path/to/file.php:12)

Using Debugger Without a Container

Debugger is fully functional without a framework:

$debug = new Debugger(); // No app, no logger → falls back to LoggerFactory
$debug->info('Standalone use');

Environment Switching at Runtime

if ($request->header('X-Debug') === '1') {
    $debug->enable();
}

// Later, to force-disable before sending a response:
$debug->disable();

Note: disable() blocks all subsequent operations including dd() (which will render a generic error page instead of dumping).

Extending the Dumper

Because all state is private, subclassing requires care. Prefer composition:

final class MyDumper
{
    public static function dumpJson(mixed $v): void
    {
        echo json_encode($v, JSON_PRETTY_PRINT | JSON_PARTIAL_OUTPUT_ON_ERROR);
    }
}

Guarding dd() in Production Controllers

A common pattern:

if (Debugger::getInstance()->isEnabled()) {
    Dumper::dd($state);
}
abort(500);

API Reference

Debugger (Public API)

Method Returns Description
__construct(?Container $app = null) void Resolves container, logger, events.
getInstance() self Singleton accessor.
enable() self Enable and log the change.
disable() self Disable and log the change.
isEnabled() bool Current state.
setLogger(Logger $logger) self Inject logger.
setEventListener(EventListener $events) self Inject event bus.
log($message, string $level = 'debug', array $context = []) self Core logging method.
dump(...$args) self Delegate to Dumper::dump().
dd(...$args) never Delegate to Dumper::dd().
startTimer(string $name = 'default') self Begin a named timer.
stopTimer(string $name = 'default', bool $log = true) ?float End timer, return elapsed seconds.
measure(callable $fn, string $label = 'anonymous', bool $log = true) mixed Time a callable and return its result.
memorySnapshot(string $label = 'default') self Capture memory.
memoryDiff(string $start, string $end) ?int Bytes between snapshots.
logMemory(string $label = 'Current memory') self Log current + peak usage.
backtrace(int $limit = 0, int $options = DEBUG_BACKTRACE_PROVIDE_OBJECT) array Get and log a backtrace.
printBacktrace(int $limit = 0) self Dump a backtrace.
logQuery(string $query, array $bindings = [], ?float $time = null) self Append to query log.
enableQueryLog() / disableQueryLog() self Toggle query collection.
getQueryLog() / clearQueryLog() array / self Access/clear query log.
on(string $event, callable $listener, int $priority = 0) self Subscribe to an event.
__call(string $method, array $args) mixed Forward to log levels.

Debugger Events

Event Payload
debug.log ['message' => string, 'level' => string, 'context' => array]
debug.dump ['args' => array]
debug.timer.start ['name' => string]
debug.timer.stop ['name' => string, 'elapsed' => float]
debug.memory.snapshot ['label' => string, 'bytes' => int]
debug.backtrace ['trace' => array]
debug.query ['query' => string, 'bindings' => array, 'time' => ?float, 'timestamp' => float]

Dumper (Public API)

Method Returns Description
configure(array $config) void Merge config over defaults.
dump(mixed ...$args) void Print each argument.
dd(mixed ...$args) never Dump then exit (or invoke $exitHandler).
format(mixed $var, bool $html = false) string Render without emitting.
static $exitHandler ?callable Override for tests.

Dumper Config Keys

Key Default Description
maxDepth 16 Recursion depth cap.
maxItems 1000 Item cap per container.
sensitiveKeys ['password', 'passwd', …] Keys to redact (case-insensitive substring match).
redact '***REDACTED***' Replacement value.

Configuration

Environment Behavior

Environment Debugger::enabled
Development (isDevelopment() === true) true
Production (isDevelopment() === false) false
No container true (default)

Override at runtime:

$debug->enable();   // force on
$debug->disable();  // force off

Sample Production Config

// In your bootstrap:
$debug = Debugger::getInstance();
if (!$app->isDevelopment()) {
    $debug->disable();
    Dumper::configure([
        'maxDepth' => 4,
        'maxItems' => 50,
    ]);
}

Security Considerations

1. Automatic Redaction

Dumper redacts any key whose lowercase form contains a sensitive substring:

password, passwd, pwd, secret, token, api_key, apikey,
access_token, refresh_token, private_key, authorization, auth

This catches "user_password", "API_KEY", "Authorization", etc.

⚠️ Redaction matches keys, not values. A password stored under "value" will not be hidden. Always review what you dump.

2. Production dd() Protection

Dumper::dd() checks the container's environment:

if (method_exists($app, 'isDevelopment') && !$app->isDevelopment()) {
    ErrorHandler::renderGenericErrorPage();
    self::terminate(1);
}

In production, dd() never leaks data — it renders a generic page.

3. Debugger::disable() is Hard

Once disabled, no logs, dumps, timers, or events fire. Use it as a kill switch before responding to untrusted requests.

4. Test-Hook Awareness

Dumper::$exitHandler is a powerful testing tool, but in production it is null by default. Ensure you do not register a handler in a bootstrap that could reach production.


Testing

Full Example Test

use PHPUnit\Framework\TestCase;
use Mlangeni\Machinjiri\Core\Debug\Dumper;
use Mlangeni\Machinjiri\Core\Debug\Debugger;

final class DumperTest extends TestCase
{
    protected function setUp(): void
    {
        Dumper::$exitHandler = null;
    }

    protected function tearDown(): void
    {
        Dumper::$exitHandler = null;
    }

    public function testRedactsPassword(): void
    {
        $html = Dumper::format(['password' => 'hunter2'], true);
        $this->assertStringContainsString('***REDACTED***', $html);
        $this->assertStringNotContainsString('hunter2', $html);
    }

    public function testDdIsInterceptable(): void
    {
        Dumper::$exitHandler = function (int $code): never {
            throw new \RuntimeException("exit({$code})");
        };

        $this->expectException(\RuntimeException::class);
        Dumper::dd(['a' => 1]);
    }

    public function testDebuggerTiming(): void
    {
        $debug = Debugger::getInstance();
        $debug->startTimer('t');
        usleep(10_000);
        $elapsed = $debug->stopTimer('t', false);

        $this->assertNotNull($elapsed);
        $this->assertGreaterThanOrEqual(0.01, $elapsed);
    }
}

Testing Events

$received = null;
$debug->setEventListener($fakeEventBus);
$debug->on('debug.log', function ($e) use (&$received) { $received = $e; });
$debug->info('hello');
$this->assertSame('hello', $received['message']);

Best Practices

Do

  • ✅ Use Debugger::getInstance() for app-wide access
  • ✅ Prefer measure() over manual startTimer/stopTimer for one-off timing
  • ✅ Always use memorySnapshot / memoryDiff when debugging memory leaks
  • ✅ Subscribe to debug.timer.stop to catch slow operations
  • ✅ Reset Dumper::$exitHandler in test tearDown() methods
  • ✅ Extend sensitiveKeys with domain-specific secrets (e.g., db_dsn, stripe_live_key)

Don't

  • ❌ Leave $debug->enable() running in production
  • ❌ Dump objects containing raw secrets under non-sensitive key names
  • ❌ Mix echo/var_dump with Dumper — it fragments output streams
  • ❌ Call dd() in library code — throw exceptions instead
  • ❌ Store container references in Dumper (it's intentionally stateless)

Troubleshooting

Symptom Likely Cause Fix
Nothing logs Debugger::enabled = false Call $debug->enable()
No logger output Logger not bound in container Check $app->bound(Logger::class); fallback logs to system logger
dd() renders a blank page in prod ErrorHandler::renderGenericErrorPage() executed Expected behavior — check isDevelopment()
Dump truncated at depth 16 maxDepth default Dumper::configure(['maxDepth' => 32])
Values still visible despite redaction Key name not in sensitiveKeys Add to config
on() does nothing No EventListener resolved Call $debug->setEventListener($events)
Circular refs not detected Object ID collision across sessions Ensure self::$seenObjects = [] between dumps (handled internally)

FAQ

Q: Does Dumper require the container? A: No. It works standalone. Only dd()'s production check consults the container when present.

Q: Can I use Debugger outside the framework? A: Yes. Construct with new Debugger() — a fallback logger is created automatically.

Q: Is Dumper PSR-3 compatible? A: No — it's a pretty-printer, not a logger. Use Debugger::log() or Dumper::format() for logging pipelines.

Q: Why is Debugger a singleton? A: For ergonomic global access (Debugger::getInstance()->info(...)). You can bypass the singleton by constructing fresh instances.

Q: Can I add custom dump formats? A: Not natively. Compose a wrapper around Dumper::format() or use json_encode for JSON dumps.

Q: What happens if EventListener fails? A: triggerEvent() silently guards with if ($this->events && $this->enabled) — listener failures won't break the debugger, but you should still check for exceptions in your own listeners.

Q: Does Dumper cache styles between dumps? A: Yes — $stylesEmitted ensures CSS is injected only once per process.


Roadmap Ideas

  • Native query logging via PDO decorator
  • Dumper::stream() for tailing dumps to a file
  • Structured payload support for dd() (JSON-encoded for browser fetch)
  • Sampler support for timer capture in high-throughput scenarios

Clone this wiki locally