From c63bc4b9a9dd07cd40bed9d0bc050637c7c1a7b3 Mon Sep 17 00:00:00 2001 From: Shawn Maddock Date: Sun, 23 Nov 2025 19:36:59 -0600 Subject: [PATCH 1/6] monolog attributes -> otel attributes --- src/Logs/Monolog/src/Handler.php | 10 ++++++---- src/Logs/Monolog/tests/Unit/HandlerTest.php | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/Logs/Monolog/src/Handler.php b/src/Logs/Monolog/src/Handler.php index 60f7568b0..641131f5e 100644 --- a/src/Logs/Monolog/src/Handler.php +++ b/src/Logs/Monolog/src/Handler.php @@ -39,10 +39,6 @@ protected function getDefaultFormatter(): FormatterInterface return new NormalizerFormatter(); } - /** - * @phan-suppress PhanTypeMismatchArgument - * @psalm-suppress InvalidOperand - */ protected function write($record): void { $formatted = $record['formatted']; @@ -56,6 +52,12 @@ protected function write($record): void if (isset($formatted[$key]) && count($formatted[$key]) > 0) { $logRecord->setAttribute($key, $formatted[$key]); } + if (isset($record[$key]) && $record[$key] !== []) { + foreach ($record[$key] as $attributeName => $attribute) { + $logRecord->setAttribute(sprintf('%s.%s', $key, $attributeName), $attribute); + $logRecord->setAttribute($attributeName, $attribute); + } + } } $this->getLogger($record['channel'])->emit($logRecord); } diff --git a/src/Logs/Monolog/tests/Unit/HandlerTest.php b/src/Logs/Monolog/tests/Unit/HandlerTest.php index 89cbf4280..309225c8d 100644 --- a/src/Logs/Monolog/tests/Unit/HandlerTest.php +++ b/src/Logs/Monolog/tests/Unit/HandlerTest.php @@ -68,8 +68,18 @@ 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(9, $attributes); + $this->assertEqualsCanonicalizing([ + 'context', + 'extra', + 'context.foo', + 'context.exception', + 'extra.foo', + 'extra.baz', + 'foo', + 'baz', + 'exception', + ], array_keys($attributes->toArray())); $this->assertEquals([ 'foo' => 'bar', 'baz' => 'bat', From 5e96341977f0dbdc792db6267949ef2d305dabe5 Mon Sep 17 00:00:00 2001 From: Shawn Maddock Date: Mon, 24 Nov 2025 12:59:22 -0600 Subject: [PATCH 2/6] added feature flag --- src/Logs/Monolog/README.md | 63 +++++++++++++++++++++ src/Logs/Monolog/src/Handler.php | 43 +++++++++++++- src/Logs/Monolog/tests/Unit/HandlerTest.php | 5 +- 3 files changed, 104 insertions(+), 7 deletions(-) diff --git a/src/Logs/Monolog/README.md b/src/Logs/Monolog/README.md index 9dd9ecaa4..f137ce80f 100644 --- a/src/Logs/Monolog/README.md +++ b/src/Logs/Monolog/README.md @@ -74,3 +74,66 @@ $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. + +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.exception => '{}' + * 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 641131f5e..e9f441b31 100644 --- a/src/Logs/Monolog/src/Handler.php +++ b/src/Logs/Monolog/src/Handler.php @@ -8,10 +8,22 @@ use Monolog\Formatter\FormatterInterface; use Monolog\Formatter\NormalizerFormatter; use Monolog\Handler\AbstractProcessingHandler; +use OpenTelemetry\API\Instrumentation\ConfigurationResolver; + use OpenTelemetry\API\Logs as API; 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 +35,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 @@ -49,16 +62,40 @@ 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) { - $logRecord->setAttribute(sprintf('%s.%s', $key, $attributeName), $attribute); - $logRecord->setAttribute($attributeName, $attribute); + 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 309225c8d..3864c8c8c 100644 --- a/src/Logs/Monolog/tests/Unit/HandlerTest.php +++ b/src/Logs/Monolog/tests/Unit/HandlerTest.php @@ -68,7 +68,7 @@ function (LogRecord $logRecord) use ($scope, $sharedState) { $this->assertGreaterThan(0, $readable->getTimestamp()); $this->assertSame('message', $readable->getBody()); $attributes = $readable->getAttributes(); - $this->assertCount(9, $attributes); + $this->assertCount(6, $attributes); $this->assertEqualsCanonicalizing([ 'context', 'extra', @@ -76,9 +76,6 @@ function (LogRecord $logRecord) use ($scope, $sharedState) { 'context.exception', 'extra.foo', 'extra.baz', - 'foo', - 'baz', - 'exception', ], array_keys($attributes->toArray())); $this->assertEquals([ 'foo' => 'bar', From b1cdc3b3dd5c97cb6c8abb783273e60f54e74424 Mon Sep 17 00:00:00 2001 From: Shawn Maddock Date: Mon, 24 Nov 2025 13:01:41 -0600 Subject: [PATCH 3/6] typo --- src/Logs/Monolog/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Logs/Monolog/README.md b/src/Logs/Monolog/README.md index f137ce80f..ecab0f4ce 100644 --- a/src/Logs/Monolog/README.md +++ b/src/Logs/Monolog/README.md @@ -102,7 +102,7 @@ new Monolog\LogRecord( * attributes => array ( * context => '{"foo":"bar","obj":{}}' * context.foo => 'bar' - * context.exception => '{}' + * context.obj => '{}' * extras => '{"foo":"bar","baz":"bat"}' * extras.foo => 'bar' * extras.baz => 'bat' From 93b996a72685a416bf770b2b5a9ba8afd366f74a Mon Sep 17 00:00:00 2001 From: Shawn Maddock Date: Mon, 24 Nov 2025 14:57:58 -0600 Subject: [PATCH 4/6] added exception handling --- src/Logs/Monolog/README.md | 13 +++++++++++-- src/Logs/Monolog/src/Handler.php | 16 +++++++++++++++- src/Logs/Monolog/tests/Unit/HandlerTest.php | 8 +++++--- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/Logs/Monolog/README.md b/src/Logs/Monolog/README.md index ecab0f4ce..803e2bb86 100644 --- a/src/Logs/Monolog/README.md +++ b/src/Logs/Monolog/README.md @@ -80,6 +80,9 @@ $logger->info('hello world'); 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: @@ -96,7 +99,9 @@ new Monolog\LogRecord( ] ); -/** becomes: +/** + * becomes: + * * OpenTelemetry\API\Logs\LogRecord ( * ..., * attributes => array ( @@ -108,6 +113,7 @@ new Monolog\LogRecord( * extras.baz => 'bat' * ) * ) + */ ``` Alternatively, if your `context` and `extras` keys do not conflict with OpenTelemetry Semantic Conventions for Attribute keys, _and_ all your values @@ -126,7 +132,9 @@ new Monolog\LogRecord( ] ); -/** becomes: +/** + * becomes: + * * OpenTelemetry\API\Logs\LogRecord ( * ..., * attributes => array ( @@ -136,4 +144,5 @@ new Monolog\LogRecord( * server.port => 80 * ) * ) + */ ``` diff --git a/src/Logs/Monolog/src/Handler.php b/src/Logs/Monolog/src/Handler.php index e9f441b31..ec5ebc727 100644 --- a/src/Logs/Monolog/src/Handler.php +++ b/src/Logs/Monolog/src/Handler.php @@ -9,8 +9,11 @@ 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 { @@ -67,6 +70,17 @@ protected function write($record): void } 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)) { diff --git a/src/Logs/Monolog/tests/Unit/HandlerTest.php b/src/Logs/Monolog/tests/Unit/HandlerTest.php index 3864c8c8c..21ca31c6c 100644 --- a/src/Logs/Monolog/tests/Unit/HandlerTest.php +++ b/src/Logs/Monolog/tests/Unit/HandlerTest.php @@ -68,12 +68,14 @@ function (LogRecord $logRecord) use ($scope, $sharedState) { $this->assertGreaterThan(0, $readable->getTimestamp()); $this->assertSame('message', $readable->getBody()); $attributes = $readable->getAttributes(); - $this->assertCount(6, $attributes); + $this->assertCount(8, $attributes); $this->assertEqualsCanonicalizing([ 'context', - 'extra', 'context.foo', - 'context.exception', + 'exception.type', + 'exception.message', + 'exception.stacktrace', + 'extra', 'extra.foo', 'extra.baz', ], array_keys($attributes->toArray())); From b3ceef74e2ebdd1677eaaefbe5863b9154571aae Mon Sep 17 00:00:00 2001 From: Shawn Maddock Date: Tue, 25 Nov 2025 09:49:50 -0600 Subject: [PATCH 5/6] additional unit testing --- src/Logs/Monolog/tests/Unit/HandlerTest.php | 75 ++++++++++++++++++--- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/src/Logs/Monolog/tests/Unit/HandlerTest.php b/src/Logs/Monolog/tests/Unit/HandlerTest.php index 21ca31c6c..626919932 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; }; @@ -72,21 +72,24 @@ function (LogRecord $logRecord) use ($scope, $sharedState) { $this->assertEqualsCanonicalizing([ 'context', 'context.foo', - 'exception.type', - 'exception.message', - 'exception.stacktrace', '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')[0]); + $this->assertSame('kaboom', $attributes->get('exception.message')); + $this->assertSame('Exception', $attributes->get('exception.type')); + $this->assertNotNull($attributes->get('exception.stacktrace')); return true; } @@ -95,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)]); + } } From 760a30788688c95f6b383a5dd967bee83903458b Mon Sep 17 00:00:00 2001 From: Shawn Maddock Date: Tue, 25 Nov 2025 09:54:13 -0600 Subject: [PATCH 6/6] test fix --- src/Logs/Monolog/tests/Unit/HandlerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Logs/Monolog/tests/Unit/HandlerTest.php b/src/Logs/Monolog/tests/Unit/HandlerTest.php index 626919932..498fb62b0 100644 --- a/src/Logs/Monolog/tests/Unit/HandlerTest.php +++ b/src/Logs/Monolog/tests/Unit/HandlerTest.php @@ -86,7 +86,7 @@ function (LogRecord $logRecord) use ($scope, $sharedState) { 'baz' => ['bat'], ], $attributes->get('extra')); $this->assertSame('bar', $attributes->get('extra.foo')); - $this->assertSame('bat', $attributes->get('extra.baz')[0]); + $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'));