Skip to content

Commit 289bb85

Browse files
committed
Add the CloudWatchFormatter
1 parent 4d76530 commit 289bb85

7 files changed

Lines changed: 202 additions & 14 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
/composer.phar
33
/composer.lock
44
/.phpunit.result.cache
5+
/.phpunit.cache

composer.json

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,26 @@
11
{
2-
"name": "mnapoli/myproject",
3-
"description": "Give it a nice description!",
4-
"keywords": [],
2+
"name": "bref/monolog-bridge",
3+
"description": "Monolog formatter optimized for AWS Lambda and CloudWatch",
4+
"keywords": ["bref", "monolog", "aws", "lambda", "cloudwatch"],
55
"license": "MIT",
66
"type": "library",
77
"autoload": {
88
"psr-4": {
9-
"MyProject\\": "src/"
9+
"Bref\\Monolog\\": "src/"
1010
}
1111
},
1212
"autoload-dev": {
1313
"psr-4": {
14-
"MyProject\\Test\\": "tests/"
14+
"Bref\\Monolog\\Test\\": "tests/"
1515
}
1616
},
1717
"require": {
18-
"php": ">=8.0"
18+
"php": ">=8.1",
19+
"monolog/monolog": "^3"
1920
},
2021
"require-dev": {
21-
"phpunit/phpunit": "^9.0",
22-
"mnapoli/hard-mode": "^0.3.0",
22+
"phpunit/phpunit": "^11",
23+
"mnapoli/hard-mode": "^0.3",
2324
"phpstan/phpstan": "^1"
2425
},
2526
"config": {

phpunit.xml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
<?xml version="1.0" encoding="utf-8"?>
2-
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" colors="true" bootstrap="./vendor/autoload.php" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd">
3-
<coverage processUncoveredFiles="true">
4-
<include>
5-
<directory suffix=".php">src</directory>
6-
</include>
7-
</coverage>
2+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" colors="true" bootstrap="./vendor/autoload.php" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd" cacheDirectory=".phpunit.cache">
83
<testsuites>
94
<testsuite name="Test suite">
105
<directory>./tests/</directory>
116
</testsuite>
127
</testsuites>
8+
<source>
9+
<include>
10+
<directory suffix=".php">src</directory>
11+
</include>
12+
</source>
1313
</phpunit>

src/.gitkeep

Whitespace-only changes.

src/CloudWatchFormatter.php

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Bref\Monolog;
4+
5+
use ArrayObject;
6+
use DateTimeInterface;
7+
use JsonSerializable;
8+
use Monolog\Formatter\NormalizerFormatter;
9+
use Monolog\LogRecord;
10+
use Stringable;
11+
use Throwable;
12+
13+
/**
14+
* Monolog formatter optimized for CloudWatch logs.
15+
*/
16+
class CloudWatchFormatter extends NormalizerFormatter
17+
{
18+
public function format(LogRecord $record): string
19+
{
20+
$level = strtoupper($record->level->name);
21+
// Make sure everything is kept on one line to count as one record
22+
$message = str_replace(["\r\n", "\r", "\n"], ' ', $record->message);
23+
$json = $this->toJson($this->normalizeRecord($record), true);
24+
25+
return "$level\t$message\t$json\n";
26+
}
27+
28+
public function formatBatch(array $records): string
29+
{
30+
return implode('', array_map(fn (LogRecord $record) => $this->format($record), $records));
31+
}
32+
33+
/**
34+
* @return array<array|bool|float|int|\stdClass|string|null>
35+
*/
36+
protected function normalizeRecord(LogRecord $record): array
37+
{
38+
$data = [
39+
'message' => $record->message,
40+
'level' => strtoupper($record->level->name),
41+
];
42+
$context = $record->context;
43+
// Move any exception to the root
44+
$exception = $context['exception'] ?? null;
45+
if ($exception instanceof Throwable) {
46+
$data['exception'] = $exception;
47+
unset($context['exception']);
48+
}
49+
if ($context !== []) {
50+
$data['context'] = $context;
51+
}
52+
if ($record->extra !== []) {
53+
$data['extra'] = $record->extra;
54+
}
55+
56+
return $this->normalize($data);
57+
}
58+
59+
/**
60+
* @return scalar|array<array|scalar|object|null>|object|null
61+
*/
62+
protected function normalize(mixed $data, int $depth = 0): mixed
63+
{
64+
if ($depth > $this->maxNormalizeDepth) {
65+
return 'Over ' . $this->maxNormalizeDepth . ' levels deep, aborting normalization';
66+
}
67+
68+
if (is_array($data)) {
69+
$normalized = [];
70+
71+
$count = 1;
72+
foreach ($data as $key => $value) {
73+
if ($count++ > $this->maxNormalizeItemCount) {
74+
$normalized['...'] = 'Over ' . $this->maxNormalizeItemCount . ' items (' . count($data) . ' total), aborting normalization';
75+
break;
76+
}
77+
78+
$normalized[$key] = $this->normalize($value, $depth + 1);
79+
}
80+
81+
return $normalized;
82+
}
83+
84+
if (is_object($data)) {
85+
if ($data instanceof DateTimeInterface) {
86+
return $this->formatDate($data);
87+
}
88+
89+
if ($data instanceof Throwable) {
90+
return $this->normalizeException($data, $depth);
91+
}
92+
93+
// if the object has specific json serializability we want to make sure we skip the __toString treatment below
94+
if ($data instanceof JsonSerializable) {
95+
return $data;
96+
}
97+
98+
if ($data instanceof Stringable) {
99+
return $data->__toString();
100+
}
101+
102+
if (get_class($data) === '__PHP_Incomplete_Class') {
103+
return new ArrayObject($data);
104+
}
105+
106+
return $data;
107+
}
108+
109+
if (is_resource($data)) {
110+
return parent::normalize($data);
111+
}
112+
113+
return $data;
114+
}
115+
}

tests/.gitkeep

Whitespace-only changes.

tests/CloudWatchFormatterTest.php

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Bref\Monolog\Test;
4+
5+
use Bref\Monolog\CloudWatchFormatter;
6+
use Exception;
7+
use Monolog\Handler\StreamHandler;
8+
use Monolog\Logger;
9+
use PHPUnit\Framework\TestCase;
10+
11+
class CloudWatchFormatterTest extends TestCase
12+
{
13+
private Logger $logger;
14+
/** @var resource */
15+
private $logs;
16+
17+
public function setUp(): void
18+
{
19+
parent::setUp();
20+
$this->logger = new Logger('default');
21+
$this->logs = fopen('php://memory', 'wb+');
22+
$handler = new StreamHandler($this->logs);
23+
$handler->setFormatter(new CloudWatchFormatter);
24+
$this->logger->pushHandler($handler);
25+
}
26+
27+
public function test simple message(): void
28+
{
29+
$this->logger->info('Test message');
30+
31+
$this->assertEquals("INFO\tTest message\t" . json_encode([
32+
'message' => 'Test message',
33+
'level' => 'INFO',
34+
], JSON_THROW_ON_ERROR) . "\n", $this->getLogs());
35+
}
36+
37+
public function test with context(): void
38+
{
39+
$this->logger->info('Test message', ['key' => 'value']);
40+
41+
$this->assertEquals("INFO\tTest message\t" . json_encode([
42+
'message' => 'Test message',
43+
'level' => 'INFO',
44+
'context' => ['key' => 'value'],
45+
], JSON_THROW_ON_ERROR) . "\n", $this->getLogs());
46+
}
47+
48+
public function test multiline message(): void
49+
{
50+
$this->logger->error("Test\nmessage");
51+
52+
$this->assertEquals("ERROR\tTest message\t" . json_encode([
53+
'message' => "Test\nmessage",
54+
'level' => 'ERROR',
55+
], JSON_THROW_ON_ERROR) . "\n", $this->getLogs());
56+
}
57+
58+
public function test with exception(): void
59+
{
60+
$e = new Exception('Test error');
61+
$this->logger->info('Test message', ['exception' => $e]);
62+
63+
$this->assertStringStartsWith('INFO Test message {"message":"Test message","level":"INFO","exception":{"class":"Exception","message":"Test error","code":0,"file":', $this->getLogs());
64+
}
65+
66+
private function getLogs(): string
67+
{
68+
rewind($this->logs);
69+
return stream_get_contents($this->logs);
70+
}
71+
}

0 commit comments

Comments
 (0)