diff --git a/system/Log/Handlers/BaseHandler.php b/system/Log/Handlers/BaseHandler.php index 2b82f0f49225..20dceb8144c7 100644 --- a/system/Log/Handlers/BaseHandler.php +++ b/system/Log/Handlers/BaseHandler.php @@ -13,6 +13,8 @@ namespace CodeIgniter\Log\Handlers; +use JsonException; + /** * Base class for logging */ @@ -58,4 +60,20 @@ public function setDateFormat(string $format): HandlerInterface return $this; } + + /** + * Encodes the context array as a JSON string. + * Returns the JSON string on success, or a descriptive error string if + * encoding fails (e.g. context contains a resource or invalid UTF-8). + * + * @param array $context + */ + protected function encodeContext(array $context): string + { + try { + return json_encode($context, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + return '[context: JSON encoding failed - ' . $e->getMessage() . ']'; + } + } } diff --git a/system/Log/Handlers/ChromeLoggerHandler.php b/system/Log/Handlers/ChromeLoggerHandler.php index 8d763399b513..5afdac599d9d 100644 --- a/system/Log/Handlers/ChromeLoggerHandler.php +++ b/system/Log/Handlers/ChromeLoggerHandler.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Log\Handlers; use CodeIgniter\HTTP\ResponseInterface; +use JsonException; /** * Allows for logging items to the Chrome console for debugging. @@ -99,10 +100,11 @@ public function __construct(array $config = []) * will stop. Any handlers that have not run, yet, will not * be run. * - * @param string $level - * @param string $message + * @param string $level + * @param string $message + * @param array $context */ - public function handle($level, $message): bool + public function handle($level, $message, array $context = []): bool { $message = $this->format($message); @@ -121,7 +123,9 @@ public function handle($level, $message): bool $type = $this->levels[$level]; } - $this->json['rows'][] = [[$message], $backtraceMessage, $type]; + $logArgs = $context !== [] ? [$message, $context] : [$message]; + + $this->json['rows'][] = [$logArgs, $backtraceMessage, $type]; $this->sendLogs(); @@ -162,8 +166,17 @@ public function sendLogs(?ResponseInterface &$response = null) $response = service('response', null, true); } + try { + $encoded = json_encode($this->json, JSON_THROW_ON_ERROR); + } catch (JsonException) { + $encoded = json_encode($this->json, JSON_PARTIAL_OUTPUT_ON_ERROR); + if ($encoded === false) { + return; + } + } + $data = base64_encode( - mb_convert_encoding(json_encode($this->json), 'UTF-8', mb_list_encodings()), + mb_convert_encoding($encoded, 'UTF-8', mb_list_encodings()), ); $response->setHeader($this->header, $data); diff --git a/system/Log/Handlers/ErrorlogHandler.php b/system/Log/Handlers/ErrorlogHandler.php index a7e820419fee..52f9add8cb5d 100644 --- a/system/Log/Handlers/ErrorlogHandler.php +++ b/system/Log/Handlers/ErrorlogHandler.php @@ -66,11 +66,16 @@ public function __construct(array $config = []) * will stop. Any handlers that have not run, yet, will not * be run. * - * @param string $level - * @param string $message + * @param string $level + * @param string $message + * @param array $context */ - public function handle($level, $message): bool + public function handle($level, $message, array $context = []): bool { + if ($context !== []) { + $message .= ' ' . $this->encodeContext($context); + } + $message = strtoupper($level) . ' --> ' . $message . "\n"; return $this->errorLog($message, $this->messageType); diff --git a/system/Log/Handlers/FileHandler.php b/system/Log/Handlers/FileHandler.php index d3132bf878ac..66ac5384afc0 100644 --- a/system/Log/Handlers/FileHandler.php +++ b/system/Log/Handlers/FileHandler.php @@ -69,12 +69,13 @@ public function __construct(array $config = []) * will stop. Any handlers that have not run, yet, will not * be run. * - * @param string $level - * @param string $message + * @param string $level + * @param string $message + * @param array $context * * @throws Exception */ - public function handle($level, $message): bool + public function handle($level, $message, array $context = []): bool { $filepath = $this->path . 'log-' . date('Y-m-d') . '.' . $this->fileExtension; @@ -104,6 +105,10 @@ public function handle($level, $message): bool $date = date($this->dateFormat); } + if ($context !== []) { + $message .= ' ' . $this->encodeContext($context); + } + $msg .= strtoupper($level) . ' - ' . $date . ' --> ' . $message . "\n"; flock($fp, LOCK_EX); diff --git a/system/Log/Handlers/HandlerInterface.php b/system/Log/Handlers/HandlerInterface.php index 40a9958c714a..b0f767fd2805 100644 --- a/system/Log/Handlers/HandlerInterface.php +++ b/system/Log/Handlers/HandlerInterface.php @@ -18,16 +18,25 @@ */ interface HandlerInterface { + /** + * The reserved key under which global CI context data is stored + * in the log context array. This data comes from the Context service + * and is injected by the Logger when $logGlobalContext is enabled. + */ + public const GLOBAL_CONTEXT_KEY = '_ci_context'; + /** * Handles logging the message. * If the handler returns false, then execution of handlers * will stop. Any handlers that have not run, yet, will not * be run. * - * @param string $level - * @param string $message + * @param string $level + * @param string $message + * @param array $context Full context array; may contain + * GLOBAL_CONTEXT_KEY with CI global data */ - public function handle($level, $message): bool; + public function handle($level, $message, array $context = []): bool; /** * Checks whether the Handler will handle logging items of this diff --git a/system/Log/Logger.php b/system/Log/Logger.php index 87edf8fbc14c..4b81f6c1bd35 100644 --- a/system/Log/Logger.php +++ b/system/Log/Logger.php @@ -264,7 +264,7 @@ public function log($level, string|Stringable $message, array $context = []): vo if ($this->logGlobalContext) { $globalContext = service('context')->getAll(); if ($globalContext !== []) { - $message .= ' ' . json_encode($globalContext); + $context[HandlerInterface::GLOBAL_CONTEXT_KEY] = $globalContext; } } @@ -284,7 +284,7 @@ public function log($level, string|Stringable $message, array $context = []): vo } // If the handler returns false, then we don't execute any other handlers. - if (! $handler->setDateFormat($this->dateFormat)->handle($level, $message)) { + if (! $handler->setDateFormat($this->dateFormat)->handle($level, $message, $context)) { break; } } diff --git a/tests/_support/Log/Handlers/TestHandler.php b/tests/_support/Log/Handlers/TestHandler.php index 025943ffe588..9eaa3ef1ab15 100644 --- a/tests/_support/Log/Handlers/TestHandler.php +++ b/tests/_support/Log/Handlers/TestHandler.php @@ -31,6 +31,13 @@ class TestHandler extends FileHandler */ protected static $logs = []; + /** + * Local storage for log contexts. + * + * @var array> + */ + protected static array $contexts = []; + protected string $destination; /** @@ -45,7 +52,8 @@ public function __construct(array $config) $this->handles = $config['handles'] ?? []; $this->destination = $this->path . 'log-' . Time::now()->format('Y-m-d') . '.' . $this->fileExtension; - self::$logs = []; + self::$logs = []; + self::$contexts = []; } /** @@ -54,14 +62,16 @@ public function __construct(array $config) * will stop. Any handlers that have not run, yet, will not * be run. * - * @param string $level - * @param string $message + * @param string $level + * @param string $message + * @param array $context */ - public function handle($level, $message): bool + public function handle($level, $message, array $context = []): bool { $date = Time::now()->format($this->dateFormat); - self::$logs[] = strtoupper($level) . ' - ' . $date . ' --> ' . $message; + self::$logs[] = strtoupper($level) . ' - ' . $date . ' --> ' . $message; + self::$contexts[] = $context; return true; } @@ -70,4 +80,12 @@ public static function getLogs() { return self::$logs; } + + /** + * @return array> + */ + public static function getContexts(): array + { + return self::$contexts; + } } diff --git a/tests/system/Log/Handlers/ChromeLoggerHandlerTest.php b/tests/system/Log/Handlers/ChromeLoggerHandlerTest.php index 9f67dd885d27..000bf2ea9c78 100644 --- a/tests/system/Log/Handlers/ChromeLoggerHandlerTest.php +++ b/tests/system/Log/Handlers/ChromeLoggerHandlerTest.php @@ -74,6 +74,26 @@ public function testSetDateFormat(): void $this->assertSame('F j, Y', $this->getPrivateProperty($logger, 'dateFormat')); } + public function testHandleIncludesContextInRow(): void + { + Services::injectMock('response', new MockResponse()); + $response = service('response'); + + $config = new LoggerConfig(); + $config->handlers['CodeIgniter\Log\Handlers\TestHandler']['handles'] = ['debug']; + + $logger = new ChromeLoggerHandler($config->handlers['CodeIgniter\Log\Handlers\TestHandler']); + $logger->handle('debug', 'Test message', [HandlerInterface::GLOBAL_CONTEXT_KEY => ['foo' => 'bar']]); + + $this->assertTrue($response->hasHeader('X-ChromeLogger-Data')); + + $decoded = json_decode(base64_decode($response->getHeaderLine('X-ChromeLogger-Data'), true), true); + $logArgs = $decoded['rows'][0][0]; + + $this->assertSame('Test message', $logArgs[0]); + $this->assertSame([HandlerInterface::GLOBAL_CONTEXT_KEY => ['foo' => 'bar']], $logArgs[1]); + } + public function testChromeLoggerHeaderSent(): void { Services::injectMock('response', new MockResponse()); diff --git a/tests/system/Log/Handlers/ErrorlogHandlerTest.php b/tests/system/Log/Handlers/ErrorlogHandlerTest.php index 2383138d7306..7aa89ab2a933 100644 --- a/tests/system/Log/Handlers/ErrorlogHandlerTest.php +++ b/tests/system/Log/Handlers/ErrorlogHandlerTest.php @@ -38,6 +38,15 @@ public function testErrorLoggingWithErrorLog(): void $this->assertTrue($logger->handle('error', 'Test message.')); } + public function testErrorLoggingAppendsContextAsJson(): void + { + $logger = $this->getMockedHandler(['handles' => ['critical', 'error']]); + $logger->method('errorLog')->willReturn(true); + $logger->expects($this->once())->method('errorLog') + ->with("ERROR --> Test message. {\"_ci_context\":{\"foo\":\"bar\"}}\n", 0); + $this->assertTrue($logger->handle('error', 'Test message.', [HandlerInterface::GLOBAL_CONTEXT_KEY => ['foo' => 'bar']])); + } + /** * @param array{handles?: list, messageType?: int} $config * diff --git a/tests/system/Log/Handlers/FileHandlerTest.php b/tests/system/Log/Handlers/FileHandlerTest.php index c810c96f9750..ab16dee5615f 100644 --- a/tests/system/Log/Handlers/FileHandlerTest.php +++ b/tests/system/Log/Handlers/FileHandlerTest.php @@ -79,6 +79,24 @@ public function testHandleCreateFile(): void $this->assertStringContainsString($expectedResult, (string) $line); } + public function testHandleAppendsContextAsJson(): void + { + $config = new LoggerConfig(); + $config->handlers[TestHandler::class]['path'] = $this->start; + $logger = new MockFileLogger($config->handlers[TestHandler::class]); + + $logger->setDateFormat('Y-m-d'); + $expected = 'log-' . date('Y-m-d') . '.log'; + vfsStream::newFile($expected)->at(vfsStream::setup('root'))->withContent(''); + $logger->handle('debug', 'Test message', [HandlerInterface::GLOBAL_CONTEXT_KEY => ['foo' => 'bar']]); + + $fp = fopen($config->handlers[TestHandler::class]['path'] . $expected, 'rb'); + $line = fgets($fp); + fclose($fp); + + $this->assertStringContainsString('Test message {"_ci_context":{"foo":"bar"}}', (string) $line); + } + public function testHandleDateTimeCorrectly(): void { $config = new LoggerConfig(); diff --git a/tests/system/Log/LoggerTest.php b/tests/system/Log/LoggerTest.php index 136dc4332bd5..0bc463a93825 100644 --- a/tests/system/Log/LoggerTest.php +++ b/tests/system/Log/LoggerTest.php @@ -452,14 +452,16 @@ public function testLogsGlobalContext(): void service('context')->set('foo', 'bar'); - $expected = 'DEBUG - ' . Time::now()->format('Y-m-d') . ' --> Test message {"foo":"bar"}'; + $expectedMessage = 'DEBUG - ' . Time::now()->format('Y-m-d') . ' --> Test message'; $logger->log('debug', 'Test message'); - $logs = TestHandler::getLogs(); + $logs = TestHandler::getLogs(); + $contexts = TestHandler::getContexts(); $this->assertCount(1, $logs); - $this->assertSame($expected, $logs[0]); + $this->assertSame($expectedMessage, $logs[0]); + $this->assertSame(['_ci_context' => ['foo' => 'bar']], $contexts[0]); } public function testDoesNotLogGlobalContext(): void diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index abc8265e0e63..aed3e96b62c5 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -37,6 +37,7 @@ Interface Changes **NOTE:** If you've implemented your own classes that implement these interfaces from scratch, you will need to update your implementations to include the new methods or method changes to ensure compatibility. +- **Logging:** ``CodeIgniter\Log\Handlers\HandlerInterface::handle()`` now requires a third parameter ``array $context = []``. Any custom log handler that overrides ``handle()`` - whether implementing ``HandlerInterface`` directly or extending a built-in handler class - must add the parameter to its ``handle()`` method signature. - **Security:** The ``SecurityInterface``'s ``verify()`` method now has a native return type of ``static``. Method Signature Changes @@ -174,6 +175,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 ` 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. Helpers and Functions ===================== diff --git a/user_guide_src/source/installation/upgrade_480.rst b/user_guide_src/source/installation/upgrade_480.rst new file mode 100644 index 000000000000..afdd1655d39e --- /dev/null +++ b/user_guide_src/source/installation/upgrade_480.rst @@ -0,0 +1,77 @@ +############################# +Upgrading from 4.7.x to 4.8.0 +############################# + +Please refer to the upgrade instructions corresponding to your installation method. + +- :ref:`Composer Installation App Starter Upgrading ` +- :ref:`Composer Installation Adding CodeIgniter4 to an Existing Project Upgrading ` +- :ref:`Manual Installation Upgrading ` + +.. contents:: + :local: + :depth: 2 + +********************** +Mandatory File Changes +********************** + +**************** +Breaking Changes +**************** + +********************* +Breaking Enhancements +********************* + +Log Handler Interface +===================== + +``CodeIgniter\Log\Handlers\HandlerInterface::handle()`` now accepts a third +parameter ``array $context = []``. + +If you have a custom log handler that overrides the ``handle()`` method +(whether implementing ``HandlerInterface`` directly or extending a built-in +handler class), you must update your ``handle()`` method signature: + +.. code-block:: php + + // Before + public function handle($level, $message): bool + + // After + public function handle($level, $message, array $context = []): bool + +The context array may contain the CI global context data under the +``HandlerInterface::GLOBAL_CONTEXT_KEY`` (``'_ci_context'``) key when +``$logGlobalContext`` is enabled in ``Config\Logger``. + +************* +Project Files +************* + +Some files in the **project space** (root, app, public, writable) received updates. Due to +these files being outside of the **system** scope they will not be changed without your intervention. + +.. note:: There are some third-party CodeIgniter modules available to assist + with merging changes to the project space: + `Explore on Packagist `_. + +Content Changes +=============== + +The following files received significant changes (including deprecations or visual adjustments) +and it is recommended that you merge the updated versions with your application: + +Config +------ + +- @TODO + +All Changes +=========== + +This is a list of all files in the **project space** that received changes; +many will be simple comments or formatting that have no effect on the runtime: + +- @TODO diff --git a/user_guide_src/source/installation/upgrading.rst b/user_guide_src/source/installation/upgrading.rst index b06f9f62a68f..ab1debe01ed3 100644 --- a/user_guide_src/source/installation/upgrading.rst +++ b/user_guide_src/source/installation/upgrading.rst @@ -22,6 +22,7 @@ Alternatively, replace it with a new file and add your previous lines. backward_compatibility_notes + upgrade_480 upgrade_471 upgrade_470 upgrade_465