diff --git a/app/Config/Filters.php b/app/Config/Filters.php index 8421543614e3..a81c4f74c249 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -11,6 +11,7 @@ use CodeIgniter\Filters\InvalidChars; use CodeIgniter\Filters\PageCache; use CodeIgniter\Filters\PerformanceMetrics; +use CodeIgniter\Filters\RequestId; use CodeIgniter\Filters\SecureHeaders; class Filters extends BaseFilters @@ -34,6 +35,7 @@ class Filters extends BaseFilters 'forcehttps' => ForceHTTPS::class, 'pagecache' => PageCache::class, 'performance' => PerformanceMetrics::class, + 'requestid' => RequestId::class, ]; /** @@ -51,11 +53,13 @@ class Filters extends BaseFilters */ public array $required = [ 'before' => [ + // 'requestid', // Request ID for each request 'forcehttps', // Force Global Secure Requests 'pagecache', // Web Page Caching ], 'after' => [ 'pagecache', // Web Page Caching + // 'requestid', // Request ID for each request 'performance', // Performance Metrics 'toolbar', // Debug Toolbar ], diff --git a/system/Config/Filters.php b/system/Config/Filters.php index 8e7d917eac76..1ebc05033024 100644 --- a/system/Config/Filters.php +++ b/system/Config/Filters.php @@ -21,6 +21,7 @@ use CodeIgniter\Filters\InvalidChars; use CodeIgniter\Filters\PageCache; use CodeIgniter\Filters\PerformanceMetrics; +use CodeIgniter\Filters\RequestId; use CodeIgniter\Filters\SecureHeaders; /** @@ -47,6 +48,7 @@ class Filters extends BaseConfig 'forcehttps' => ForceHTTPS::class, 'pagecache' => PageCache::class, 'performance' => PerformanceMetrics::class, + 'requestid' => RequestId::class, ]; /** @@ -64,11 +66,13 @@ class Filters extends BaseConfig */ public array $required = [ 'before' => [ + // 'requestid', // Request ID for each request 'forcehttps', // Force Global Secure Requests 'pagecache', // Web Page Caching ], 'after' => [ 'pagecache', // Web Page Caching + // 'requestid', // Request ID for each request 'performance', // Performance Metrics 'toolbar', // Debug Toolbar ], diff --git a/system/Filters/RequestId.php b/system/Filters/RequestId.php new file mode 100644 index 000000000000..f2db86376525 --- /dev/null +++ b/system/Filters/RequestId.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Filters; + +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; + +class RequestId implements FilterInterface +{ + /** + * Generates a unique request ID for each incoming request and adds it to the request context. + * If the incoming request already has X-Request-ID header, then that header is used instead. + * + * {@inheritDoc} + */ + public function before(RequestInterface $request, $arguments = null): ?ResponseInterface + { + $requestId = trim($request->getHeaderLine('X-Request-ID')); + + if (! $this->isValidRequestId($requestId)) { + $requestId = bin2hex(random_bytes(16)); + } + + context()->set('request_id', $requestId); + + $request->setHeader('X-Request-ID', $requestId); + + return null; + } + + /** + * {@inheritDoc} + */ + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): ?ResponseInterface + { + if (context()->has('request_id')) { + $response->setHeader('X-Request-ID', context()->get('request_id')); + } + + return null; + } + + private function isValidRequestId(string $requestId): bool + { + if ($requestId === '') { + return false; + } + + if (strlen($requestId) > 64) { + return false; + } + + return preg_match('/^[A-Za-z0-9._:-]+$/', $requestId) === 1; + } +} diff --git a/tests/system/Filters/RequestIdTest.php b/tests/system/Filters/RequestIdTest.php new file mode 100644 index 000000000000..03804d27a88f --- /dev/null +++ b/tests/system/Filters/RequestIdTest.php @@ -0,0 +1,169 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Filters; + +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class RequestIdTest extends CIUnitTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + context()->clearAll(); + } + + public function testBefore(): void + { + $filter = new RequestId(); + $request = service('request', null, false); + + $filter->before($request); + + $requestId = context()->get('request_id'); + + $requestIdFromHeader = $request->getHeaderLine('X-Request-ID'); + + $this->assertNotEmpty($requestId); + $this->assertSame($requestId, $requestIdFromHeader); + $this->assertSame(32, strlen($requestId)); + $this->assertMatchesRegularExpression('/^[A-Za-z0-9._:-]+$/', $requestId); + } + + public function testBeforeWithExistingRequestId(): void + { + $filter = new RequestId(); + $request = service('request', null, false); + + $existingRequestId = 'test-request-id-123'; + $request->setHeader('X-Request-ID', $existingRequestId); + + $filter->before($request); + + $requestId = context()->get('request_id'); + + $requestIdFromHeader = $request->getHeaderLine('X-Request-ID'); + + $this->assertSame($existingRequestId, $requestId); + $this->assertSame($existingRequestId, $requestIdFromHeader); + $this->assertMatchesRegularExpression('/^[A-Za-z0-9._:-]+$/', $requestId); + } + + public function testBeforeWithExistingInvalidRequestId(): void + { + $filter = new RequestId(); + $request = service('request', null, false); + + $existingRequestId = 'Abc@!#$'; + $request->setHeader('X-Request-ID', $existingRequestId); + + $filter->before($request); + + $requestId = context()->get('request_id'); + + $requestIdFromHeader = $request->getHeaderLine('X-Request-ID'); + + $this->assertNotSame($existingRequestId, $requestId); + $this->assertSame($requestId, $requestIdFromHeader); + $this->assertSame(32, strlen($requestId)); + $this->assertMatchesRegularExpression('/^[A-Za-z0-9._:-]+$/', $requestId); + } + + public function testBeforeWithExistingLongRequestId(): void + { + $filter = new RequestId(); + $request = service('request', null, false); + + $existingRequestId = str_repeat('a', 65); + $request->setHeader('X-Request-ID', $existingRequestId); + + $filter->before($request); + + $requestId = context()->get('request_id'); + + $requestIdFromHeader = $request->getHeaderLine('X-Request-ID'); + + $this->assertNotSame($existingRequestId, $requestId); + $this->assertSame($requestId, $requestIdFromHeader); + $this->assertSame(32, strlen($requestId)); + $this->assertMatchesRegularExpression('/^[A-Za-z0-9._:-]+$/', $requestId); + } + + public function testAfter(): void + { + $filter = new RequestId(); + $request = service('request', null, false); + $response = service('response', null, false); + + context()->set('request_id', 'test-request-id-123'); + + $filter->after($request, $response); + + $this->assertTrue($response->hasHeader('X-Request-ID')); + $this->assertSame('test-request-id-123', $response->getHeaderLine('X-Request-ID')); + } + + public function testAfterWithoutRequestId(): void + { + $filter = new RequestId(); + $request = service('request', null, false); + $response = service('response', null, false); + + context()->remove('request_id'); + + $filter->after($request, $response); + + $this->assertFalse($response->hasHeader('X-Request-ID')); + } + + public function testResponseOutputsRequestIdFromRequestHeader(): void + { + $filter = new RequestId(); + $request = service('request', null, false); + $response = service('response', null, false); + + $existingRequestId = 'test-request-id-123'; + $request->setHeader('X-Request-ID', $existingRequestId); + + $filter->before($request); + + $filter->after($request, $response); + + $this->assertTrue($response->hasHeader('X-Request-ID')); + $this->assertSame($existingRequestId, $response->getHeaderLine('X-Request-ID')); + } + + public function testResponseOutputsGeneratedRequestId(): void + { + $filter = new RequestId(); + $request = service('request', null, false); + $response = service('response', null, false); + + context()->remove('request_id'); + + $filter->before($request); + + $generatedRequestId = context()->get('request_id'); + + $filter->after($request, $response); + + $this->assertTrue($response->hasHeader('X-Request-ID')); + $this->assertSame($generatedRequestId, $response->getHeaderLine('X-Request-ID')); + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 2008184b4941..cb27ee1d00c2 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -308,6 +308,7 @@ Others - **Float and Double Casting:** Added support for precision and rounding mode when casting to float or double in entities. - Added ``CodeIgniter\Input\InputData`` and ``InputDataFactory`` for reusable typed input data objects. - Float and Double casting now throws ``CastException::forInvalidFloatRoundingMode()`` if an rounding mode other than up, down, even or odd is provided. +- **Filters:** Added ``RequestId`` filter for request tracing and correlation logging. The filter stores the request ID in the request context and automatically adds the ``X-Request-ID`` response header. Incoming ``X-Request-ID`` headers are used when valid. See :ref:`requestid` for details. - **Environment:** Added ``CodeIgniter\EnvironmentDetector`` class and corresponding ``environment`` service as a mockable wrapper around the ``ENVIRONMENT`` constant. Framework internals that previously compared ``ENVIRONMENT`` directly now go through this service, making environment-specific branches reachable in tests via ``Services::injectMock()``. See :ref:`environment-detector-service`. - **Time:** Added ``Time::between()``, ``Time::min()``, and ``Time::max()`` comparison helpers. See :ref:`between `, :ref:`min `, and :ref:`max `. diff --git a/user_guide_src/source/incoming/filters.rst b/user_guide_src/source/incoming/filters.rst index 8da3e31baab1..d15df025a858 100644 --- a/user_guide_src/source/incoming/filters.rst +++ b/user_guide_src/source/incoming/filters.rst @@ -315,6 +315,7 @@ The filters bundled with CodeIgniter4 are: - ``forcehttps`` => :ref:`forcehttps` - ``pagecache`` => :doc:`PageCache <../general/caching>` - ``performance`` => :ref:`performancemetrics` +- ``requestid`` => :ref:`requestid` .. note:: The filters are executed in the order defined in the config file. However, if enabled, ``DebugToolbar`` is always executed last because it should be able to capture everything that happens in the other filters. @@ -377,3 +378,26 @@ If you want to customize the headers, extend ``CodeIgniter\Filters\SecureHeaders .. literalinclude:: filters/011.php If you want to know about secure headers, see `OWASP Secure Headers Project `_. + +.. _requestid: + +Request ID +========== + +.. versionadded:: 4.8.0 + +This filter adds a request ID to each request in context of the application. This can be useful for +debugging and logging purposes, as it allows you to trace a specific request through the application. + +Framework-generated IDs are 32-character hexadecimal strings and are unique for practical purposes, however valid incoming IDs are reused. +It is added to the request's context and can be accessed via the ``request_id`` key. + +To enable this filter, simply add/uncomment the ``requestid`` alias in the ``$required['before']`` and ``$required['after']`` array in **app/Config/Filters.php**: + +.. literalinclude:: filters/014.php + +.. note:: If the incoming request has a header named ``X-Request-ID``, the value of that header + will be used as the request ID instead of generating a new one or checking for uniqueness. + The framework does basic validation to ensure that the incoming request ID is a non-empty string, + has at most 64 characters, and contains only valid characters (alphanumeric, dot, underscore, colon, and hyphen). + If the validation fails, a new request ID will be generated as normal. This allows you to pass in your own request ID if you have one available, such as from a client or a load balancer. diff --git a/user_guide_src/source/incoming/filters/014.php b/user_guide_src/source/incoming/filters/014.php new file mode 100644 index 000000000000..5f6715d5f14d --- /dev/null +++ b/user_guide_src/source/incoming/filters/014.php @@ -0,0 +1,25 @@ + [ + 'requestid', // Request ID for each request + 'forcehttps', // Force Global Secure Requests + 'pagecache', // Web Page Caching + ], + 'after' => [ + 'pagecache', // Web Page Caching + 'requestid', // Request ID for each request + 'performance', // Performance Metrics + 'toolbar', // Debug Toolbar + ], + ]; + + // ... +} diff --git a/user_guide_src/source/installation/upgrade_480.rst b/user_guide_src/source/installation/upgrade_480.rst index 27a494e91e4e..25591bf7921b 100644 --- a/user_guide_src/source/installation/upgrade_480.rst +++ b/user_guide_src/source/installation/upgrade_480.rst @@ -73,6 +73,8 @@ and it is recommended that you merge the updated versions with your application: Config ------ +- app/Config/Filters.php + - Added a new filter named ``requestid`` that adds a unique request ID to each request in the application's context. - app/Config/Mimes.php - ``Config\Mimes::$mimes`` added a new key ``md`` for Markdown files. - app/Config/Routing.php