Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 9 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,48 +14,48 @@ 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

cs:
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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
6 changes: 3 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion phpstan.neon
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
parameters:
level: 5
level: max
paths:
- src
- tests
91 changes: 46 additions & 45 deletions src/StderrLogger.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
namespace Bref\Logger;

use Psr\Log\AbstractLogger;
use Psr\Log\InvalidArgumentException;
use Psr\Log\LogLevel;
use Stringable;
use Throwable;

/**
Expand All @@ -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<mixed> $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);
Expand Down Expand Up @@ -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<mixed> $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);
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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);
}

Expand All @@ -195,17 +197,19 @@ private function normalize($data, int $depth = 0)

/**
* Normalizes an exception for JSON serialization.
*
* @return array<string, mixed>
*/
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(),
Expand All @@ -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);
}
}
Loading
Loading