brunoggdev/ci4-logging-extended

Extended logging for CodeIgniter 4: improved context serialization, exception() method with rich context, web-based Log Viewer with IDE deep links, and exception alerting.

Maintainers

Package info

github.com/brunoggdev/ci4-logging-extended

pkg:composer/brunoggdev/ci4-logging-extended

Statistics

Installs: 140

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.4.0 2026-06-05 16:10 UTC

This package is auto-updated.

Last update: 2026-06-05 16:18:43 UTC


README

Extended logging for CodeIgniter 4 — part of the ci4-*-extended series.

No database. No migrations. No new infrastructure. No new conventions to learn.

This package enhances CI4's native logging capabilities without getting in your way — every existing log_message() and logger()->*() call in your app works automatically from the moment you install it. No finicky or complex configurations, it just works.

Three things in one package:

  1. Context serialization — CI4's native logger silently discards context keys that have no matching {placeholder}. The extended logger appends them automatically as structured key=value pairs.
  2. exception() method — Log a Throwable with rich, structured context: location, request, user identity, session, stack trace — all configurable and extensible.
  3. Log Viewer — A clean web UI for browsing, filtering, and searching your daily log files, with deep links straight to your IDE.

Installation

composer require brunoggdev/ci4-logging-extended

That's it. The package auto-registers via its Services config — no setup required. To customize behavior (Log Viewer gate, exception context, deep links), publish the config:

php spark logging-extended:publish

💡Tip: since config values are returned from methods, env() works anywhere in your config. There's no forced convention, but LE_ is a natural prefix if you want one:

'enabled' => (bool) env('LE_VIEWER_ENABLED', true),
'perPage' => (int) env('LE_PER_PAGE', 50),

Context serialization

PSR-3 only interpolates context keys that have a matching {placeholder} in the message — everything else is discarded by design. With the extended logger, leftover keys are appended automatically:

// Native CI4 — context is silently discarded, log only shows "copyProducts failed"
log_message('error', 'copyProducts failed', [
    'source' => $sourceId,
    'target' => $targetId,
    'error'  => $e->getMessage(),
]);

With the extended logger:

ERROR - 2026-03-20 14:32:01 --> copyProducts failed | source=42 target=99 error="Division by zero"

PSR-3 placeholder interpolation still works — keys consumed by {placeholders} are not duplicated in the appended context:

logger()->warning('Retry {job} on attempt {attempt}', [
    'job'     => 'SendEmail',   // consumed by {job}
    'attempt' => 3,             // consumed by {attempt}
    'reason'  => 'timeout',    // not a placeholder — appended
]);
// WARNING - ... --> Retry SendEmail on attempt 3 | reason=timeout

Value formatting

PHP type Log format
null key=null
true / false key=true / key=false
String without spaces key=value
String with spaces key="value with spaces"
Array / object key={"json":"encoded"}

exception() method

Log a Throwable with a single call:

try {
    // ...
} catch (Throwable $e) {
    logger()->exception($e);              // defaults to 'error' level
    logger()->exception($e, 'warning');   // any PSR-3 level
    logger()->exception($e, 'error', 'Failed to process order #' . $orderId); // custom message
}

Default output (with trace: false for brevity):

ERROR - 2026-03-20 14:32:01 --> [RuntimeException] Something went wrong | message="Something went wrong" location=/var/www/app/Services/OrderService.php:84

location points to the call site — if the exception was created via a static constructor (e.g. AppException::for(...)), the package walks the stack trace to skip the factory frame and report where it was actually called from.

With a custom message, the exception's own message is omitted from context — the call-site message is the log entry:

ERROR - 2026-03-20 14:32:01 --> [RuntimeException] Failed to process order #99 | location=/var/www/app/Services/OrderService.php:84

Configuration

Publish the config (php spark logging-extended:publish) and edit app/Config/LoggingExtended.php. Everything lives in the exception() method — modify what you need:

protected function exception(): array
{
    return [
        'trace'   => true,
        'request' => [
            'enabled' => true,
            'params'  => false,     // GET/POST/JSON body — off by default (privacy)
            'headers' => false,     // true = all headers; array of names = allow-list; false = off
            'redact'  => ['password', 'token', 'api_key', 'authorization', 'cookie', ...],
        ],
        'captures' => [
            'user'    => fn () => ['id' => auth()->id(), 'email' => auth()->user()?->email],
            'session' => false,     // true = all session data; callable for specific keys
            'extra'   => ['tenant' => fn () => session('tenant_id')],
        ],
        'alerts'  => [
            'handlers' => [SlackAlertHandler::class],
            'levels'   => ['critical', 'error'],
            'throttle' => 15 * MINUTE,
        ],
    ];
}

Rich exception context example

With user, request, captures, and trace enabled, a single exception() call produces:

ERROR - 2026-03-20 14:32:01 --> [RuntimeException] Something went wrong | message="Something went wrong" location=/var/www/app/Services/OrderService.php:84 request={"method":"POST","url":"https://app.test/checkout"} user={"id":7,"email":"alice@example.com"} tenant=acme-corp
#0 /var/www/app/Controllers/CheckoutController.php(42): OrderService->process()
#1 /var/www/vendor/codeigniter4/framework/system/Router/Router.php(563): ...
...

Every structured key is searchable in the Log Viewer using dot-notation: user.email=alice@example.com, request.method=POST, tenant=acme-corp.

Extending with external trackers

Override exception() in a subclass and call parent:: to keep the file log entry:

use Brunoggdev\LoggingExtended\Logger;

class SentryLogger extends Logger
{
    public function exception(Throwable $e, string $level = 'error', ?string $message = null): void
    {
        \Sentry\captureException($e);
        parent::exception($e, $level, $message);
    }
}

Wire your subclass in app/Config/Services.php instead of the base logger. The buildExceptionContext() method is also protected — override it to add fields without replacing the whole method.

Alert handlers

Handlers are notified after each matching log entry is written to file — useful for Slack notifications, PagerDuty, custom webhooks, etc. Configure them in the alerts key of your exception() method.

A handler can be a closure, an invokable class, or any class with a handle(LogAlert $alert): void method:

class SlackAlertHandler
{
    public function handle(LogAlert $alert): void
    {
        Http::post(env('SLACK_WEBHOOK'), [
            'text' => "[{$alert->level}] {$alert->message}",
            'user' => $alert->captures['user'] ?? null,
        ]);
    }
}

LogAlert exposes:

Property Type Description
$level string PSR-3 level in lowercase ('error', 'critical', …)
$message string Exception message or interpolated log message
$captures array Your configured captures: request, user, session, and any extra keys
$context array Everything else: CI4 native keys (routeInfo, exFile, …) and any key/value pairs passed to plain logger()->*() calls
$timestamp Time Alert time in the app timezone
$exception ?Throwable The raw exception, when triggered via exception() or CI4's native handler
$location ?string file.php:line of the throw site, smart-resolved for named constructors

A broken handler never takes down the logger — all calls are wrapped in try/catch and the file log is always written first.

throttle accepts any duration using CI4's time constants (15 * MINUTE, 2 * HOUR, etc.) and uses an isolated file cache — never touches your app's cache store — to suppress repeat alerts for the same level + message within that window. If a handler returns false, the throttle lock is not saved — use this to signal that the alert was not delivered so the next occurrence gets another chance.

Log Viewer

A web UI for browsing your daily log files, with multiple themes to choose from and light/dark mode:

Log Viewer — brite theme Log Viewer — lumen theme Log Viewer — litera theme Log Viewer — pulse theme

Features

  • Browse daily log files with pagination and level breakdowns
  • Filter by level — click any level badge to narrow entries
  • Search — full-text, dot-notation context lookup, and regex (see Filtering and search)
  • Shareable links — search query, level filter, file, and page are all reflected in the URL
  • Live tail — new entries stream in automatically while viewing today's file (via SSE)
  • File management — delete individual files or bulk-select and delete multiple at once
  • IDE deep links — click any stack frame to jump straight to that file and line in your editor
  • Occurrence tracking — repeated messages show 2/5 to surface noise without losing context

Setup

The viewer is enabled by default in your published config. Set the gate in your viewer() method to control who can access it:

protected function viewer(): array
{
    return [
        // ...
        'gate' => fn () => auth()->loggedIn() && auth()->user()->isAdmin(),
        // or: 'gate' => self::GATE_LOGIN,
    ];
}

Using self::GATE_LOGIN activates the built-in login page — run php spark log-viewer:set-password to set the password (stored as a bcrypt hash in .env).

By default (before publishing) the gate allows access only in the development environment and denies with 404 everywhere else.

You can also require existing CI4 filters before the gate fires — useful if you have an existing auth filter:

'routes' => [
    'path'    => 'logs',
    'filters' => ['auth'],  // CI4 filter aliases applied before the gate
],

Accessing the viewer

The viewer mounts at /logs by default. Change routes.path in your viewer() method to move it:

'routes' => [
    'path'    => 'devtools/logs',
    'filters' => [],
],

Filtering and search

The search box supports:

Query Matches
payment failed entries containing both words (AND logic)
user.email=alice@example.com dot-notation equality
request.method=POST dot-notation equality
user.email=.*@example\.com dot-notation with regex
user.id dot-notation presence check (key exists)

Multiple space-separated terms all must match (AND, not OR).

IDE deep links

Stack frame and location file paths in the viewer link directly to your editor (vscode:// / phpstorm://).

These settings are per-developer and stored in the browser — open the ⚙ Settings panel (top of the sidebar) and set your editor, your local project path, and an optional WSL distro. Each developer configures their own once, which is what makes deep links work on a shared staging/production viewer: there's no single server-wide local path to fight over.

The server path is auto-detected (ROOTPATH) and rewritten to your local path, so the same logs deep-link correctly on every machine. The first time you click a deep link without a local path set, the Settings panel opens automatically.

The deeplink config block only seeds first-run defaults and handles one server-side edge case:

'deeplink' => [
    // First-run defaults seeded into each viewer's browser. Developers override
    // these in the Settings panel — no need to set them per environment.
    'ide'        => 'vscode',   // 'vscode', 'phpstorm', or null
    'wslDistro'  => null,       // WSL distro name (VS Code only)
    'localPath'  => null,       // a developer's local project path

    // Server path prefix rewritten to each developer's localPath.
    // null = auto-detect from ROOTPATH (recommended). Set it only for symlinked
    // releases or containers where the paths recorded in the logs differ.
    'serverPath' => null,
],

Upgrading from a single-localPath setup? Your existing ide / wslDistro / localPath values still apply as each browser's first-run defaults, so nothing breaks. Going forward, configure per-developer in the Settings panel; these seed keys are deprecated and will be removed in v2.

Viewer configuration reference

protected function viewer(): array
{
    return [
        'enabled'  => true,         // false = completely hide the viewer and its routes
        'gate'     => null,         // null = deny (404); GATE_LOGIN = built-in login; callable = custom
        'routes'   => [
            'path'    => 'logs',    // URL path where the viewer is accessible
            'filters' => [],        // CI4 filter aliases applied before the gate
        ],
        'deeplink' => [                 // first-run seeds; configured per-developer in Settings
            'ide'        => 'vscode',   // 'vscode', 'phpstorm', or null
            'wslDistro'  => null,       // WSL distro name (VSCode only)
            'localPath'  => null,       // a developer's local path
            'serverPath' => null,       // null = auto-detect (ROOTPATH); set for symlink/container deploys
        ],
        'perPage'  => 50,           // entries per page
    ];
}

log:tail

Watch your log file live in the terminal:

php spark log:tail

Shows the last 20 lines on startup, then streams new entries as they arrive. Rolls over automatically at midnight.

Options

Option Description Default
-level Filter by log level
-filter Filter lines containing this text (case-insensitive)
-lines Lines to show on startup (0 to skip history) 20

Examples

php spark log:tail                                        # watch everything
php spark log:tail -level error -lines 0                 # errors only, no history
php spark log:tail -filter payment -lines 100            # keyword filter, last 100 lines
php spark log:tail -level warning -filter checkout       # combine both filters

Output

  ERROR      2026-03-20 14:32:01  copyProducts failed | source=42 error="Timeout"
  WARNING    2026-03-20 14:32:05  Retry scheduled | job=CopyProducts attempt=2
  INFO       2026-03-20 14:32:10  Job completed successfully

Level labels are colorized: red for error/critical/alert/emergency, yellow for warning, cyan for notice, green for info, gray for debug. The context block (after |) is highlighted in cyan.

Related packages