From 1b27f3f8bdebf297c1ddcb919736b105adcb69fe Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Wed, 11 Jun 2025 18:43:32 +0100 Subject: [PATCH 1/6] Add a Monolog log formatter optimized for CloudWatch --- composer.json | 3 +- docs/environment/logs.mdx | 15 ++- src/Logs/CloudWatchFormatter.php | 121 +++++++++++++++++++++++++ tests/Logs/CloudWatchFormatterTest.php | 71 +++++++++++++++ 4 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 src/Logs/CloudWatchFormatter.php create mode 100644 tests/Logs/CloudWatchFormatterTest.php diff --git a/composer.json b/composer.json index 9b407da22..3557c4da3 100644 --- a/composer.json +++ b/composer.json @@ -43,7 +43,8 @@ "phpstan/phpstan": "^1.10.26", "phpunit/phpunit": "^9.6.10", "symfony/console": "^4.4|^5.0|^6.0|^7.0", - "symfony/yaml": "^4.4|^5.0|^6.0|^7.0" + "symfony/yaml": "^4.4|^5.0|^6.0|^7.0", + "monolog/monolog": "^3.9" }, "scripts": { "test": [ diff --git a/docs/environment/logs.mdx b/docs/environment/logs.mdx index 6a433562b..4098bda9a 100644 --- a/docs/environment/logs.mdx +++ b/docs/environment/logs.mdx @@ -32,8 +32,19 @@ Your application can write logs to CloudWatch: For example with [Monolog](https://github.com/Seldaek/monolog): ```php -$log = new Monolog\Logger('name'); -$log->pushHandler(new StreamHandler('php://stderr', Logger::WARNING)); +$log = new Monolog\Logger('default'); +$log->pushHandler(new StreamHandler('php://stderr', Logger::INFO)); + +$log->warning('This is a warning!'); +``` + +Bref also provides a formatter optimized for CloudWatch: + +```php +$log = new Monolog\Logger('default'); +$handler = new StreamHandler('php://stderr', Logger::INFO); +$handler->setFormatter(new \Bref\Logs\CloudWatchFormatter); +$log->pushHandler($handler); $log->warning('This is a warning!'); ``` diff --git a/src/Logs/CloudWatchFormatter.php b/src/Logs/CloudWatchFormatter.php new file mode 100644 index 000000000..fef51bc6b --- /dev/null +++ b/src/Logs/CloudWatchFormatter.php @@ -0,0 +1,121 @@ +level->name); + // Make sure everything is kept on one line to count as one record + $message = str_replace(["\r\n", "\r", "\n"], ' ', $record->message); + $json = $this->toJson($this->normalizeRecord($record), true); + + return "$level\t$message\t$json\n"; + } + + /** + * @inheritDoc + */ + public function formatBatch(array $records): string + { + return implode('', array_map(fn(LogRecord $record) => $this->format($record), $records)); + } + + /** + * @return array + */ + protected function normalizeRecord(LogRecord $record): array + { + $data = [ + 'message' => $record->message, + 'level' => strtoupper($record->level->name), + ]; + $context = $record->context; + // Move any exception to the root + $exception = $context['exception'] ?? null; + if ($exception instanceof Throwable) { + $data['exception'] = $exception; + unset($context['exception']); + } + if ($context !== []) { + $data['context'] = $context; + } + if ($record->extra !== []) { + $data['extra'] = $record->extra; + } + + return $this->normalize($data); + } + + /** + * @return null|scalar|array|object + */ + protected function normalize(mixed $data, int $depth = 0): mixed + { + if ($depth > $this->maxNormalizeDepth) { + return 'Over '.$this->maxNormalizeDepth.' levels deep, aborting normalization'; + } + + if (is_array($data)) { + $normalized = []; + + $count = 1; + foreach ($data as $key => $value) { + if ($count++ > $this->maxNormalizeItemCount) { + $normalized['...'] = 'Over '.$this->maxNormalizeItemCount.' items ('. count($data).' total), aborting normalization'; + break; + } + + $normalized[$key] = $this->normalize($value, $depth + 1); + } + + return $normalized; + } + + if (is_object($data)) { + if ($data instanceof DateTimeInterface) { + return $this->formatDate($data); + } + + if ($data instanceof Throwable) { + return $this->normalizeException($data, $depth); + } + + // if the object has specific json serializability we want to make sure we skip the __toString treatment below + if ($data instanceof JsonSerializable) { + return $data; + } + + if ($data instanceof Stringable) { + return $data->__toString(); + } + + if (get_class($data) === '__PHP_Incomplete_Class') { + return new ArrayObject($data); + } + + return $data; + } + + if (is_resource($data)) { + return parent::normalize($data); + } + + return $data; + } +} diff --git a/tests/Logs/CloudWatchFormatterTest.php b/tests/Logs/CloudWatchFormatterTest.php new file mode 100644 index 000000000..27db50bd7 --- /dev/null +++ b/tests/Logs/CloudWatchFormatterTest.php @@ -0,0 +1,71 @@ +logger = new Logger('default'); + $this->logs = fopen('php://memory', 'wb+'); + $handler = new StreamHandler($this->logs); + $handler->setFormatter(new CloudWatchFormatter); + $this->logger->pushHandler($handler); + } + + public function test simple message(): void + { + $this->logger->info('Test message'); + + $this->assertEquals("INFO\tTest message\t" . json_encode([ + 'message' => 'Test message', + 'level' => 'INFO', + ], JSON_THROW_ON_ERROR) . "\n", $this->getLogs()); + } + + public function test with context(): void + { + $this->logger->info('Test message', ['key' => 'value']); + + $this->assertEquals("INFO\tTest message\t" . json_encode([ + 'message' => 'Test message', + 'level' => 'INFO', + 'context' => ['key' => 'value'], + ], JSON_THROW_ON_ERROR) . "\n", $this->getLogs()); + } + + public function test multiline message(): void + { + $this->logger->error("Test\nmessage"); + + $this->assertEquals("ERROR\tTest message\t" . json_encode([ + 'message' => "Test\nmessage", + 'level' => 'ERROR', + ], JSON_THROW_ON_ERROR) . "\n", $this->getLogs()); + } + + public function test with exception(): void + { + $e = new Exception('Test error'); + $this->logger->info('Test message', ['exception' => $e]); + + $this->assertStringStartsWith('INFO Test message {"message":"Test message","level":"INFO","exception":{"class":"Exception","message":"Test error","code":0,"file":', $this->getLogs()); + } + + private function getLogs(): string + { + rewind($this->logs); + return stream_get_contents($this->logs); + } +} From 878ae386bf25a627f89c392abaa0b04d9710344c Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Fri, 13 Jun 2025 21:43:46 +0200 Subject: [PATCH 2/6] Widen dependency constraint to support 8.0 builds --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 3557c4da3..3d68ecea6 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,7 @@ "phpunit/phpunit": "^9.6.10", "symfony/console": "^4.4|^5.0|^6.0|^7.0", "symfony/yaml": "^4.4|^5.0|^6.0|^7.0", - "monolog/monolog": "^3.9" + "monolog/monolog": "^2.10 || ^3.9" }, "scripts": { "test": [ From c86914726f6a5a1717eb3c10862f1fa5d0e6dce1 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Fri, 13 Jun 2025 21:46:02 +0200 Subject: [PATCH 3/6] Fix CS --- src/Logs/CloudWatchFormatter.php | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/Logs/CloudWatchFormatter.php b/src/Logs/CloudWatchFormatter.php index fef51bc6b..337869875 100644 --- a/src/Logs/CloudWatchFormatter.php +++ b/src/Logs/CloudWatchFormatter.php @@ -15,9 +15,6 @@ */ class CloudWatchFormatter extends NormalizerFormatter { - /** - * @inheritDoc - */ public function format(LogRecord $record): string { $level = strtoupper($record->level->name); @@ -28,12 +25,9 @@ public function format(LogRecord $record): string return "$level\t$message\t$json\n"; } - /** - * @inheritDoc - */ public function formatBatch(array $records): string { - return implode('', array_map(fn(LogRecord $record) => $this->format($record), $records)); + return implode('', array_map(fn (LogRecord $record) => $this->format($record), $records)); } /** @@ -63,12 +57,12 @@ protected function normalizeRecord(LogRecord $record): array } /** - * @return null|scalar|array|object + * @return null|scalar|array|object */ protected function normalize(mixed $data, int $depth = 0): mixed { if ($depth > $this->maxNormalizeDepth) { - return 'Over '.$this->maxNormalizeDepth.' levels deep, aborting normalization'; + return 'Over ' . $this->maxNormalizeDepth . ' levels deep, aborting normalization'; } if (is_array($data)) { @@ -77,7 +71,7 @@ protected function normalize(mixed $data, int $depth = 0): mixed $count = 1; foreach ($data as $key => $value) { if ($count++ > $this->maxNormalizeItemCount) { - $normalized['...'] = 'Over '.$this->maxNormalizeItemCount.' items ('. count($data).' total), aborting normalization'; + $normalized['...'] = 'Over ' . $this->maxNormalizeItemCount . ' items (' . count($data) . ' total), aborting normalization'; break; } From 5d5c12926e9b53189b8d8a517b42af7564393d7a Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Fri, 13 Jun 2025 21:53:05 +0200 Subject: [PATCH 4/6] Fix CS --- src/Logs/CloudWatchFormatter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Logs/CloudWatchFormatter.php b/src/Logs/CloudWatchFormatter.php index 337869875..24476dd1a 100644 --- a/src/Logs/CloudWatchFormatter.php +++ b/src/Logs/CloudWatchFormatter.php @@ -57,7 +57,7 @@ protected function normalizeRecord(LogRecord $record): array } /** - * @return null|scalar|array|object + * @return scalar|array|object|null */ protected function normalize(mixed $data, int $depth = 0): mixed { From ad36aadaca520ab50a13ff592cdecd44f63467fe Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Fri, 13 Jun 2025 23:03:28 +0200 Subject: [PATCH 5/6] Move the Monolog formatter class to a separate repository --- composer.json | 3 +- docs/environment/logs.mdx | 83 ++++++++++++++---- docs/laravel/getting-started.mdx | 23 +++++ docs/symfony/getting-started.mdx | 23 +++++ src/Logs/CloudWatchFormatter.php | 115 ------------------------- tests/Logs/CloudWatchFormatterTest.php | 71 --------------- 6 files changed, 111 insertions(+), 207 deletions(-) delete mode 100644 src/Logs/CloudWatchFormatter.php delete mode 100644 tests/Logs/CloudWatchFormatterTest.php diff --git a/composer.json b/composer.json index 3d68ecea6..9b407da22 100644 --- a/composer.json +++ b/composer.json @@ -43,8 +43,7 @@ "phpstan/phpstan": "^1.10.26", "phpunit/phpunit": "^9.6.10", "symfony/console": "^4.4|^5.0|^6.0|^7.0", - "symfony/yaml": "^4.4|^5.0|^6.0|^7.0", - "monolog/monolog": "^2.10 || ^3.9" + "symfony/yaml": "^4.4|^5.0|^6.0|^7.0" }, "scripts": { "test": [ diff --git a/docs/environment/logs.mdx b/docs/environment/logs.mdx index 4098bda9a..e669e4fdf 100644 --- a/docs/environment/logs.mdx +++ b/docs/environment/logs.mdx @@ -1,4 +1,6 @@ import { NextSeo } from 'next-seo'; +import { Tab, Tabs } from 'nextra/components'; +import { Callout } from 'nextra/components'; @@ -29,33 +31,76 @@ Your application can write logs to CloudWatch: - [With the PHP-FPM runtime for web apps](../runtimes/fpm-runtime.mdx): write logs to `stderr` - [With the runtime for event-driven functions](../runtimes/function.mdx): write logs to `stdout` (using `echo` for example) or `stderr` -For example with [Monolog](https://github.com/Seldaek/monolog): +All logs written to `stdout` or `stderr` are automatically be sent to CloudWatch asynchronously by AWS Lambda, **without performance impact on applications**. -```php -$log = new Monolog\Logger('default'); -$log->pushHandler(new StreamHandler('php://stderr', Logger::INFO)); + + + If you use Laravel, Bref will automatically configure Laravel to log to CloudWatch via `stderr`. You don't have to do anything. -$log->warning('This is a warning!'); -``` + It is recommended you enable Bref's logs formatter optimized for CloudWatch: -Bref also provides a formatter optimized for CloudWatch: + ```yaml filename="serverless.yml" + provider: + environment: + LOG_STDERR_FORMATTER: Bref\Monolog\CloudWatchFormatter + ``` -```php -$log = new Monolog\Logger('default'); -$handler = new StreamHandler('php://stderr', Logger::INFO); -$handler->setFormatter(new \Bref\Logs\CloudWatchFormatter); -$log->pushHandler($handler); + + This formatter will be enabled by default in Bref v3. + -$log->warning('This is a warning!'); -``` + With this formatter, logs will contain structured data that can be filtered in CloudWatch Logs Insights. For example, you can filter by log level, exception class, or anything in the [Laravel Context](https://laravel.com/docs/12.x/context). + + + If you use Symfony, Bref will automatically configure Symfony to log to CloudWatch via `stderr`. You don't have to do anything. -For simple needs, you can replace Monolog with [Bref's logger](https://github.com/brefphp/logger), a PSR-3 logger designed for AWS Lambda: + It is recommended you enable Bref's logs formatter optimized for CloudWatch: -```php -$log = new \Bref\Logger\StderrLogger(); + ```yaml filename="config/packages/prod/monolog.yaml" + monolog: + handlers: + file: + type: stream + level: info + formatter: 'Bref\Monolog\CloudWatchFormatter' + ``` -$log->warning('This is a warning!'); -``` + + This formatter will be enabled by default in Bref v3. + + + With this formatter, logs will contain structured data that can be filtered in CloudWatch Logs Insights. For example, you can filter by log level or exception class. + + + You can use [Monolog](https://github.com/Seldaek/monolog) to write logs to CloudWatch via `stderr`: + + ```php + $log = new Monolog\Logger('default'); + $log->pushHandler(new StreamHandler('php://stderr', Logger::INFO)); + + $log->warning('This is a warning!'); + ``` + + Bref provides a formatter optimized for CloudWatch, it is highly recommended to use it: + + ```php + $log = new Monolog\Logger('default'); + $handler = new StreamHandler('php://stderr', Logger::INFO); + $handler->setFormatter(new Bref\Logs\CloudWatchFormatter); + $log->pushHandler($handler); + + $log->warning('This is a warning!'); + ``` + + For simple needs, you can replace Monolog with [Bref's logger](https://github.com/brefphp/logger), a PSR-3 logger designed for AWS Lambda: + + ```php + $log = new \Bref\Logger\StderrLogger(); + + $log->warning('This is a warning!'); + ``` + + ### Reading logs diff --git a/docs/laravel/getting-started.mdx b/docs/laravel/getting-started.mdx index 0d22e2459..02e001824 100644 --- a/docs/laravel/getting-started.mdx +++ b/docs/laravel/getting-started.mdx @@ -80,6 +80,29 @@ php artisan config:clear && php artisan config:cache In case your application is showing a blank page after being deployed, [have a look at the logs](../environment/logs.md). +## Logs + +Thanks to the Bref integration, Laravel will automatically log to CloudWatch via `stderr`. You don't have to do anything. + +You can learn more about logs in the [Logs guide](../environment/logs.md). + +It is recommended you enable Bref's logs formatter optimized for CloudWatch: + +```yaml filename="config/packages/prod/monolog.yaml" +monolog: + handlers: + file: + type: stream + level: info + formatter: 'Bref\Monolog\CloudWatchFormatter' +``` + + + This formatter will be enabled by default in Bref v3. + + +With this formatter, logs will contain structured data that can be filtered in CloudWatch Logs Insights. For example, you can filter by log level, exception class, or anything in the [Laravel Context](https://laravel.com/docs/12.x/context). + ## Website assets Have a look at the [Website guide](../use-cases/websites.mdx) to learn how to deploy a website with assets. diff --git a/docs/symfony/getting-started.mdx b/docs/symfony/getting-started.mdx index 39bcc9b96..e2ee46360 100644 --- a/docs/symfony/getting-started.mdx +++ b/docs/symfony/getting-started.mdx @@ -117,6 +117,29 @@ class Kernel extends BrefKernel In case your application is showing a blank page after being deployed, [have a look at the logs](../environment/logs.md). +## Logs + +Thanks to the Bref integration, Symfony will automatically log to CloudWatch via `stderr`. You don't have to do anything. + +You can learn more about logs in the [Logs guide](../environment/logs.md). + +It is recommended you enable Bref's logs formatter optimized for CloudWatch: + +```yaml filename="config/packages/prod/monolog.yaml" +monolog: + handlers: + file: + type: stream + level: info + formatter: 'Bref\Monolog\CloudWatchFormatter' +``` + + + This formatter will be enabled by default in Bref v3. + + +With this formatter, logs will contain structured data that can be filtered in CloudWatch Logs Insights. For example, you can filter by log level or exception class. + ## Website assets Have a look at the [Website guide](../use-cases/websites.mdx) to learn how to deploy a website with assets. diff --git a/src/Logs/CloudWatchFormatter.php b/src/Logs/CloudWatchFormatter.php deleted file mode 100644 index 24476dd1a..000000000 --- a/src/Logs/CloudWatchFormatter.php +++ /dev/null @@ -1,115 +0,0 @@ -level->name); - // Make sure everything is kept on one line to count as one record - $message = str_replace(["\r\n", "\r", "\n"], ' ', $record->message); - $json = $this->toJson($this->normalizeRecord($record), true); - - return "$level\t$message\t$json\n"; - } - - public function formatBatch(array $records): string - { - return implode('', array_map(fn (LogRecord $record) => $this->format($record), $records)); - } - - /** - * @return array - */ - protected function normalizeRecord(LogRecord $record): array - { - $data = [ - 'message' => $record->message, - 'level' => strtoupper($record->level->name), - ]; - $context = $record->context; - // Move any exception to the root - $exception = $context['exception'] ?? null; - if ($exception instanceof Throwable) { - $data['exception'] = $exception; - unset($context['exception']); - } - if ($context !== []) { - $data['context'] = $context; - } - if ($record->extra !== []) { - $data['extra'] = $record->extra; - } - - return $this->normalize($data); - } - - /** - * @return scalar|array|object|null - */ - protected function normalize(mixed $data, int $depth = 0): mixed - { - if ($depth > $this->maxNormalizeDepth) { - return 'Over ' . $this->maxNormalizeDepth . ' levels deep, aborting normalization'; - } - - if (is_array($data)) { - $normalized = []; - - $count = 1; - foreach ($data as $key => $value) { - if ($count++ > $this->maxNormalizeItemCount) { - $normalized['...'] = 'Over ' . $this->maxNormalizeItemCount . ' items (' . count($data) . ' total), aborting normalization'; - break; - } - - $normalized[$key] = $this->normalize($value, $depth + 1); - } - - return $normalized; - } - - if (is_object($data)) { - if ($data instanceof DateTimeInterface) { - return $this->formatDate($data); - } - - if ($data instanceof Throwable) { - return $this->normalizeException($data, $depth); - } - - // if the object has specific json serializability we want to make sure we skip the __toString treatment below - if ($data instanceof JsonSerializable) { - return $data; - } - - if ($data instanceof Stringable) { - return $data->__toString(); - } - - if (get_class($data) === '__PHP_Incomplete_Class') { - return new ArrayObject($data); - } - - return $data; - } - - if (is_resource($data)) { - return parent::normalize($data); - } - - return $data; - } -} diff --git a/tests/Logs/CloudWatchFormatterTest.php b/tests/Logs/CloudWatchFormatterTest.php deleted file mode 100644 index 27db50bd7..000000000 --- a/tests/Logs/CloudWatchFormatterTest.php +++ /dev/null @@ -1,71 +0,0 @@ -logger = new Logger('default'); - $this->logs = fopen('php://memory', 'wb+'); - $handler = new StreamHandler($this->logs); - $handler->setFormatter(new CloudWatchFormatter); - $this->logger->pushHandler($handler); - } - - public function test simple message(): void - { - $this->logger->info('Test message'); - - $this->assertEquals("INFO\tTest message\t" . json_encode([ - 'message' => 'Test message', - 'level' => 'INFO', - ], JSON_THROW_ON_ERROR) . "\n", $this->getLogs()); - } - - public function test with context(): void - { - $this->logger->info('Test message', ['key' => 'value']); - - $this->assertEquals("INFO\tTest message\t" . json_encode([ - 'message' => 'Test message', - 'level' => 'INFO', - 'context' => ['key' => 'value'], - ], JSON_THROW_ON_ERROR) . "\n", $this->getLogs()); - } - - public function test multiline message(): void - { - $this->logger->error("Test\nmessage"); - - $this->assertEquals("ERROR\tTest message\t" . json_encode([ - 'message' => "Test\nmessage", - 'level' => 'ERROR', - ], JSON_THROW_ON_ERROR) . "\n", $this->getLogs()); - } - - public function test with exception(): void - { - $e = new Exception('Test error'); - $this->logger->info('Test message', ['exception' => $e]); - - $this->assertStringStartsWith('INFO Test message {"message":"Test message","level":"INFO","exception":{"class":"Exception","message":"Test error","code":0,"file":', $this->getLogs()); - } - - private function getLogs(): string - { - rewind($this->logs); - return stream_get_contents($this->logs); - } -} From b7f08f8aaca6b4f6a290ebdf0d7276b2c6c54c37 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Sat, 14 Jun 2025 13:23:30 +0200 Subject: [PATCH 6/6] Fix Laravel docs --- docs/laravel/getting-started.mdx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/laravel/getting-started.mdx b/docs/laravel/getting-started.mdx index 02e001824..4dc42c545 100644 --- a/docs/laravel/getting-started.mdx +++ b/docs/laravel/getting-started.mdx @@ -88,13 +88,10 @@ You can learn more about logs in the [Logs guide](../environment/logs.md). It is recommended you enable Bref's logs formatter optimized for CloudWatch: -```yaml filename="config/packages/prod/monolog.yaml" -monolog: - handlers: - file: - type: stream - level: info - formatter: 'Bref\Monolog\CloudWatchFormatter' +```yaml filename="serverless.yml" +provider: + environment: + LOG_STDERR_FORMATTER: Bref\Monolog\CloudWatchFormatter ```