Skip to content
Open
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
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,41 @@ $processor = new DataDogProcessor([
],
]);
```

## Tracking the current log context

Sometimes you want context to be added automatically to all log entries, for example the current
request id ("current plate") for the whole request scope.

This can be done with `Stefna\Logger\LogContext` together with the `LogContextProcessor`. The
processor needs to be registered once:

```php
<?php declare(strict_types=1);

\Stefna\Logger\Logger::getManager()->pushProcessor(new \Stefna\Logger\Processor\LogContextProcessor());
```

Set a value for the current request scope:

```php
<?php declare(strict_types=1);

\Stefna\Logger\LogContext::set('requestId', $request->getId());
```

Each key holds a single value. Use `unset()` to remove a key again:

```php
<?php declare(strict_types=1);

\Stefna\Logger\LogContext::set('requestId', $request->getId());
\Stefna\Logger\LogContext::set('requestId', $subRequest->getId());
// all log entries in the sub-scope will include the sub request id
\Stefna\Logger\LogContext::unset('requestId');
// the request id is no longer tracked
```

Values tracked with `set()` are merged into the `context` of every log record processed by the
`LogContextProcessor`. Keys set explicitly on a log call will override the tracked value for that
call. Call `LogContext::clear()` at the end of the request or job to reset all state.
43 changes: 43 additions & 0 deletions src/LogContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php declare(strict_types=1);

namespace Stefna\Logger;

/**
* Static context facade for adding context to all log entries.
*
* Every key holds a single value for the current request scope. The last
* `set()` for a key can be undone once with `undo()`.
*/
final class LogContext
{
/** @var array<string, mixed> */
private static array $context = [];

public static function set(string $key, mixed $value): void
{
self::$context[$key] = $value;
}

public static function unset(string $key): void
{
unset(self::$context[$key]);
}

public static function has(string $key): bool
{
return array_key_exists($key, self::$context);
}

/**
* @return array<string, mixed>
*/
public static function getAll(): array
{
return self::$context;
}

public static function clear(): void
{
self::$context = [];
}
}
26 changes: 26 additions & 0 deletions src/Processor/LogContextProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php declare(strict_types=1);

namespace Stefna\Logger\Processor;

use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
use Stefna\Logger\LogContext;

/**
* Processor that merges the current {@see LogContext} into the context of
* every log record processed.
*/
final class LogContextProcessor implements ProcessorInterface
{
public function __invoke(LogRecord $record): LogRecord
{
$context = LogContext::getAll();
if ($context === []) {
return $record;
}

return $record->with(
context: array_merge($context, $record->context),
);
}
}
96 changes: 96 additions & 0 deletions tests/LogContextTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php declare(strict_types=1);

namespace Stefna\Logger;

use PHPUnit\Framework\TestCase;

final class LogContextTest extends TestCase
{
protected function setUp(): void
{
LogContext::clear();
}

protected function tearDown(): void
{
LogContext::clear();
}

public function testSetStoresValue(): void
{
LogContext::set('plate', 'beef');

$this->assertTrue(LogContext::has('plate'));
$this->assertSame(['plate' => 'beef'], LogContext::getAll());
}

public function testSetReplacesPreviousValue(): void
{
LogContext::set('plate', 'beef');
LogContext::set('plate', 'pork');

$this->assertSame(['plate' => 'pork'], LogContext::getAll());
}

public function testHas(): void
{
$this->assertFalse(LogContext::has('plate'));

LogContext::set('plate', 'beef');

$this->assertTrue(LogContext::has('plate'));
}

public function testUnsetRemovesKey(): void
{
LogContext::set('plate', 'beef');

LogContext::unset('plate');

$this->assertFalse(LogContext::has('plate'));
$this->assertSame([], LogContext::getAll());
}

public function testUnsetUnknownKeyIsNoop(): void
{
LogContext::set('plate', 'beef');

LogContext::unset('unknown');

$this->assertSame(['plate' => 'beef'], LogContext::getAll());
}

public function testUnsetOnlyAffectsSingleKey(): void
{
LogContext::set('plate', 'beef');
LogContext::set('scope', 'import');

LogContext::unset('plate');

$this->assertFalse(LogContext::has('plate'));
$this->assertSame(['scope' => 'import'], LogContext::getAll());
}

public function testClearResetsEverything(): void
{
LogContext::set('plate', 'beef');
LogContext::set('scope', 'import');

LogContext::clear();

$this->assertSame([], LogContext::getAll());
$this->assertFalse(LogContext::has('plate'));
$this->assertFalse(LogContext::has('scope'));
}

public function testGetAllReturnsAllValues(): void
{
LogContext::set('plate', 'beef');
LogContext::set('scope', 'import');

$this->assertSame([
'plate' => 'beef',
'scope' => 'import',
], LogContext::getAll());
}
}
83 changes: 83 additions & 0 deletions tests/Processor/LogContextProcessorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php declare(strict_types=1);

namespace Stefna\Logger\Processor;

use Monolog\Level;
use Monolog\LogRecord;
use PHPUnit\Framework\TestCase;
use Stefna\Logger\LogContext;

final class LogContextProcessorTest extends TestCase
{
protected function setUp(): void
{
LogContext::clear();
}

protected function tearDown(): void
{
LogContext::clear();
}

public function testSetValuesAreMergedIntoRecordContext(): void
{
LogContext::set('plate', 'beef');
$processor = new LogContextProcessor();

$newRecord = $processor($this->createLogRecord());

$this->assertSame([
'plate' => 'beef',
], $newRecord->context);
}

public function testSetValueAppearsInRecordContext(): void
{
LogContext::set('scope', 'import');
$processor = new LogContextProcessor();

$newRecord = $processor($this->createLogRecord());

$this->assertSame([
'scope' => 'import',
], $newRecord->context);
}

public function testExplicitRecordContextOverridesTrackedValue(): void
{
LogContext::set('plate', 'beef');
$processor = new LogContextProcessor();

$newRecord = $processor($this->createLogRecord([
'plate' => 'pork',
]));

$this->assertSame([
'plate' => 'pork',
], $newRecord->context);
}

public function testReturnsSameRecordWhenNoContextIsSet(): void
{
$processor = new LogContextProcessor();

$record = $this->createLogRecord();

$this->assertSame($record, $processor($record));
}

/**
* @param array<mixed> $context
*/
private function createLogRecord(array $context = []): LogRecord
{
return new LogRecord(
new \DateTimeImmutable(),
'test',
Level::Info,
'test',
$context,
);
}
}

Loading