diff --git a/.gitattributes b/.gitattributes index 805a9fe..e888d34 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,8 +3,9 @@ tests/ export-ignore .github/ export-ignore .gitattributes export-ignore .gitignore export-ignore -.phpcs.xml export-ignore +.phpcs.xml.dist export-ignore phpunit.xml export-ignore +phpstan.neon export-ignore # Auto detect text files and perform LF normalization * text=auto diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65a1037..4295747 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,26 +14,26 @@ jobs: timeout-minutes: 15 strategy: matrix: - php: [ '8.1', '8.2', '8.3', '8.4' ] + php: [ '8.2', '8.3', '8.4', '8.5' ] dependency-version: [ '' ] include: - - php: '8.1' + - php: '8.2' dependency-version: '--prefer-lowest' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} coverage: none - name: Cache Composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.composer/cache key: php-${{ matrix.php }}-composer-locked-${{ hashFiles('composer.lock') }} restore-keys: php-${{ matrix.php }}-composer-locked- - name: Install PHP dependencies - run: composer update ${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-progress --no-suggest + run: composer update ${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-progress - name: PHPUnit run: vendor/bin/phpunit @@ -41,21 +41,21 @@ jobs: name: Coding standards runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.3 + php-version: 8.5 tools: cs2pr coverage: none - name: Cache Composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.composer/cache key: php-composer-locked-${{ hashFiles('composer.lock') }} restore-keys: php-composer-locked- - name: Install PHP dependencies - run: composer install --no-interaction --no-progress --no-suggest + run: composer install --no-interaction --no-progress - name: PHP CS run: vendor/bin/phpcs -q --no-colors --report=checkstyle | cs2pr - name: PHPStan diff --git a/README.md b/README.md index f636df6..8b1409a 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ All you need to log with [Bref](https://bref.sh) on AWS Lambda. -Bref/Logger is a lightweight [PSR-3](https://www.php-fig.org/psr/psr-3/) logger for AWS Lambda. Messages are sent to `stderr` so that they end up in [CloudWatch](https://bref.sh/docs/environment/logs.html). +Bref/Logger is a lightweight [PSR-3](https://www.php-fig.org/psr/psr-3/) logger for AWS Lambda. Messages are sent to `stderr` so that they end up in [CloudWatch](https://bref.sh/docs/environment/logs). ## Why? -As explained in [the Bref documentation](https://bref.sh/docs/environment/logs.html), logging in AWS Lambda means logging to `stderr`. Logs written to `stderr` are automatically sent to [CloudWatch](https://aws.amazon.com/cloudwatch/), AWS' solution to collect and view logs. +As explained in [the Bref documentation](https://bref.sh/docs/environment/logs), logging in AWS Lambda means logging to `stderr`. Logs written to `stderr` are automatically sent to [CloudWatch](https://aws.amazon.com/cloudwatch/), AWS' solution to collect and view logs. While classic loggers like [Monolog](https://github.com/Seldaek/monolog) work fine, this logger comes as a simpler and lighter alternative optimized for AWS Lambda. It does not require any configuration and currently contains a single class. diff --git a/composer.json b/composer.json index f7d2239..f57fc43 100644 --- a/composer.json +++ b/composer.json @@ -15,14 +15,14 @@ } }, "require": { - "php": ">=8.0", + "php": ">=8.2", "ext-json": "*", "psr/log": "^1.1 | ^2.0 | ^3.0" }, "require-dev": { - "phpunit/phpunit": "^10.5|^11|^12", + "phpunit/phpunit": "^11.5|^12|^13", "mnapoli/hard-mode": "^0.3", - "phpstan/phpstan": "^1" + "phpstan/phpstan": "^2" }, "config": { "allow-plugins": { diff --git a/phpstan.neon b/phpstan.neon index 46b0f47..1494f51 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,5 @@ parameters: - level: 5 + level: max paths: - src - tests diff --git a/src/StderrLogger.php b/src/StderrLogger.php index 844b4e2..6315595 100644 --- a/src/StderrLogger.php +++ b/src/StderrLogger.php @@ -3,7 +3,9 @@ namespace Bref\Logger; use Psr\Log\AbstractLogger; +use Psr\Log\InvalidArgumentException; use Psr\Log\LogLevel; +use Stringable; use Throwable; /** @@ -22,44 +24,39 @@ class StderrLogger extends AbstractLogger LogLevel::DEBUG => 1, ]; - /** @var string */ - private $logLevel; - - /** @var string|null */ - private $url; - - /** @var resource|null */ + /** @var resource|string The stream, or its URL until it is opened */ private $stream; /** * @param string $logLevel The log level above which messages will be logged. Messages under this log level will be ignored. * @param resource|string $stream If unsure leave the default value. */ - public function __construct(string $logLevel = LogLevel::INFO, $stream = 'php://stderr') - { - $this->logLevel = $logLevel; - - if (is_resource($stream)) { - $this->stream = $stream; - } elseif (is_string($stream)) { - $this->url = $stream; - } else { + public function __construct( + private string $logLevel = LogLevel::INFO, + $stream = 'php://stderr', + ) { + if (! is_resource($stream) && ! is_string($stream)) { throw new \InvalidArgumentException('A stream must either be a resource or a string.'); } + $this->stream = $stream; } /** - * {@inheritdoc} + * @param mixed $level + * @param string|Stringable $message + * @param array $context + * @throws InvalidArgumentException If the log level is not one of the PSR-3 levels. */ public function log($level, $message, array $context = []): void { + if (! is_string($level) || ! isset(self::LOG_LEVEL_MAP[$level])) { + throw new InvalidArgumentException('Unsupported log level: ' . var_export($level, true)); + } if (self::LOG_LEVEL_MAP[$level] < self::LOG_LEVEL_MAP[$this->logLevel]) { return; } - $this->openStderr(); - - $message = $this->interpolate($message, $context); + $message = $this->interpolate((string) $message, $context); // Make sure everything is kept on one line to count as one record $displayMessage = str_replace(["\r\n", "\r", "\n"], ' ', $message); @@ -89,41 +86,48 @@ public function log($level, $message, array $context = []): void $formattedMessage = "$requestId\t$formattedMessage"; } - fwrite($this->stream, $formattedMessage); + fwrite($this->stream(), $formattedMessage); } - private function openStderr(): void + /** + * @return resource + */ + private function stream() { - if ($this->stream !== null) { - return; - } - $this->stream = fopen($this->url, 'a'); - if (! $this->stream) { - throw new \RuntimeException('Unable to open stream ' . $this->url); + if (is_string($this->stream)) { + $stream = fopen($this->stream, 'a'); + if ($stream === false) { + throw new \RuntimeException('Unable to open stream ' . $this->stream); + } + $this->stream = $stream; } + + return $this->stream; } /** * Interpolates context values into the message placeholders. + * + * @param array $context */ private function interpolate(string $message, array $context): string { - if (strpos($message, '{') === false) { + if (! str_contains($message, '{')) { return $message; } $replacements = []; foreach ($context as $key => $val) { - if ($val === null || is_scalar($val) || (\is_object($val) && method_exists($val, '__toString'))) { - $replacements["{{$key}}"] = $val; + if ($val === null || is_scalar($val) || $val instanceof Stringable) { + $replacements["{{$key}}"] = (string) $val; } elseif ($val instanceof \DateTimeInterface) { $replacements["{{$key}}"] = $val->format(\DateTime::RFC3339); } elseif (\is_object($val)) { - $replacements["{{$key}}"] = '{object ' . \get_class($val) . '}'; + $replacements["{{$key}}"] = '{object ' . $val::class . '}'; } elseif (\is_resource($val)) { $replacements["{{$key}}"] = '{resource}'; } else { - $replacements["{{$key}}"] = json_encode($val); + $replacements["{{$key}}"] = (string) json_encode($val); } } @@ -133,11 +137,9 @@ private function interpolate(string $message, array $context): string /** * Normalizes data for JSON serialization. * - * @param mixed $data * @param int $depth Current recursion depth - * @return mixed */ - private function normalize($data, int $depth = 0) + private function normalize(mixed $data, int $depth = 0): mixed { $maxDepth = 9; // Similar to NormalizerFormatter's default $maxItems = 1000; // Similar to NormalizerFormatter's default @@ -175,11 +177,11 @@ private function normalize($data, int $depth = 0) return $data; } - if (method_exists($data, '__toString')) { + if ($data instanceof Stringable) { return $data->__toString(); } - if (get_class($data) === '__PHP_Incomplete_Class') { + if ($data instanceof \__PHP_Incomplete_Class) { return new \ArrayObject($data); } @@ -195,17 +197,19 @@ private function normalize($data, int $depth = 0) /** * Normalizes an exception for JSON serialization. + * + * @return array */ private function normalizeException(Throwable $e, int $depth = 0): array { $maxDepth = 9; if ($depth > $maxDepth) { - return ['class' => get_class($e), 'message' => 'Over ' . $maxDepth . ' levels deep, aborting normalization']; + return ['class' => $e::class, 'message' => 'Over ' . $maxDepth . ' levels deep, aborting normalization']; } $data = [ - 'class' => get_class($e), + 'class' => $e::class, 'message' => $e->getMessage(), 'code' => $e->getCode(), 'file' => $e->getFile() . ':' . $e->getLine(), @@ -218,11 +222,8 @@ private function normalizeException(Throwable $e, int $depth = 0): array return $data; } - /** - * @param mixed $data - */ - private function toJson($data): string + private function toJson(mixed $data): string { - return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION); + return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR); } } diff --git a/tests/StderrLoggerTest.php b/tests/StderrLoggerTest.php index 40df28a..32951e0 100644 --- a/tests/StderrLoggerTest.php +++ b/tests/StderrLoggerTest.php @@ -5,24 +5,30 @@ use Bref\Logger\StderrLogger; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Psr\Log\InvalidArgumentException; use Psr\Log\LogLevel; +use RuntimeException; +use Stringable; class StderrLoggerTest extends TestCase { /** @var resource */ private $stream; - /** @var StderrLogger */ - private $logger; + private StderrLogger $logger; public function setUp(): void { parent::setUp(); - $this->stream = fopen('php://memory', 'a+'); + $stream = fopen('php://memory', 'a+'); + if ($stream === false) { + throw new RuntimeException('Unable to open a memory stream'); + } + $this->stream = $stream; $this->logger = new StderrLogger(LogLevel::DEBUG, $this->stream); } - public function test_log_messages_format() + public function test_log_messages_format(): void { $this->logger->debug('Debug'); $this->logger->info('Info'); @@ -47,7 +53,7 @@ public function test_log_messages_format() ); } - public function test_logs_above_the_configured_log_level() + public function test_logs_above_the_configured_log_level(): void { $this->logger = new StderrLogger(LogLevel::WARNING, $this->stream); $this->logger->debug('Debug'); @@ -74,7 +80,7 @@ public function test_logs_above_the_configured_log_level() * @param mixed $contextValue */ #[DataProvider('provideInterpolationExamples')] - public function test_log_messages_are_interpolated($contextValue, string $expectedMessage) + public function test_log_messages_are_interpolated($contextValue, string $expectedMessage): void { $this->logger->info('{foo}', [ 'foo' => $contextValue, @@ -84,6 +90,9 @@ public function test_log_messages_are_interpolated($contextValue, string $expect $this->assertStringStartsWith('INFO ' . $expectedMessage . ' ', $logs); } + /** + * @return list + */ public static function provideInterpolationExamples(): array { $date = new \DateTime; @@ -103,7 +112,7 @@ public static function provideInterpolationExamples(): array ]; } - public function test_logs_with_context() + public function test_logs_with_context(): void { $this->logger->info('Test message', ['key' => 'value']); @@ -114,7 +123,7 @@ public function test_logs_with_context() ); } - public function test_multiline_message() + public function test_multiline_message(): void { $this->logger->error("Test\nmessage"); @@ -125,7 +134,7 @@ public function test_multiline_message() ); } - public function test_with_exception() + public function test_with_exception(): void { $e = new \Exception('Test error'); $this->logger->info('Test message', ['exception' => $e]); @@ -136,7 +145,7 @@ public function test_with_exception() $this->assertStringContainsString('"message":"Test error"', $logs); } - public function test_lines_start_with_the_lambda_request_id() + public function test_lines_start_with_the_lambda_request_id(): void { $_SERVER['LAMBDA_REQUEST_ID'] = '8f507cfc-8b35-4e7e-9f26-f2a3a6e7e1a2'; try { @@ -148,19 +157,46 @@ public function test_lines_start_with_the_lambda_request_id() $this->assertLogsMatch(<<<'LOGS' 8f507cfc-8b35-4e7e-9f26-f2a3a6e7e1a2 INFO Test message {"message":"Test message","level":"INFO"} +LOGS + ); + } + + public function test_unsupported_log_levels_are_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->logger->log('warn', 'Test message'); + } + + public function test_stringable_messages_are_logged(): void + { + $this->logger->info(new class implements Stringable { + public function __toString(): string + { + return 'Test message'; + } + }); + + $this->assertLogsMatch(<<<'LOGS' +INFO Test message {"message":"Test message","level":"INFO"} + LOGS ); } private function assertLogsMatch(string $expectedLog): void { - rewind($this->stream); - self::assertStringMatchesFormat($expectedLog, fread($this->stream, fstat($this->stream)['size'])); + self::assertStringMatchesFormat($expectedLog, $this->getLogs()); } private function getLogs(): string { rewind($this->stream); - return stream_get_contents($this->stream); + $logs = stream_get_contents($this->stream); + if ($logs === false) { + throw new RuntimeException('Unable to read the logs'); + } + + return $logs; } } \ No newline at end of file