Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions app/Config/Logger.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,41 @@ class Logger extends BaseConfig
*/
public bool $logGlobalContext = false;

/**
* --------------------------------------------------------------------------
* Whether to log per-call context data
* --------------------------------------------------------------------------
*
* When enabled, context keys not used as placeholders in the message are
* passed to handlers as structured data. Per PSR-3, any ``Throwable`` instance
* in the ``exception`` key is automatically normalized to an array representation.
*/
public bool $logContext = false;

/**
* --------------------------------------------------------------------------
* Whether to include the stack trace for Throwables in context
* --------------------------------------------------------------------------
*
* When enabled, the stack trace is included when normalizing a Throwable
* in the ``exception`` context key. Only relevant when $logContext is true.
*/
public bool $logContextTrace = false;

/**
* --------------------------------------------------------------------------
* Whether to keep context keys that were used as placeholders
* --------------------------------------------------------------------------
*
* By default, context keys that were interpolated into the message as
* {placeholder} are stripped before passing context to handlers, since
* their values are already present in the message text. Set to true to
* keep them as structured data as well.
*
* Only relevant when $logContext is true.
*/
public bool $logContextUsedKeys = false;

/**
* --------------------------------------------------------------------------
* Log Handlers
Expand Down
77 changes: 76 additions & 1 deletion system/Log/Logger.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,27 @@ class Logger implements LoggerInterface
*/
protected bool $logGlobalContext = false;

/**
* Whether to log per-call context data passed to log methods.
*
* Set in app/Config/Logger.php
*/
protected bool $logContext = false;

/**
* Whether to include the stack trace when a Throwable is in the context.
*
* Set in app/Config/Logger.php
*/
protected bool $logContextTrace = false;

/**
* Whether to keep context keys that were already used as placeholders.
*
* Set in app/Config/Logger.php
*/
protected bool $logContextUsedKeys = false;

/**
* Constructor.
*
Expand Down Expand Up @@ -162,7 +183,10 @@ public function __construct($config, bool $debug = CI_DEBUG)
$this->logCache = [];
}

$this->logGlobalContext = $config->logGlobalContext ?? $this->logGlobalContext;
$this->logGlobalContext = $config->logGlobalContext ?? $this->logGlobalContext;
$this->logContext = $config->logContext ?? $this->logContext;
$this->logContextTrace = $config->logContextTrace ?? $this->logContextTrace;
$this->logContextUsedKeys = $config->logContextUsedKeys ?? $this->logContextUsedKeys;
}

/**
Expand Down Expand Up @@ -259,8 +283,26 @@ public function log($level, string|Stringable $message, array $context = []): vo
return;
}

$interpolatedKeys = array_keys(array_filter(
$context,
static fn ($key): bool => str_contains((string) $message, '{' . $key . '}'),
ARRAY_FILTER_USE_KEY,
));

$message = $this->interpolate($message, $context);

if ($this->logContext) {
if (! $this->logContextUsedKeys) {
foreach ($interpolatedKeys as $key) {
unset($context[$key]);
}
}

$context = $this->normalizeContext($context);
} else {
$context = [];
}

if ($this->logGlobalContext) {
$globalContext = service('context')->getAll();
if ($globalContext !== []) {
Expand Down Expand Up @@ -290,6 +332,39 @@ public function log($level, string|Stringable $message, array $context = []): vo
}
}

/**
* Normalizes context values for structured logging.
* Per PSR-3, if an Exception is given to produce a stack trace, it MUST be
* in a key named "exception". Only that key is converted into an array
* representation.
*
* @param array<string, mixed> $context
*
* @return array<string, mixed>
*/
protected function normalizeContext(array $context): array
{
if (isset($context['exception']) && $context['exception'] instanceof Throwable) {
$value = $context['exception'];

$normalized = [
'class' => $value::class,
'message' => $value->getMessage(),
'code' => $value->getCode(),
'file' => clean_path($value->getFile()),
'line' => $value->getLine(),
];

if ($this->logContextTrace) {
$normalized['trace'] = $value->getTraceAsString();
}

$context['exception'] = $normalized;
}

return $context;
}

/**
* Replaces any placeholders in the message with variables
* from the context, as well as a few special items like:
Expand Down
170 changes: 170 additions & 0 deletions tests/system/Log/LoggerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -505,4 +505,174 @@ public function testDoesNotLogHiddenGlobalContext(): void
$this->assertCount(1, $logs);
$this->assertSame($expected, $logs[0]);
}

public function testContextNotPassedToHandlersByDefault(): void
{
$config = new LoggerConfig();
$logger = new Logger($config);

$logger->log('debug', 'Test message', ['foo' => 'bar', 'baz' => 'qux']);

$contexts = TestHandler::getContexts();

$this->assertSame([[]], $contexts);
}

public function testLogContextPassesNonInterpolatedKeysToHandlers(): void
{
$config = new LoggerConfig();
$config->logContext = true;

$logger = new Logger($config);

$logger->log('debug', 'Hello {name}', ['name' => 'World', 'user_id' => 42]);

$contexts = TestHandler::getContexts();

$this->assertArrayNotHasKey('name', $contexts[0]);
$this->assertSame(42, $contexts[0]['user_id']);
}

public function testLogContextStripsInterpolatedKeysByDefault(): void
{
$config = new LoggerConfig();
$config->logContext = true;

$logger = new Logger($config);

$logger->log('debug', 'Hello {name}', ['name' => 'World']);

$contexts = TestHandler::getContexts();

$this->assertSame([[]], $contexts);
}

public function testLogContextKeepsInterpolatedKeysWhenEnabled(): void
{
$config = new LoggerConfig();
$config->logContext = true;
$config->logContextUsedKeys = true;

$logger = new Logger($config);

$logger->log('debug', 'Hello {name}', ['name' => 'World']);

$contexts = TestHandler::getContexts();

$this->assertArrayHasKey('name', $contexts[0]);
$this->assertSame('World', $contexts[0]['name']);
}

public function testLogContextNormalizesThrowable(): void
{
$config = new LoggerConfig();
$config->logContext = true;

$logger = new Logger($config);

try {
throw new RuntimeException('Something went wrong', 42);
} catch (RuntimeException $e) {
$logger->log('error', 'An error occurred', ['exception' => $e]);
}

$contexts = TestHandler::getContexts();

$this->assertArrayHasKey('exception', $contexts[0]);

$normalized = $contexts[0]['exception'];

$this->assertSame(RuntimeException::class, $normalized['class']);
$this->assertSame('Something went wrong', $normalized['message']);
$this->assertSame(42, $normalized['code']);
$this->assertArrayHasKey('file', $normalized);
$this->assertArrayHasKey('line', $normalized);
$this->assertArrayNotHasKey('trace', $normalized);
}

public function testLogContextDoesNotNormalizeThrowableUnderArbitraryKey(): void
{
$config = new LoggerConfig();
$config->logContext = true;

$logger = new Logger($config);

try {
throw new RuntimeException('Something went wrong');
} catch (RuntimeException $e) {
$logger->log('error', 'An error occurred', ['error' => $e]);
}

$contexts = TestHandler::getContexts();

// Per PSR-3, only the 'exception' key is normalized; other keys are left as-is.
$this->assertInstanceOf(RuntimeException::class, $contexts[0]['error']);
}

public function testLogContextNormalizesThrowableWithTrace(): void
{
$config = new LoggerConfig();
$config->logContext = true;
$config->logContextTrace = true;

$logger = new Logger($config);

try {
throw new RuntimeException('Something went wrong');
} catch (RuntimeException $e) {
$logger->log('error', 'An error occurred', ['exception' => $e]);
}

$contexts = TestHandler::getContexts();

$this->assertArrayHasKey('exception', $contexts[0]);
$this->assertArrayHasKey('trace', $contexts[0]['exception']);
$this->assertIsString($contexts[0]['exception']['trace']);
}

public function testLogContextNormalizesInterpolatedThrowableWhenUsedKeysEnabled(): void
{
$config = new LoggerConfig();
$config->logContext = true;
$config->logContextUsedKeys = true;

$logger = new Logger($config);

try {
throw new RuntimeException('Something went wrong');
} catch (RuntimeException $e) {
$logger->log('error', '[ERROR] {exception}', ['exception' => $e]);
}

$contexts = TestHandler::getContexts();

$this->assertArrayHasKey('exception', $contexts[0]);

$normalized = $contexts[0]['exception'];

$this->assertIsArray($normalized);
$this->assertSame(RuntimeException::class, $normalized['class']);
$this->assertSame('Something went wrong', $normalized['message']);
}

public function testLogContextDisabledStillAllowsGlobalContext(): void
{
$config = new LoggerConfig();
$config->logContext = false;
$config->logGlobalContext = true;

$logger = new Logger($config);

Time::setTestNow('2026-02-18 12:00:00');

service('context')->set('request_id', 'abc123');

$logger->log('debug', 'Test message', ['extra' => 'data']);

$contexts = TestHandler::getContexts();

$this->assertArrayNotHasKey('extra', $contexts[0]);
$this->assertArrayHasKey('_ci_context', $contexts[0]);
$this->assertSame(['request_id' => 'abc123'], $contexts[0]['_ci_context']);
}
}
1 change: 1 addition & 0 deletions user_guide_src/source/changelogs/v4.8.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ Libraries

- **Context**: This new feature allows you to easily set and retrieve normal or hidden contextual data for the current request. See :ref:`Context <context>` for details.
- **Logging:** Log handlers now receive the full context array as a third argument to ``handle()``. When ``$logGlobalContext`` is enabled, the CI global context is available under the ``HandlerInterface::GLOBAL_CONTEXT_KEY`` key. Built-in handlers append it to the log output; custom handlers can use it for structured logging.
- **Logging:** Added :ref:`per-call context logging <logging-per-call-context>` with three new ``Config\Logger`` options (``$logContext``, ``$logContextTrace``, ``$logContextUsedKeys``). Per PSR-3, a ``Throwable`` in the ``exception`` context key is automatically normalized to a meaningful array. All options default to ``false``.

Helpers and Functions
=====================
Expand Down
35 changes: 35 additions & 0 deletions user_guide_src/source/general/logging.rst
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,41 @@ This would produce a log entry like:

See :ref:`context` for full documentation on storing and managing context data.

.. _logging-per-call-context:

Per-Call Context Logging
------------------------

.. versionadded:: 4.8.0

By default, context values passed to ``log_message()`` are only used for placeholder
interpolation and are not stored anywhere. You can enable structured context logging
by setting ``$logContext = true`` in **app/Config/Logger.php**:

.. literalinclude:: logging/009.php

When enabled, any context key that is **not** referenced as a ``{placeholder}`` in the
message is passed to handlers as structured data. Keys that were interpolated into the
message are stripped by default (since their values are already present in the message
text), but you can keep them by setting ``$logContextUsedKeys = true``.

.. literalinclude:: logging/007.php

**Throwable normalization**

Per PSR-3, a ``Throwable`` instance must be passed under the ``exception`` key to be
handled specially. When found there, it is automatically normalized into a meaningful
array instead of being serialized as an empty object:

.. literalinclude:: logging/008.php

The normalized array contains ``class``, ``message``, ``code``, ``file``, and ``line``.
To also include the full stack trace, set ``$logContextTrace = true``.

.. note:: ``$logContext`` and ``$logGlobalContext`` are independent. You can enable either
or both. When both are enabled, per-call context and global context are merged before
being passed to handlers.

Using Third-Party Loggers
=========================

Expand Down
10 changes: 10 additions & 0 deletions user_guide_src/source/general/logging/007.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

// With $logContext = true in Config\Logger.
// 'user_id' is not used as a placeholder, so it is passed to handlers as structured data.
log_message('error', 'Payment failed for order {order_id}', [
'order_id' => 'ord_999', // interpolated into the message, stripped from context by default
'user_id' => 42, // not in message, kept and passed to handlers
]);

// Handlers receive context: ['user_id' => 42]
Loading
Loading