diff --git a/src/Logs/Monolog/README.md b/src/Logs/Monolog/README.md index 9dd9ecaa4..803e2bb86 100644 --- a/src/Logs/Monolog/README.md +++ b/src/Logs/Monolog/README.md @@ -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 + * ) + * ) + */ +``` diff --git a/src/Logs/Monolog/src/Handler.php b/src/Logs/Monolog/src/Handler.php index 60f7568b0..ec5ebc727 100644 --- a/src/Logs/Monolog/src/Handler.php +++ b/src/Logs/Monolog/src/Handler.php @@ -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; @@ -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 @@ -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']; @@ -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); + } + $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; + } } diff --git a/src/Logs/Monolog/tests/Unit/HandlerTest.php b/src/Logs/Monolog/tests/Unit/HandlerTest.php index 89cbf4280..498fb62b0 100644 --- a/src/Logs/Monolog/tests/Unit/HandlerTest.php +++ b/src/Logs/Monolog/tests/Unit/HandlerTest.php @@ -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; }; @@ -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; } @@ -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)]); + } }