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.
Package info
github.com/brunoggdev/ci4-logging-extended
pkg:composer/brunoggdev/ci4-logging-extended
Requires (Dev)
- codeigniter4/devkit: ^1.0
- codeigniter4/framework: ^4.3
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:
- Context serialization — CI4's native logger silently discards context keys that have no matching
{placeholder}. The extended logger appends them automatically as structuredkey=valuepairs. exception()method — Log aThrowablewith rich, structured context: location, request, user identity, session, stack trace — all configurable and extensible.- 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, butLE_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:
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/5to 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-
localPathsetup? Your existingide/wslDistro/localPathvalues 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.



