Skip to content

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 runs

The 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

FileResponsibility
application/helpers/log_helper.phpDefines the four CI3-override functions, the _log_helper_build_formatter() factory, and the private dispatcher _logger_dispatch_or_fallback().
application/config/container/logger.phpDI configurator. Registers Monolog\Logger via AppLoggerFactory::create and binds it to Psr\Log\LoggerInterface.
src/Logger/AppLoggerFactory.phpStatic factory invoked by the DI configurator. Builds the Monolog\Logger instance, pushes the introspection processor, and constructs handlers.
src/Logger/AppIntrospectionProcessor.phpSubclass of Monolog's IntrospectionProcessor that ALSO skips our CI3-override helper functions, so caller-info points at the real call site.
src/Logger/NamedLoggerInterface.phpSubinterface 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.phpSingle channel ('app'). Two flat configs: file-mode (default, LineFormatter) and stdout-mode (MONOLOG_CHANNEL_CONFIGURATION=STDOUT, JsonFormatter, level-split across stdout/stderr).
public/index.phpLoads the helper file before CI3 boots (line 289), then loads system/core/CodeIgniter.php (line 299).
application/core/Log.phpDefensive 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 on Advisable\Logger\NamedLoggerInterface — it includes the channel-tagging contract.
  • If your component just logs without tagging, depend on Psr\Log\LoggerInterface for the cleanest PSR-3 surface. Test mocks are simpler against the bare PSR-3 interface.
  • The DI container resolves both to the same Monolog\Logger instance.

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 across src/, application/, ecommercen/). Each migrated class either constructor-injects NamedLoggerInterface (modern src/ classes) or uses the lazy di() 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 across application/ and ecommercen/ — 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/ to src/ 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:

formatFormatterUsed inWhy
lineMonolog\Formatter\LineFormatter (built via _log_helper_build_formatter())File mode (rotating local file)Human-readable for tail -f during development.
jsonMonolog\Formatter\JsonFormatter with includeStacktraces=trueSTDOUT 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:

LevelStreamPipeline
Debug, Info, Notice, WarningstdoutFilterHandler(Debug..Warning) → ChannelThresholdHandler → StreamHandler('php://stdout')
Error, Critical, Alert, EmergencystderrFilterHandler(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:

  • FilterHandler outermost — picks WHICH stream this pipeline writes to.
  • ChannelThresholdHandler middle — applies the per-channel admit/suppress decision.
  • StreamHandler innermost — 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 (across application/ and ecommercen/) 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 (11 log_message() calls) is a CI3 helper file (not a class). Migration would require either converting it to a class with LoggerInterface injection, or accepting the per-call context-tagging pattern. Not done in this work.
  • Psr\Log\LoggerInterface mocks in tests cannot call withName(). If a consumer types its constructor as NamedLoggerInterface, mocks must implement the named interface (e.g. an anonymous class with withName() returning $this) or use a real Monolog\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