Appearance
SY-32: Logging Architecture and Migration Guide
Flow ID: SY-32 | Module(s): application/helpers, application/config/container, application/core, ecommercen/core, src/Logger, public/index.php | Complexity: Medium Last Updated: 2026-05-07
Status: Active Owner: Platform team
Overview
The platform's logging is built on Monolog 3 and registered as a single Psr\Log\LoggerInterface service in DI. Modern src/ services inject the logger via constructor; legacy CI3 controllers / models / libraries access it via di()->get(LoggerInterface::class). CodeIgniter 3's global log_message() helper continues to exist and continues to work — its body has been replaced with a PSR-3 routing call so all existing call sites benefit from the same logger without source-level changes.
Boot flow
public/index.php
1. composer autoload
2. dotenv
3. require application/helpers/log_helper.php ← defines our 4 functions
4. require system/core/CodeIgniter.php
├─ set_error_handler('_error_handler') ← OUR fn (CI3 thinks it's its own)
├─ set_exception_handler('_exception_handler') ← OUR fn
├─ register_shutdown_function('_shutdown_handler') ← OUR fn
└─ Common.php loads — function_exists() guards skip CI3 defaults
5. CI3 dispatches the controller → application code runsThe four functions we override (log_message, _error_handler, _exception_handler, _shutdown_handler) all forward to Psr\Log\LoggerInterface via the DI container. Pre-DI calls (from CI3's very-early bootstrap, before the container is built) fall back to error_log().
Where things live
| File | Responsibility |
|---|---|
application/helpers/log_helper.php | Defines the four CI3-override functions, the _log_helper_build_formatter() factory, and the private dispatcher _logger_dispatch_or_fallback(). |
application/config/container/logger.php | DI configurator. Registers Monolog\Logger via AppLoggerFactory::create and binds it to Psr\Log\LoggerInterface. |
src/Logger/AppLoggerFactory.php | Static factory invoked by the DI configurator. Builds the Monolog\Logger instance, pushes the introspection processor, and constructs handlers. |
src/Logger/AppIntrospectionProcessor.php | Subclass of Monolog's IntrospectionProcessor that ALSO skips our CI3-override helper functions, so caller-info points at the real call site. |
src/Logger/NamedLoggerInterface.php | Subinterface of Psr\Log\LoggerInterface that adds the withName(string $name): static contract. The DI container resolves both interfaces to the same Monolog\Logger instance. Components that call withName() should depend on this interface for type-safety. |
application/config/monolog.php | Single channel ('app'). Two flat configs: file-mode (default, LineFormatter) and stdout-mode (MONOLOG_CHANNEL_CONFIGURATION=STDOUT, JsonFormatter, level-split across stdout/stderr). |
public/index.php | Loads the helper file before CI3 boots (line 289), then loads system/core/CodeIgniter.php (line 299). |
application/core/Log.php | Defensive stub (class CI_Log) that throws on construction. Kept as a tripwire for any future code that calls load_class('Log', 'core'). |
Canonical injection patterns
Modern src/ class (preferred — constructor injection)
php
namespace Advisable\YourComponent;
use Advisable\Logger\NamedLoggerInterface;
class YourComponent
{
private \Advisable\Logger\NamedLoggerInterface $logger;
public function __construct(\Advisable\Logger\NamedLoggerInterface $logger /* + your other deps */)
{
$this->logger = $logger->withName('your-component');
}
public function doWork(): void
{
$this->logger->info('did the work', ['foo' => 'bar']);
}
}The withName('your-component') call sets the %channel% field for every record this instance emits — that's how you get a tagged channel string for filtering in Grafana or tail -f log.php | grep your-component.
Choosing between LoggerInterface and NamedLoggerInterface:
- If your component will tag its records via
withName('component'), depend onAdvisable\Logger\NamedLoggerInterface— it includes the channel-tagging contract. - If your component just logs without tagging, depend on
Psr\Log\LoggerInterfacefor the cleanest PSR-3 surface. Test mocks are simpler against the bare PSR-3 interface. - The DI container resolves both to the same
Monolog\Loggerinstance.
If your component doesn't need a permanent identity (e.g. a controller method that handles many concerns), skip withName() and pass per-call tags via structured context: $this->logger->error('msg', ['component' => 'something']);.
Legacy CI3 controller / model / library (lazy di() lookup)
php
class Some_controller extends MY_Controller
{
private ?\Advisable\Logger\NamedLoggerInterface $logger = null;
private function logger(): \Advisable\Logger\NamedLoggerInterface
{
return $this->logger ??= di()->get(\Advisable\Logger\NamedLoggerInterface::class)->withName('some');
}
public function index()
{
$this->logger()->error('boom', ['cart_id' => 42]);
}
}CI3 controllers are instantiated by the framework's HMVC loader, so constructor injection from Symfony DI is not available. The lazy property avoids the cost of resolving the logger on every request — only when the controller actually logs.
Plain log_message() (no migration needed)
php
log_message('error', 'something happened', ['key' => 'value']);Continues to work everywhere. Routes through PSR-3 like everything else, but the resulting record has the default channel string (env('ENVIRONMENT')) and no per-call component tag unless you put one in the context array.
Migrating existing call sites
Migration coverage:
- All
new CodeIgniterLogger()instantiations have been migrated (44 sites acrosssrc/,application/,ecommercen/). Each migrated class either constructor-injectsNamedLoggerInterface(modernsrc/classes) or uses the lazydi()accessor (legacy CI3 classes). - Three worked-example
log_message()migrations:Adv_checkout.php,src/Public/PublicOrders.php,src/Moosend/Moosend.php. - The remaining
log_message()callers — approximately 180 sites acrossapplication/andecommercen/— continue to route through the global helper override and produce records with the default channel string. Migration is incremental.
Migrate remaining log_message() call sites opportunistically:
- When you're touching the file for another reason
- When the file is high-traffic enough that a tagged channel string would make Grafana queries cleaner
- When you're moving the file from
application//ecommercen/tosrc/anyway
Single-line guarantee
Every log record emitted by the platform's logger occupies exactly one physical line.
File mode (MONOLOG_CHANNEL_CONFIGURATION unset / STANDARD): Monolog's LineFormatter with allowInlineLineBreaks=false. Embedded newlines in messages or stack traces collapse to a single space. The factory function _log_helper_build_formatter() is the single source of truth.
STDOUT mode (MONOLOG_CHANNEL_CONFIGURATION=STDOUT): Monolog's JsonFormatter with appendNewline=true and includeStacktraces=true. Each record renders as one JSON object terminated by \n. JSON guarantees no embedded literal newlines (they are encoded as \n inside string values), so the single-line property is structurally enforced. See Output formats for the full rationale.
This is enforced by tests/Unit/Logger/CoreLoggerFormatTest.php (line formatter contract), tests/Unit/Logger/AppLoggerFactoryTest.php (JSON shape and level routing), and tests/Integration/Logger/LoggerDiBindingTest.php (file mode handler stack and introspection processor).
Caller info via AppIntrospectionProcessor
Every log line carries the file/line/class/function of the actual caller — the controller / model / service / helper that called $logger->error(...) or log_message(...). The processor is a small subclass of Monolog's IntrospectionProcessor that adds the four CI3-override helper functions (log_message, _error_handler, _exception_handler, _shutdown_handler) and the private dispatcher (_logger_dispatch_or_fallback) to its skip set. Monolog's stock processor only skips class frames matching skipClassesPartials plus a hardcoded call_user_func / call_user_func_array set; user-supplied function names are not honoured. Without the subclass, every record routed through log_message() would report _logger_dispatch_or_fallback as the source.
If you see log_helper.php or Monolog\ reported as the caller, the skip logic is wrong — investigate src/Logger/AppIntrospectionProcessor.php.
Output formats
The factory selects a formatter per handler from the format key in application/config/monolog.php. Two formats are supported:
format | Formatter | Used in | Why |
|---|---|---|---|
line | Monolog\Formatter\LineFormatter (built via _log_helper_build_formatter()) | File mode (rotating local file) | Human-readable for tail -f during development. |
json | Monolog\Formatter\JsonFormatter with includeStacktraces=true | STDOUT mode (K8s) | Loki/Grafana/Datadog/CloudWatch parse JSON natively at ingest, so queries like level=ERROR channel=klarna context.cart_id=42 resolve against indexed fields rather than line-pattern regex. |
JSON output preserves the single-line guarantee structurally — embedded newlines inside string values are JSON-escaped to \n, and one record corresponds to exactly one physical line. Stack traces in context.exception are flattened into the same record.
A custom lineFormat template per handler is only honoured when format is line. The JSON formatter has no template; field names are fixed by Monolog (message, level, level_name, channel, datetime, context, extra).
stdout / stderr split (12-factor)
In STDOUT mode each record routes to exactly one stream:
| Level | Stream | Pipeline |
|---|---|---|
Debug, Info, Notice, Warning | stdout | FilterHandler(Debug..Warning) → ChannelThresholdHandler → StreamHandler('php://stdout') |
Error, Critical, Alert, Emergency | stderr | FilterHandler(Error..Emergency) → ChannelThresholdHandler → StreamHandler('php://stderr') |
FilterHandler sits at the top of the pipeline so a record outside the level range bubbles to the next pipeline without paying the per-channel-gate cost. Records appear once on the stream that matches their severity and never on the other.
Wrapping order is deliberate:
FilterHandleroutermost — picks WHICH stream this pipeline writes to.ChannelThresholdHandlermiddle — applies the per-channel admit/suppress decision.StreamHandlerinnermost — emits the formatted line to the actual stream.
K8s collects stdout and stderr separately. Grafana/Loki queries can partition {stream="stderr"} for alerting and {stream="stdout"} for diagnostic browsing. CLI tools (kubectl logs --all-containers, docker logs) consume both streams unmodified.
Per-channel thresholds
The platform-default threshold in application/config/monolog.php applies to every channel that isn't explicitly mapped. To override the default for specific components, list them under channelThresholds:
php
'channelThresholds' => [
'klarna' => 'DEBUG', // verbose Klarna trace during a payment-gateway investigation
'transporter-elta' => 'WARNING', // ELTA tracking calls — quiet info-level chatter
'matomo' => 'CRITICAL', // Matomo is noisy in development; only log fatal issues
],Channels mapped here override the platform default both ways — you can EITHER admit lower-severity records than the platform default (e.g. klarna at DEBUG while the platform default is NOTICE) OR raise the bar (e.g. matomo at CRITICAL to silence info/warning noise).
Mechanism: Advisable\Logger\ChannelThresholdHandler decorates each handler in the Logger. The decorator inspects $record->channel against the map and either delegates to the wrapped handler or returns false (suppress). The wrapped handler itself accepts Level::Debug so the decorator can raise OR lower the effective threshold; with the decorator in place, the wrapped handler's threshold is irrelevant.
Runtime override via env var
APP_LOG_CHANNEL_OVERRIDES accepts a JSON object that merges on top of the static map at config-load time. Useful for temporary debug sessions in K8s without a redeploy:
bash
APP_LOG_CHANNEL_OVERRIDES='{"klarna":"DEBUG","transporter-elta":"DEBUG"}'The merge is shallow — env-var entries replace map entries with the same key. To clear an override after debugging, restart the pod with the env var unset (or empty {}).
CI3 functions in system/core/Common.php
CI3's bundled definitions of log_message, _error_handler, _exception_handler, _shutdown_handler (behind function_exists() guards in system/core/Common.php) are dormant in this codebase. Our application/helpers/log_helper.php defines all four before CI3 loads. Don't be confused if you read Common.php and see the CI3 bodies — they're never called.
What about the named channel services?
There used to be deferred_task.logger and project_agora.logger in DI, each a CoreLogger configured for its own log file. Both are gone. Their consumers (DeferredTaskRunner, ProjectAgoraFactory) now take the autowired LoggerInterface and call withName() to preserve the channel string in formatted output. Same observable shape, one fewer parallel logger.
Known Issues & Security Gaps
- The remaining
log_message()callers (acrossapplication/andecommercen/) have not been individually migrated. They work through the global helper override and produce records with the default channel string. Migration is incremental. application/helpers/farmakon_helper.php(11log_message()calls) is a CI3 helper file (not a class). Migration would require either converting it to a class withLoggerInterfaceinjection, or accepting the per-call context-tagging pattern. Not done in this work.Psr\Log\LoggerInterfacemocks in tests cannot callwithName(). If a consumer types its constructor asNamedLoggerInterface, mocks must implement the named interface (e.g. an anonymous class withwithName()returning$this) or use a realMonolog\Logger. Existing tests for the migrated consumers (e.g.tests/Unit/DeferredTask/DeferredTaskRunnerTest.php) demonstrate this pattern.
See also
- Tests:
tests/Unit/Logger/CoreLoggerFormatTest.php,tests/Unit/Logger/LogHelperHandlersTest.php,tests/Unit/Logger/AppLoggerFactoryTest.php,tests/Unit/Logger/ChannelThresholdHandlerTest.php,tests/Integration/Logger/LoggerDiBindingTest.php - DI registration:
application/config/container/logger.php - Factory:
src/Logger/AppLoggerFactory.php - Named logger interface:
src/Logger/NamedLoggerInterface.php - Custom processor:
src/Logger/AppIntrospectionProcessor.php - Helper functions:
application/helpers/log_helper.php