From 10be21f9e99f8d8d3f0448445c5617a5d8ca3310 Mon Sep 17 00:00:00 2001 From: "sergei.baikin" Date: Wed, 18 Sep 2024 15:08:01 +0200 Subject: [PATCH] Add OTEL logging --- README.md | 57 +++++++++++------ composer.json | 7 ++- .../GuzzleRequestExceptionContext.php | 2 +- src/Formatter.php | 4 +- src/Laravel/LaravelLoggerCreating.php | 17 ++++- src/NewrelicProcessor.php | 2 + src/OtelFormatter.php | 62 +++++++++++++++++++ .../Compiler/ExceptionContextPass.php | 8 ++- src/Symfony/SymfonyLoggingBundle.php | 3 - src/Symfony/config/services.php | 15 +++++ 10 files changed, 145 insertions(+), 32 deletions(-) create mode 100644 src/OtelFormatter.php diff --git a/README.md b/README.md index 27f5721..c1727ed 100644 --- a/README.md +++ b/README.md @@ -95,30 +95,47 @@ symfony_logging: ``` make monolog configuration looks like this -```yaml -monolog: - handlers: - handler1: - type: stream - path: "php://stderr" - formatter: 'Gotphoto\Logging\Formatter' - main: - type: stream - path: "php://stderr" - formatter: 'Gotphoto\Logging\Formatter' - level: info - channels: [ "!something"] - handler2: - formatter: 'Gotphoto\Logging\Formatter' +```php +handler('newrelic') + ->type('stream') + ->path('php://stderr') + ->formatter(Formatter::class) + // log start from info messages (debug is lowest level) + ->level('info'); + $monolog->handler('otel') + ->type('service') + ->id(Handler::class) + ->formatter(OtelFormatter::class) + // log start from info messages (debug is lowest level) + ->level('info'); + +}; + +``` +Where the most important things are: + +NewRelic: +``` + ->type('stream') + ->path('php://stderr') + ->formatter(Formatter::class) ``` -Where the most important things are +Otel: ``` -type: stream -path: "php://stderr" -formatter: 'Gotphoto\Logging\Formatter' + ->type('service') + ->id(Handler::class) + ->formatter(OtelFormatter::class) ``` -And `main` is a fully working example ### Exception context Works in Symfony automatically. Just create implementation for the interface `Gotphoto\Logging\ExceptionContext\ExceptionContext` and add it as a diff --git a/composer.json b/composer.json index c038749..15a6ada 100644 --- a/composer.json +++ b/composer.json @@ -3,11 +3,12 @@ "description": "Integrate a common way for log handling.", "type": "library", "require": { - "php": "^8.1", - "monolog/monolog": "^3.3" + "php": "^8.2", + "monolog/monolog": "^3.7.0", + "open-telemetry/opentelemetry-logger-monolog": "^1.0" }, "require-dev": { - "vimeo/psalm": "^4.24", + "vimeo/psalm": "^5.26.1", "illuminate/support": "^9.19", "symfony/http-kernel": "^6.1", "symfony/dependency-injection": "^6.1", diff --git a/src/ExceptionContext/GuzzleRequestExceptionContext.php b/src/ExceptionContext/GuzzleRequestExceptionContext.php index f18ac77..daa300d 100644 --- a/src/ExceptionContext/GuzzleRequestExceptionContext.php +++ b/src/ExceptionContext/GuzzleRequestExceptionContext.php @@ -13,7 +13,7 @@ public function __invoke(RequestException $exception): array { /** * @psalm-suppress PossiblyNullReference - * @psalm-suppress RedundantConditionGivenDocblockType + * @psalm-suppress RedundantCondition */ if ($exception->getResponse() !== null && $exception->getResponse()->getBody() !== null) { return ['message' => $exception->getResponse()->getBody()->getContents()]; diff --git a/src/Formatter.php b/src/Formatter.php index 58c483d..6ce1c94 100644 --- a/src/Formatter.php +++ b/src/Formatter.php @@ -49,8 +49,9 @@ public function __construct( */ public function format(LogRecord $record): string { - /** @var array{timestamp: int, datetime: string} $data */ + /** @var array{timestamp: int, datetime: string, extra?:array, context?:array} $data */ $data = parent::format($record); + /** @psalm-suppress RiskyTruthyFalsyComparison this is okay null or empty string */ if (empty($data['datetime'])) { $data['datetime'] = gmdate('c'); } @@ -100,6 +101,7 @@ protected function normalize(mixed $data, int $depth = 0): mixed $data = array_merge($data, $data['extra']['newrelic-context']); /** @psalm-suppress MixedArrayAccess we checked that it is an array */ unset($data['extra']['newrelic-context']); + /** @psalm-suppress RiskyTruthyFalsyComparison this is okay null or empty string */ if (empty($data['extra'])) { unset($data['extra']); } diff --git a/src/Laravel/LaravelLoggerCreating.php b/src/Laravel/LaravelLoggerCreating.php index 1200307..122e118 100644 --- a/src/Laravel/LaravelLoggerCreating.php +++ b/src/Laravel/LaravelLoggerCreating.php @@ -4,9 +4,9 @@ namespace Gotphoto\Logging\Laravel; +use App\Lib\Log\OtelFormatter; use Aws\Exception\AwsException; use Gotphoto\Logging\ExceptionContext\AwsExceptionContext; -use Gotphoto\Logging\ExceptionContext\ExceptionContext; use Gotphoto\Logging\ExceptionContext\GuzzleRequestExceptionContext; use Gotphoto\Logging\Formatter; use Gotphoto\Logging\NewrelicProcessor; @@ -17,6 +17,9 @@ use Monolog\Logger; use Monolog\Processor\ProcessorInterface; use Monolog\Processor\PsrLogMessageProcessor; +use OpenTelemetry\API\Globals; +use OpenTelemetry\Contrib\Logs\Monolog\Handler; +use Psr\Log\LogLevel; final class LaravelLoggerCreating { @@ -56,6 +59,18 @@ public function __invoke(array $config) ); $log->pushHandler($handler); + $otelHandler = new Handler( + Globals::loggerProvider(), + LogLevel::INFO + ); + $otelHandler->setFormatter( + new \Gotphoto\Logging\OtelFormatter([ + RequestException::class => [new GuzzleRequestExceptionContext()], + AwsException::class => [new AwsExceptionContext()], + ]) + ); + $log->pushHandler($otelHandler); + return $log; } } diff --git a/src/NewrelicProcessor.php b/src/NewrelicProcessor.php index 432b5b5..6a8e5a5 100644 --- a/src/NewrelicProcessor.php +++ b/src/NewrelicProcessor.php @@ -14,6 +14,8 @@ class NewrelicProcessor implements ProcessorInterface * Returns the given record with the New Relic linking metadata added * if a compatible New Relic extension is loaded, otherwise returns the * given record unmodified + * + * @return LogRecord The processed record */ public function __invoke(LogRecord $record) { diff --git a/src/OtelFormatter.php b/src/OtelFormatter.php new file mode 100644 index 0000000..3b84068 --- /dev/null +++ b/src/OtelFormatter.php @@ -0,0 +1,62 @@ +> $exceptionContextProviderMap + */ + public function __construct(private readonly array $exceptionContextProviderMap = []) + { + parent::__construct(); + } + + /** + * @return array + */ + protected function normalizeException(\Throwable $e, int $depth = 0) + { + /** @var array{message: string, context?: array} $data */ + $data = parent::normalizeException($e, $depth); + + $exceptionProviders = $this->getExceptionContexts($e); + + foreach ($exceptionProviders as $exceptionProvider) { + /** + * @var array $additionalContext + */ + $additionalContext = $exceptionProvider($e); + if (!empty($additionalContext)) { + $data['context'] = ($data['context'] ?? []) + $additionalContext; + } + } + + return $data; + } + + /** + * @return array + */ + protected function getExceptionContexts(\Throwable $e): array + { + if (isset($this->exceptionContextProviderMap[\get_class($e)])) { + return $this->exceptionContextProviderMap[\get_class($e)]; + } + $exceptionContexts = []; + foreach (array_keys($this->exceptionContextProviderMap) as $className) { + if ($e instanceof $className) { + $exceptionContexts = array_merge($exceptionContexts + $this->exceptionContextProviderMap[$className]); + } + } + + return $exceptionContexts; + } +} diff --git a/src/Symfony/DependencyInjection/Compiler/ExceptionContextPass.php b/src/Symfony/DependencyInjection/Compiler/ExceptionContextPass.php index 6dce7d4..845bfa9 100644 --- a/src/Symfony/DependencyInjection/Compiler/ExceptionContextPass.php +++ b/src/Symfony/DependencyInjection/Compiler/ExceptionContextPass.php @@ -3,6 +3,7 @@ namespace Gotphoto\Logging\Symfony\DependencyInjection\Compiler; use Gotphoto\Logging\Formatter; +use Gotphoto\Logging\OtelFormatter; use ReflectionClass; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -22,7 +23,7 @@ public function process(ContainerBuilder $container) if (!$reflectionClass->hasMethod('__invoke')) { throw new \Exception($definition->getClass() . ' has to have __invoke method.'); } - $reflectionMethod = $reflectionClass->getMethod('__invoke'); + $reflectionMethod = $reflectionClass->getMethod('__invoke'); $typehintClassName = $reflectionMethod->getParameters()[0]->getClass()->getName(); if (!is_subclass_of($typehintClassName, Throwable::class)) { throw new \Exception($definition->getClass() . ' has to have __invoke method with argument "is_subclass_of Throwable".'); @@ -31,7 +32,8 @@ public function process(ContainerBuilder $container) $exceptionContextMap[$typehintClassName][] = new Reference($id); } - $definition = $container->getDefinition(Formatter::class); - $definition->setArgument('$exceptionContextProviderMap', $exceptionContextMap); + $container->getDefinition(Formatter::class)->setArgument('$exceptionContextProviderMap', $exceptionContextMap); + + $container->getDefinition(OtelFormatter::class)->setArgument('$exceptionContextProviderMap', $exceptionContextMap); } } diff --git a/src/Symfony/SymfonyLoggingBundle.php b/src/Symfony/SymfonyLoggingBundle.php index 323518a..f8072b2 100644 --- a/src/Symfony/SymfonyLoggingBundle.php +++ b/src/Symfony/SymfonyLoggingBundle.php @@ -9,9 +9,6 @@ class SymfonyLoggingBundle extends Bundle { - /** - * @psalm-suppress MissingReturnType can not use with php 7.0 - */ public function build(ContainerBuilder $container) { parent::build($container); diff --git a/src/Symfony/config/services.php b/src/Symfony/config/services.php index a69b8b9..d45a806 100644 --- a/src/Symfony/config/services.php +++ b/src/Symfony/config/services.php @@ -6,8 +6,14 @@ use Gotphoto\Logging\ExceptionContext\GuzzleRequestExceptionContext; use Gotphoto\Logging\Formatter; use Gotphoto\Logging\NewrelicProcessor; +use Gotphoto\Logging\OtelFormatter; use Monolog\Processor\PsrLogMessageProcessor; +use OpenTelemetry\API\Globals; +use OpenTelemetry\API\Logs\LoggerProviderInterface; +use OpenTelemetry\Contrib\Logs\Monolog\Handler; +use Psr\Log\LogLevel; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use function Symfony\Component\DependencyInjection\Loader\Configurator\inline_service; return static function (ContainerConfigurator $containerConfigurator) { $s = $containerConfigurator->services(); @@ -28,4 +34,13 @@ ->tag('gotphoto_logging.exception_context'); $s->set(GuzzleRequestExceptionContext::class) ->tag('gotphoto_logging.exception_context'); + + $s->set(Handler::class) + ->arg( + '$loggerProvider', + inline_service(LoggerProviderInterface::class) + ->factory([Globals::class, 'loggerProvider']), + ) + ->arg('$level', LogLevel::INFO); + $s->set(OtelFormatter::class); };