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
72 changes: 72 additions & 0 deletions src/Logs/Monolog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,75 @@ $logger = new \Monolog\Logger(
);
$logger->info('hello world');
```

### Attributes Mode

This OpenTelemetry handler will convert any `context` array or `extras` array in the `Monolog\LogRecord` to `OpenTelemetry\API\Logs\LogRecord`
attributes. There are two options for handling conflicts between the classes.

_Note that exceptions have special handling in both the PSR-3 spec and the OpenTelemetry spec. If a PHP `Throwable` is included in the `context`_
_array with a key of `exception`, it will be added as `exception.` attributes to the OpenTelemetry Log Record._

By default, the attribute keys will be `context` and `extras` with JSON encoded arrays, along with `context.` and `extras.` prefixed keys with the
individual array entries, which will also be JSON encoded if they are not scalar values. Example:

```php
new Monolog\LogRecord(
...,
context: [
'foo' => 'bar',
'obj' => new stdClass(),
],
extras: [
'foo' => 'bar',
'baz' => 'bat',
]
);

/**
* becomes:
*
* OpenTelemetry\API\Logs\LogRecord (
* ...,
* attributes => array (
* context => '{"foo":"bar","obj":{}}'
* context.foo => 'bar'
* context.obj => '{}'
* extras => '{"foo":"bar","baz":"bat"}'
* extras.foo => 'bar'
* extras.baz => 'bat'
* )
* )
*/
```

Alternatively, if your `context` and `extras` keys do not conflict with OpenTelemetry Semantic Conventions for Attribute keys, _and_ all your values
are scalar, you can set `OTEL_PHP_MONOLOG_ATTRIB_MODE=otel` and they will be sent directly as Attributes. Example:

```php
new Monolog\LogRecord(
...,
context: [
'myapp.data.foo' => 'bar',
'myapp.data.baz' => 'bat',
],
extras: [
'server.address' => 'example.com',
'server.port' => 80,
]
);

/**
* becomes:
*
* OpenTelemetry\API\Logs\LogRecord (
* ...,
* attributes => array (
* myapp.data.foo => 'bar'
* myapp.data.baz => 'bat'
* server.address => 'example.com'
* server.port => 80
* )
* )
*/
```
63 changes: 58 additions & 5 deletions src/Logs/Monolog/src/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,25 @@
use Monolog\Formatter\FormatterInterface;
use Monolog\Formatter\NormalizerFormatter;
use Monolog\Handler\AbstractProcessingHandler;
use OpenTelemetry\API\Instrumentation\ConfigurationResolver;
use OpenTelemetry\API\Logs as API;
use OpenTelemetry\SDK\Common\Exception\StackTraceFormatter;
use OpenTelemetry\SemConv\Attributes\ExceptionAttributes;

use Throwable;

class Handler extends AbstractProcessingHandler
{
public const OTEL_PHP_MONOLOG_ATTRIB_MODE = 'OTEL_PHP_MONOLOG_ATTRIB_MODE';
public const MODE_PSR3 = 'psr3';
public const MODE_OTEL = 'otel';
private const MODES = [
self::MODE_PSR3,
self::MODE_OTEL,
];
public const DEFAULT_MODE = self::MODE_PSR3;
private static string $mode;

/** @var API\LoggerInterface[] */
private array $loggers = [];
private API\LoggerProviderInterface $loggerProvider;
Expand All @@ -23,6 +38,7 @@ public function __construct(API\LoggerProviderInterface $loggerProvider, $level,
{
parent::__construct($level, $bubble);
$this->loggerProvider = $loggerProvider;
self::$mode = self::getMode();
}

protected function getLogger(string $channel): API\LoggerInterface
Expand All @@ -39,10 +55,6 @@ protected function getDefaultFormatter(): FormatterInterface
return new NormalizerFormatter();
}

/**
* @phan-suppress PhanTypeMismatchArgument
* @psalm-suppress InvalidOperand
*/
protected function write($record): void
{
$formatted = $record['formatted'];
Expand All @@ -53,10 +65,51 @@ protected function write($record): void
->setBody($formatted['message'])
;
foreach (['context', 'extra'] as $key) {
if (isset($formatted[$key]) && count($formatted[$key]) > 0) {
if (self::$mode === self::MODE_PSR3 && isset($formatted[$key]) && count($formatted[$key]) > 0) {
$logRecord->setAttribute($key, $formatted[$key]);
}
if (isset($record[$key]) && $record[$key] !== []) {
foreach ($record[$key] as $attributeName => $attribute) {
if (
$key === 'context'
&& $attributeName === 'exception'
&& $attribute instanceof Throwable
) {
$logRecord->setAttribute(ExceptionAttributes::EXCEPTION_TYPE, $attribute::class);
$logRecord->setAttribute(ExceptionAttributes::EXCEPTION_MESSAGE, $attribute->getMessage());
$logRecord->setAttribute(ExceptionAttributes::EXCEPTION_STACKTRACE, StackTraceFormatter::format($attribute));

continue;
}
switch (self::$mode) {
case self::MODE_PSR3:
if (!is_scalar($attribute)) {
$attribute = json_encode($attribute);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth adding a throw on try/catch with JSON_THROW_ON_ERROR here so that any potential encoding issues can be handled?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How should it be handled? Fallback to serialize?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also haven't checked if json_encode is even needed or if setAttribute handles it upstream

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recall in the past there were some issues due to mb encoding for PDO instrumentation which got fixed. So I guess there's potential pitfalls ahead 🤔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How should it be handled? Fallback to serialize?

Maybe it is something that doesn't need tackling right away; I may just be worrying over nothing! Not a blocker for me though.

}
$logRecord->setAttribute(sprintf('%s.%s', $key, $attributeName), $attribute);

break;
case self::MODE_OTEL:
$logRecord->setAttribute($attributeName, $attribute);

break;
}
}
}
}
$this->getLogger($record['channel'])->emit($logRecord);
}

private static function getMode(): string
{
$resolver = new ConfigurationResolver();
if ($resolver->has(self::OTEL_PHP_MONOLOG_ATTRIB_MODE)) {
$val = $resolver->getString(self::OTEL_PHP_MONOLOG_ATTRIB_MODE);
if ($val && in_array($val, self::MODES)) {
return $val;
}
}

return self::DEFAULT_MODE;
}
}
82 changes: 74 additions & 8 deletions src/Logs/Monolog/tests/Unit/HandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public function test_handle_record(): void
$sharedState->method('getLogRecordLimits')->willReturn($limits);
$handler = new Handler($this->provider, 100, true);
$processor = function ($record) {
$record['extra'] = ['foo' => 'bar', 'baz' => 'bat'];
$record['extra'] = ['foo' => 'bar', 'baz' => ['bat']];

return $record;
};
Expand All @@ -68,16 +68,28 @@ function (LogRecord $logRecord) use ($scope, $sharedState) {
$this->assertGreaterThan(0, $readable->getTimestamp());
$this->assertSame('message', $readable->getBody());
$attributes = $readable->getAttributes();
$this->assertCount(2, $attributes);
$this->assertEquals(['context', 'extra'], array_keys($attributes->toArray()));
$this->assertCount(8, $attributes);
$this->assertEqualsCanonicalizing([
'context',
'context.foo',
'extra',
'extra.foo',
'extra.baz',
'exception.message',
'exception.type',
'exception.stacktrace',
], array_keys($attributes->toArray()));
$this->assertSame('bar', $attributes->get('context')['foo']);
$this->assertSame('bar', $attributes->get('context.foo'));
$this->assertEquals([
'foo' => 'bar',
'baz' => 'bat',
'baz' => ['bat'],
], $attributes->get('extra'));
$this->assertSame('bar', $attributes->get('context')['foo']);
$this->assertSame('bar', $attributes->get('context')['foo']);
$this->assertNotNull($attributes->get('context')['exception']);
$this->assertNotNull($attributes->get('context')['exception']['message']);
$this->assertSame('bar', $attributes->get('extra.foo'));
$this->assertSame('["bat"]', $attributes->get('extra.baz'));
$this->assertSame('kaboom', $attributes->get('exception.message'));
$this->assertSame('Exception', $attributes->get('exception.type'));
$this->assertNotNull($attributes->get('exception.stacktrace'));

return true;
}
Expand All @@ -86,4 +98,58 @@ function (LogRecord $logRecord) use ($scope, $sharedState) {
/** @psalm-suppress UndefinedDocblockClass */
$monolog->info('message', ['foo' => 'bar', 'exception' => new \Exception('kaboom', 500)]);
}

public function test_handle_record_attrib_mode(): void
{
putenv(sprintf('%s=%s', Handler::OTEL_PHP_MONOLOG_ATTRIB_MODE, Handler::MODE_OTEL));

$channelName = 'test';
$scope = $this->createMock(InstrumentationScopeInterface::class);
$sharedState = $this->createMock(LoggerSharedState::class);
$resource = $this->createMock(ResourceInfo::class);
$limits = $this->createMock(LogRecordLimits::class);
$attributeFactory = Attributes::factory();
$limits->method('getAttributeFactory')->willReturn($attributeFactory);
$sharedState->method('getResource')->willReturn($resource);
$sharedState->method('getLogRecordLimits')->willReturn($limits);
$handler = new Handler($this->provider, 100, true);
$processor = function ($record) {
$record['extra'] = ['foo' => 'qux', 'bar' => 'quux'];

return $record;
};
$monolog = new \Monolog\Logger($channelName);
$monolog->pushHandler($handler);
$monolog->pushProcessor($processor);

$this->logger
->expects($this->once())
->method('emit')
->with($this->callback(
function (LogRecord $logRecord) use ($scope, $sharedState) {
$readable = new ReadableLogRecord($scope, $sharedState, $logRecord);
$attributes = $readable->getAttributes();
$this->assertCount(6, $attributes);
$this->assertEqualsCanonicalizing([
'foo',
'bar',
'baz',
'exception.message',
'exception.type',
'exception.stacktrace',
], array_keys($attributes->toArray()));
$this->assertSame('qux', $attributes->get('foo'));
$this->assertSame('quux', $attributes->get('bar'));
$this->assertSame('quuux', $attributes->get('baz'));
$this->assertSame('kaboom', $attributes->get('exception.message'));
$this->assertSame('Exception', $attributes->get('exception.type'));
$this->assertNotNull($attributes->get('exception.stacktrace'));

return true;
}
));

/** @psalm-suppress UndefinedDocblockClass */
$monolog->info('message', ['baz' => 'quuux', 'exception' => new \Exception('kaboom', 500)]);
}
}
Loading