diff --git a/composer.json b/composer.json index fb38cb0..e22f513 100644 --- a/composer.json +++ b/composer.json @@ -39,8 +39,10 @@ "psr-4": { "RunOpenCode\\Component\\Bitmask\\": "src/RunOpenCode/Component/Bitmask/src/", "RunOpenCode\\Component\\Dataset\\": "src/RunOpenCode/Component/Dataset/src/", + "RunOpenCode\\Component\\Logger\\": "src/RunOpenCode/Component/Logger/src/", "RunOpenCode\\Component\\Metadata\\": "src/RunOpenCode/Component/Metadata/src/", "RunOpenCode\\Component\\Query\\": "src/RunOpenCode/Component/Query/src/", + "RunOpenCode\\Bundle\\LoggerBundle\\": "src/RunOpenCode/Bundle/LoggerBundle/src/", "RunOpenCode\\Bundle\\QueryBundle\\": "src/RunOpenCode/Bundle/QueryBundle/src/", "RunOpenCode\\Bundle\\MetadataBundle\\": "src/RunOpenCode/Bundle/MetadataBundle/src/", "Monorepo\\": "monorepo/" @@ -54,8 +56,10 @@ "psr-4": { "RunOpenCode\\Component\\Bitmask\\Tests\\": "src/RunOpenCode/Component/Bitmask/tests/", "RunOpenCode\\Component\\Dataset\\Tests\\": "src/RunOpenCode/Component/Dataset/tests/", + "RunOpenCode\\Component\\Logger\\Tests\\": "src/RunOpenCode/Component/Logger/tests/", "RunOpenCode\\Component\\Metadata\\Tests\\": "src/RunOpenCode/Component/Metadata/tests/", "RunOpenCode\\Component\\Query\\Tests\\": "src/RunOpenCode/Component/Query/tests/", + "RunOpenCode\\Bundle\\LoggerBundle\\Tests\\": "src/RunOpenCode/Bundle/LoggerBundle/tests/", "RunOpenCode\\Bundle\\QueryBundle\\Tests\\": "src/RunOpenCode/Bundle/QueryBundle/tests/", "RunOpenCode\\Bundle\\MetadataBundle\\Tests\\": "src/RunOpenCode/Bundle/MetadataBundle/tests/" } @@ -66,7 +70,6 @@ "phpstan": "XDEBUG_MODE=off vendor/bin/phpstan analyse --memory-limit=-1", "phpcs": "XDEBUG_MODE=off vendor/bin/php-cs-fixer fix --diff --verbose --show-progress=dots --allow-risky=yes", "deps": "bin/deps", - "phpmd": "XDEBUG_MODE=off vendor/bin/phpmd src text phpmd.xml | tee build/phpmd/phpmd.txt & vendor/bin/phpmd src xml phpmd.xml --reportfile build/phpmd/phpmd.xml ; vendor/bin/phpmd src html phpmd.xml --reportfile build/phpmd/phpmd.html", "phpmetrics": "phpmetrics --report-html=build/metrics src/", "rector": "XDEBUG_MODE=off vendor/bin/rector process --ansi", "infection": "infection --threads=max" diff --git a/docs/source/bundles/index.rst b/docs/source/bundles/index.rst index 22ed985..ba34460 100644 --- a/docs/source/bundles/index.rst +++ b/docs/source/bundles/index.rst @@ -14,5 +14,6 @@ Table of Contents :maxdepth: 2 :titlesonly: + logger-bundle/index metadata-bundle/index diff --git a/docs/source/bundles/logger-bundle/index.rst b/docs/source/bundles/logger-bundle/index.rst new file mode 100644 index 0000000..86124c8 --- /dev/null +++ b/docs/source/bundles/logger-bundle/index.rst @@ -0,0 +1,177 @@ +============= +Logger Bundle +============= + +The Logger Bundle provides seamless integration of the Logger component with +the Symfony framework. This bundle automatically configures the Logger as a +service and provides configuration options for customizing its behavior. + +The Logger component is a decorator for PSR-3 logger implementations that adds +convenience methods for exception logging and context enrichment. See the +:doc:`../../components/logger/index` for details about the Logger component +itself. + +Installation +------------ + +To use the Logger component in a Symfony application, install the bundle: + +.. code-block:: console + + composer require runopencode/logger-bundle + +The bundle will be automatically registered in your ``config/bundles.php`` if +you're using Symfony Flex. + +Configuration +------------- + +The bundle provides several configuration options that can be set in your +``config/packages/runopencode_logger.yaml`` file: + +.. code-block:: yaml + + runopencode_logger: + debug: '%kernel.debug%' + default_log_level: 'error' + +Configuration options +~~~~~~~~~~~~~~~~~~~~~ + +* ``debug`` (boolean, default: ``false``): Enable debug mode. When enabled, the + ``exception()`` method will throw exceptions after logging them. This is + useful during development. Typically set to ``%kernel.debug%`` to match + Symfony's debug mode. + +* ``default_log_level`` (string, default: ``'critical'``): The default log level + used for exception logging when no level is explicitly specified. Valid values + are: ``emergency``, ``alert``, ``critical``, ``error``, ``warning``, + ``notice``, ``info``, ``debug``. + +Using the Logger service +------------------------- + +Once the bundle is installed and configured, the Logger service is automatically +available for dependency injection. Inject it using the interface: + +.. code-block:: php + :linenos: + + repository->create($userData); + $this->mailer->sendWelcomeEmail($user); + + return $user; + } catch (\Throwable $exception) { + $this->logger->exception( + $exception, + 'User registration failed', + ['email' => $userData['email'] ?? 'unknown'] + ); + // ... + } + } + } + +The Logger is automatically configured with Symfony's default logger (the +``logger`` service). + +Automatic context provider registration +---------------------------------------- + +One of the most powerful features of the bundle is automatic registration of +context providers. Any service that implements ``LoggerContextInterface`` is +automatically detected and injected into the Logger. + +Creating a context provider +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Simply create a service that implements the interface: + +.. code-block:: php + :linenos: + + requestStack->getCurrentRequest(); + + if (null === $request) { + return []; + } + + return [ + 'request_id' => $request->headers->get('X-Request-ID') ?? uniqid(), + 'request_uri' => $request->getRequestUri(), + 'request_method' => $request->getMethod(), + 'user_agent' => $request->headers->get('User-Agent'), + ]; + } + } + +If you have services autoconfiguration enabled (which is the default in Symfony), +this service will be automatically registered and tagged with +``runopencode.logger.context_provider``. No additional configuration is needed! + +Manual registration +~~~~~~~~~~~~~~~~~~~ + +If you're not using autoconfiguration or want to explicitly register a context +provider, you can do so in your services configuration: + +.. code-block:: yaml + + services: + App\Logger\Context\RequestContextProvider: + tags: + - { name: 'runopencode.logger.context_provider' } + +Best practices +-------------- + +1. **Use the interface**: Always inject ``LoggerInterface`` from the Logger + component, not the concrete ``Logger`` class or PSR-3's ``LoggerInterface``. + +2. **Configure per environment**: Use different configuration files for dev, + test, and prod environments to adjust debug mode and log levels. + +3. **Create focused context providers**: Each context provider should have a + single responsibility (e.g., one for request data, one for user data, etc.). + +4. **Keep providers lightweight**: Context providers are called on every log + entry, so avoid expensive operations. + +See the :doc:`../../components/logger/context-providers` documentation for more +information about creating and using context providers. diff --git a/docs/source/components/index.rst b/docs/source/components/index.rst index 5a112c3..d8ffd88 100644 --- a/docs/source/components/index.rst +++ b/docs/source/components/index.rst @@ -18,5 +18,6 @@ Table of Contents :titlesonly: dataset/index + logger/index metadata/index query/index diff --git a/docs/source/components/logger/context-providers.rst b/docs/source/components/logger/context-providers.rst new file mode 100644 index 0000000..0ee7fff --- /dev/null +++ b/docs/source/components/logger/context-providers.rst @@ -0,0 +1,238 @@ +================= +Context providers +================= + +Context providers allow you to automatically enrich all log entries with +additional context information from various sources. This is useful for adding +consistent metadata to all logs, such as request IDs, user information, +environment details, etc. + +What are context providers? +---------------------------- + +A context provider is a class that implements the ``LoggerContextInterface`` +and provides additional context data to be merged with the context passed to +any log method. This allows you to inject contextual information into all log +entries without having to manually pass it every time you log something. + +Common use cases include: + +* Adding request ID to all logs for request tracing +* Including authenticated user information +* Adding environment details (hostname, instance ID, etc.) +* Including application version or build number +* Adding correlation IDs for distributed systems + +The LoggerContextInterface +--------------------------- + +To create a context provider, implement the ``LoggerContextInterface``: + +.. code-block:: php + :linenos: + + $current Current context passed to the logger method. + * + * @return array Context data to append to the current context. + */ + public function get(array $current): array; + } + +The ``get()`` method receives the current context (the context that was passed +to the log method) and returns additional context data to be merged with it. + +Creating a context provider +---------------------------- + +Here's an example of a simple context provider that adds request ID to all logs: + +.. code-block:: php + :linenos: + + $this->requestId]; + } + } + +Using context providers +----------------------- + +To use context providers, pass them to the Logger constructor: + +.. code-block:: php + :linenos: + + info('User action performed'); + + // Resulting context will be something like: + // [ + // 'request_id' => '96a101dd-c49a-4fea-aee2-a76510f32190', + // ] + +Context merging +--------------- + +Context from providers is merged with the context passed to log methods. The +merge happens in this order: + +1. Context from each provider is collected (in the order they were registered) +2. Each provider's context is merged with the accumulated context +3. The final context is merged with the context passed to the log method + +If there are duplicate keys, later values override earlier ones: + +.. code-block:: php + :linenos: + + 'abc', 'env' => 'prod'] + // Provider 2 returns: ['user_id' => 42, 'env' => 'staging'] + // Passed context: ['env' => 'dev', 'action' => 'create'] + + // Final context will be: + // [ + // 'request_id' => 'abc', + // 'env' => 'dev', // From passed context (overrides providers) + // 'user_id' => 42, + // 'action' => 'create', + // ] + +This means that the context passed directly to the log method always has the +highest priority. + +Access to current context +------------------------- + +Context providers have access to the current context (including context from +previous providers) through the ``$current`` parameter. This allows you to make +decisions based on what's already in the context: + +.. code-block:: php + :linenos: + + memory_get_usage(true), + 'peak_memory' => memory_get_peak_usage(true), + 'time' => microtime(true), + ]; + } + + return []; + } + } + +Performance considerations +-------------------------- + +Context providers are called for every log entry, so keep their implementation +lightweight. Avoid expensive operations like database queries or external API +calls in context providers. + +If you need to include data that's expensive to compute, consider: + +* Caching the data in the provider instance +* Making the computation lazy (only when actually needed) +* Using a different approach for expensive context data + +Good example (lightweight): + +.. code-block:: php + :linenos: + + $_ENV['APP_ENV'] ?? 'unknown', + 'hostname' => gethostname(), + ]; + } + } + +Bad example (expensive - avoid this): + +.. code-block:: php + :linenos: + + $this->db->query('SELECT COUNT(*) FROM users WHERE active = 1'), + ]; + } + } + +See the :doc:`Logger Bundle documentation <../../bundles/logger-bundle/index>` +for details on Symfony integration. diff --git a/docs/source/components/logger/index.rst b/docs/source/components/logger/index.rst new file mode 100644 index 0000000..7060ecb --- /dev/null +++ b/docs/source/components/logger/index.rst @@ -0,0 +1,120 @@ +================ +Logger component +================ + +The Logger component is a decorator for PSR-3 logger implementations that adds +convenience methods for exception logging and context enrichment. It wraps any +PSR-3 compatible logger and extends its functionality with exception-specific +logging methods and the ability to automatically enrich log context from +multiple sources. + +When working with exceptions, you often need to log them with proper context +before either throwing them or suppressing them based on the environment (e.g., +development vs. production). This component provides a clean API for these +common scenarios while maintaining full compatibility with PSR-3. + +Features +-------- + +* **Exception logging methods**: dedicated ``exception()`` and ``throw()`` + methods for logging exceptions with automatic context enrichment. +* **Debug mode support**: toggle between logging and throwing exceptions + depending on the environment. +* **Context providers**: automatically enrich log entries with additional + context from multiple sources (e.g., request ID, user information, etc.). +* **PSR-3 compatible**: fully implements PSR-3 ``LoggerInterface`` and can wrap + any PSR-3 logger. +* **Configurable default log level**: set the default severity level for + exception logging. +* **Symfony ready**: via dedicated ``runopencode/logger-bundle`` package, + see :doc:`../../bundles/logger-bundle/index` for integration details. + +Table of Contents +----------------- + +.. toctree:: + :maxdepth: 1 + + installation + usage + context-providers + +Quick example +------------- + +Basic usage of the Logger component to log exceptions with custom context: + +.. code-block:: php + :linenos: + + gateway->charge($payment); + } catch (\Throwable $exception) { + // Log the exception with additional context + $this->logger->exception( + $exception, + 'Payment processing failed', + [ + 'payment_id' => $payment->getId(), + 'amount' => $payment->getAmount(), + 'currency' => $payment->getCurrency(), + ] + ); + // ... + } + } + } + +In the example above, if an exception occurs during payment processing, it will +be logged with the custom message and context, including payment details. The +``exception()`` method logs the exception but does not re-throw it (unless debug +mode is enabled), allowing you to handle the error yourself. + +For cases where you want to both log and throw the exception, use the +``throw()`` method instead: + +.. code-block:: php + :linenos: + + performOperation(); + } catch (\Throwable $exception) { + // Log and re-throw the exception + $this->logger->throw( + $exception, + 'Critical operation failed', + ['operation' => 'critical'] + ); + } + } + +See :doc:`usage` for more detailed examples and :doc:`context-providers` to +learn how to automatically enrich all log entries with additional context. + +For Symfony framework integration, see the +:doc:`Logger Bundle documentation <../../bundles/logger-bundle/index>`. + diff --git a/docs/source/components/logger/installation.rst b/docs/source/components/logger/installation.rst new file mode 100644 index 0000000..c3b1760 --- /dev/null +++ b/docs/source/components/logger/installation.rst @@ -0,0 +1,156 @@ +============ +Installation +============ + +To install the Logger component, you will need to use Composer. Run the +following command in your terminal: + +.. code-block:: console + + composer require runopencode/logger + +This will download and install the Logger component along with its dependencies. + +Basic setup +----------- + +In your project, you will need to initialize the Logger by wrapping an existing +PSR-3 logger implementation: + +.. code-block:: php + :linenos: + + pushHandler(new StreamHandler('/path/to/logs/app.log', LogLevel::DEBUG)); + + // Wrap it with RunOpenCode Logger + $logger = new Logger( + decorated: $psrLogger, + contextProviders: [], // Optional: context providers + debug: false, // Optional: debug mode (default: false) + defaultLevel: LogLevel::CRITICAL // Optional: default log level (default: CRITICAL) + ); + +The Logger component is a decorator that wraps any PSR-3 logger implementation +and adds additional functionality on top of it. + +Using the interface +------------------- + +It is highly recommended to use the ``LoggerInterface`` from this component +as a dependency in your classes, rather than the concrete implementation: + +.. code-block:: php + :linenos: + + repository->delete($userId); + } catch (\Throwable $exception) { + $this->logger->exception( + $exception, + 'Failed to delete user', + ['user_id' => $userId] + ); + // ... + } + } + } + +The ``LoggerInterface`` extends PSR-3's ``LoggerInterface`` and adds the +``exception()`` and ``throw()`` methods for convenient exception logging. + +Debug mode +---------- + +When creating a Logger instance, you can enable debug mode. In debug mode, the +``exception()`` method will throw the exception after logging it, which is +useful during development: + +.. code-block:: php + :linenos: + + exception($exception); + + // You can still override it per-call + $logger->exception($exception, level: LogLevel::CRITICAL); + +The default log level is ``LogLevel::CRITICAL`` if not specified. + +Symfony integration +------------------- + +If you are using Symfony framework, you should use the +``runopencode/logger-bundle`` package which automatically registers the Logger +as a service in your container and provides configuration options. + +See the :doc:`Logger Bundle documentation <../../bundles/logger-bundle/index>` +for more information about Symfony integration. diff --git a/docs/source/components/logger/usage.rst b/docs/source/components/logger/usage.rst new file mode 100644 index 0000000..433d494 --- /dev/null +++ b/docs/source/components/logger/usage.rst @@ -0,0 +1,285 @@ +===== +Usage +===== + +The Logger component provides a decorator for PSR-3 loggers with additional +methods specifically designed for exception handling. This document covers the +main usage patterns and features. + +Exception logging +----------------- + +The primary feature of this component is the ability to log exceptions with +a clean API. The component provides two methods for exception logging: + +* ``exception()`` - Logs the exception but does not throw it (unless in debug mode) +* ``throw()`` - Logs the exception and always throws it + +exception() method +~~~~~~~~~~~~~~~~~~ + +Use the ``exception()`` method when you want to log an exception and handle it +gracefully without re-throwing it: + +.. code-block:: php + :linenos: + + validateOrder($order); + $this->processPayment($order); + $this->notifyCustomer($order); + + return OrderResult::success($order); + } catch (\Throwable $exception) { + // Log the exception with context + $this->logger->exception( + $exception, + 'Order placement failed', + [ + 'order_id' => $order->getId(), + 'customer_id' => $order->getCustomerId(), + 'total' => $order->getTotal(), + ] + ); + + return OrderResult::failure($exception->getMessage()); + } + } + } + +In the example above, when an exception occurs, it is logged with additional +context, but the method returns a failure result instead of crashing. This is +useful for non-critical operations where you want to continue execution. + +throw() method +~~~~~~~~~~~~~~ + +Use the ``throw()`` method when you want to log an exception and then re-throw +it: + +.. code-block:: php + :linenos: + + database->execute('CRITICAL_OPERATION'); + } catch (\Exception $exception) { + // Log and re-throw + $this->logger->throw( + $exception, + 'Critical database operation failed', + ['operation' => 'CRITICAL_OPERATION'] + ); + } + } + +This is useful when you need to log the exception with context but still want +the exception to propagate up the call stack. + +Custom messages +~~~~~~~~~~~~~~~ + +Both methods accept an optional custom message parameter. If provided, this +message will be used for logging instead of the exception's message: + +.. code-block:: php + :linenos: + + externalApi->call(); + } catch (\Throwable $exception) { + // Use a custom message for logging + $this->logger->exception( + $exception, + 'External API call failed - retrying later' + ); + } + +If no custom message is provided, the exception's message will be used. + +Log levels +~~~~~~~~~~ + +You can specify the log level for each exception: + +.. code-block:: php + :linenos: + + cache->clear(); + } catch (\Throwable $exception) { + // Log at WARNING level instead of the default CRITICAL + $this->logger->exception( + $exception, + 'Cache clear failed', + level: LogLevel::WARNING + ); + } + +If no level is specified, the logger will use the default level configured +during initialization (which defaults to ``LogLevel::CRITICAL``). + +Context enrichment +~~~~~~~~~~~~~~~~~~ + +The ``exception()`` and ``throw()`` methods automatically add the exception +object to the log context under the ``exception`` key: + +.. code-block:: php + :linenos: + + logger->exception( + $exception, + 'Operation failed', + ['user_id' => 123] + ); + + // The actual context passed to the underlying PSR-3 logger will be: + // [ + // 'user_id' => 123, + // 'exception' => $exception - Automatically added + // ] + +This ensures that the exception object is always available in the log context +for further processing by your logging infrastructure (e.g., Sentry, error +tracking services, etc.). + +Additionally, if you have configured context providers (see +:doc:`context-providers`), they will automatically enrich the context with +additional information. + +PSR-3 compatibility +------------------- + +The Logger component fully implements PSR-3's ``LoggerInterface``, so you can +use all standard PSR-3 methods: + +.. code-block:: php + :linenos: + + emergency('System is down'); + $logger->alert('Database connection lost'); + $logger->critical('Critical error occurred'); + $logger->error('An error occurred'); + $logger->warning('Warning: deprecated method used'); + $logger->notice('User logged in'); + $logger->info('Operation completed successfully'); + $logger->debug('Debug information'); + + // Generic log method + $logger->log(LogLevel::INFO, 'Custom log message', ['key' => 'value']); + +All these methods are forwarded to the underlying PSR-3 logger that was passed +to the Logger constructor. If context providers are configured, they will also +enrich the context for these standard PSR-3 methods. + +Debug mode behavior +------------------- + +The behavior of the ``exception()`` method changes based on the debug mode: + +.. code-block:: php + :linenos: + + exception($exception); + + // Execution continues here + echo "Exception was logged, continuing execution..."; + } + + // Development mode (debug = true) + $logger = new Logger($psrLogger, debug: true); + + try { + throw new \RuntimeException('Test exception'); + } catch (\Throwable $exception) { + // This will log the exception AND throw it again + $logger->exception($exception); + + // This line will NOT be reached + echo "This will never be printed"; + } + +This allows you to use the same code in both environments, with different +behavior based on the debug flag. In production, you can log exceptions and +handle them gracefully, while in development, exceptions are still thrown so +you can see stack traces and debug issues. + +Note that the ``throw()`` method always re-throws the exception regardless of +the debug mode. + +Working with context +-------------------- + +You can pass any additional context data when logging exceptions: + +.. code-block:: php + :linenos: + + api->updateUser($userId, $data); + } catch (\Throwable $exception) { + $this->logger->exception( + $exception, + 'User update failed', + [ + 'user_id' => $userId, + 'request_data' => $data, + 'timestamp' => time(), + 'ip_address' => $_SERVER['REMOTE_ADDR'] ?? 'unknown', + ] + ); + } + +The context array can contain any data that will help you debug the issue. This +data is passed to the underlying PSR-3 logger and can be processed by your log +handlers (e.g., written to files, sent to log aggregation services, etc.). + +For automatically enriching context across all log calls, see +:doc:`context-providers`. diff --git a/src/RunOpenCode/Bundle/LoggerBundle/LICENSE b/src/RunOpenCode/Bundle/LoggerBundle/LICENSE new file mode 100644 index 0000000..e8b6ed7 --- /dev/null +++ b/src/RunOpenCode/Bundle/LoggerBundle/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 RunOpenCode + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/RunOpenCode/Bundle/LoggerBundle/README.md b/src/RunOpenCode/Bundle/LoggerBundle/README.md new file mode 100644 index 0000000..e69de29 diff --git a/src/RunOpenCode/Bundle/LoggerBundle/composer.json b/src/RunOpenCode/Bundle/LoggerBundle/composer.json new file mode 100644 index 0000000..ec1cafa --- /dev/null +++ b/src/RunOpenCode/Bundle/LoggerBundle/composer.json @@ -0,0 +1,43 @@ +{ + "name": "runopencode/logger-bundle", + "description": "Symfony integration with runopencode/logger component.", + "license": "MIT", + "authors": [ + { + "name": "Nikola Svitlica a.k.a TheCelavi", + "email": "thecelavi@runopencode.com" + }, + { + "name": "Stefan Veljancic", + "email": "veljancicstefan@gmail.com" + } + ], + "require": { + "php": ">=8.4", + "psr/log": "^3.0", + "runopencode/logger": "^0.2", + "symfony/config": "^7.0", + "symfony/http-kernel": "^7.0", + "symfony/dependency-injection": "^7.0" + }, + "autoload": { + "psr-4": { + "RunOpenCode\\Bundle\\LoggerBundle\\": "src/" + } + }, + "repositories": [ + { + "type": "path", + "url": "../../Component/Logger", + "options": { + "symlink": false + } + } + ], + "config": { + "sort-packages": true + }, + "version": "0.2", + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/src/RunOpenCode/Bundle/LoggerBundle/src/LoggerBundle.php b/src/RunOpenCode/Bundle/LoggerBundle/src/LoggerBundle.php new file mode 100644 index 0000000..bec19f1 --- /dev/null +++ b/src/RunOpenCode/Bundle/LoggerBundle/src/LoggerBundle.php @@ -0,0 +1,71 @@ +rootNode() + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('debug') + ->defaultFalse() + ->info('Set to "true" if exception should be thrown instead of logged (useful for development purposes).') + ->end() + ->enumNode('default_log_level') + ->defaultValue(LogLevel::CRITICAL) + ->values(Logger::getLogLevels()) + ->info('Set default log level for exceptions.') + ->end() + ->end() + ->end(); + } + + /** + * {@inheritdoc} + * + * @param array{ + * debug: bool, + * default_log_level: string + * } $config + */ + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void + { + $builder->registerForAutoconfiguration(LoggerContextInterface::class) + ->addTag('runopencode.logger.context_provider'); + + $services = $container->services(); + + $services + ->set(Logger::class) + ->args([ + '$decorated' => service('monolog.logger'), + '$contextProviders' => tagged_iterator('runopencode.logger.context_provider'), + '$debug' => $config['debug'], + '$defaultLevel' => $config['default_log_level'], + ]); + + $services->alias(LoggerInterface::class, Logger::class); + } +} diff --git a/src/RunOpenCode/Bundle/LoggerBundle/tests/LoggerBundleTest.php b/src/RunOpenCode/Bundle/LoggerBundle/tests/LoggerBundleTest.php new file mode 100644 index 0000000..7e24476 --- /dev/null +++ b/src/RunOpenCode/Bundle/LoggerBundle/tests/LoggerBundleTest.php @@ -0,0 +1,69 @@ +container->setParameter('kernel.environment', 'test'); + $this->container->setParameter('kernel.build_dir', 'tmp'); + } + + #[Test] + public function load_extension_with_default_configuration(): void + { + $this->load(); + + $this->assertContainerBuilderHasService(Logger::class); + $this->assertContainerBuilderHasAlias(LoggerInterface::class, Logger::class); + + $this->assertContainerBuilderHasServiceDefinitionWithArgument(Logger::class, '$decorated'); + $this->assertContainerBuilderHasServiceDefinitionWithArgument(Logger::class, '$contextProviders'); + $this->assertContainerBuilderHasServiceDefinitionWithArgument(Logger::class, '$debug', false); + $this->assertContainerBuilderHasServiceDefinitionWithArgument(Logger::class, '$defaultLevel', LogLevel::CRITICAL); + } + + #[Test] + public function load_extension_with_custom_configuration(): void + { + $this->load([ + 'debug' => true, + 'default_log_level' => 'warning', + ]); + + $this->assertContainerBuilderHasService(Logger::class); + $this->assertContainerBuilderHasAlias(LoggerInterface::class, Logger::class); + + $this->assertContainerBuilderHasServiceDefinitionWithArgument(Logger::class, '$debug', true); + $this->assertContainerBuilderHasServiceDefinitionWithArgument(Logger::class, '$defaultLevel', 'warning'); + } + + /** + * {@inheritdoc} + */ + protected function getContainerExtensions(): array + { + $bundle = new LoggerBundle(); + $extension = $bundle->getContainerExtension(); + + if (!$extension instanceof ExtensionInterface) { + throw new \RuntimeException('Failed to get container extension from LoggerBundle.'); + } + + return [$extension]; + } +} diff --git a/src/RunOpenCode/Component/Dataset/tests/Model/ItemTest.php b/src/RunOpenCode/Component/Dataset/tests/Model/ItemTest.php index ede9b80..540ed09 100644 --- a/src/RunOpenCode/Component/Dataset/tests/Model/ItemTest.php +++ b/src/RunOpenCode/Component/Dataset/tests/Model/ItemTest.php @@ -27,7 +27,8 @@ public function array_access(): void { $item = new Item('1', 1); - $this->assertTrue(isset($item[0], $item[1])); + $this->assertArrayHasKey(0, $item); // @phpstan-ignore-line argument.type + $this->assertArrayHasKey(1, $item); // @phpstan-ignore-line argument.type $this->assertSame('1', $item[0]); $this->assertSame(1, $item[1]); } diff --git a/src/RunOpenCode/Component/Logger/README.md b/src/RunOpenCode/Component/Logger/README.md new file mode 100644 index 0000000..e69de29 diff --git a/src/RunOpenCode/Component/Logger/composer.json b/src/RunOpenCode/Component/Logger/composer.json new file mode 100644 index 0000000..ffef5d5 --- /dev/null +++ b/src/RunOpenCode/Component/Logger/composer.json @@ -0,0 +1,30 @@ +{ + "name": "runopencode/logger", + "description": "Wrapper for psr/logger with additional method to log exceptions.", + "license": "MIT", + "authors": [ + { + "name": "Nikola Svitlica a.k.a TheCelavi", + "email": "thecelavi@runopencode.com" + }, + { + "name": "Stefan Veljancic", + "email": "veljancicstefan@gmail.com" + } + ], + "require": { + "php": ">=8.4", + "psr/log": "^2.0|^3.0" + }, + "autoload": { + "psr-4": { + "RunOpenCode\\Component\\Logger\\": "src/" + } + }, + "config": { + "sort-packages": true + }, + "version": "0.2", + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/src/RunOpenCode/Component/Logger/src/Contract/LoggerContextInterface.php b/src/RunOpenCode/Component/Logger/src/Contract/LoggerContextInterface.php new file mode 100644 index 0000000..db3579e --- /dev/null +++ b/src/RunOpenCode/Component/Logger/src/Contract/LoggerContextInterface.php @@ -0,0 +1,24 @@ + $contextProviders Context providers to enrich log context. + */ + public function __construct( + private PsrLoggerInterface $decorated, + private iterable $contextProviders = [], + private bool $debug = false, + private string $defaultLevel = LogLevel::CRITICAL + ) { + if (!$this->isValidLogLevel($defaultLevel)) { + throw new \InvalidArgumentException(\sprintf( + 'Provided value "%s" for default log level is not known (known values are "%s").', + $defaultLevel, + \implode('", "', self::getLogLevels()) + )); + } + } + + /** + * {@inheritdoc} + */ + public function exception(\Throwable $exception, \Stringable|string|null $message = null, array $context = [], ?string $level = null): void + { + $this->logException($exception, $message, $context, $level); + + if ($this->debug) { + throw $exception; + } + } + + /** + * {@inheritdoc} + */ + public function throw(\Throwable $exception, \Stringable|string|null $message = null, array $context = [], ?string $level = null): void + { + $this->logException($exception, $message, $context, $level); + + throw $exception; + } + + /** + * {@inheritdoc} + */ + public function emergency(\Stringable|string $message, array $context = []): void + { + $this->log(LogLevel::EMERGENCY, $message, $context); + } + + /** + * {@inheritdoc} + */ + public function alert(\Stringable|string $message, array $context = []): void + { + $this->log(LogLevel::ALERT, $message, $context); + } + + /** + * {@inheritdoc} + */ + public function critical(\Stringable|string $message, array $context = []): void + { + $this->log(LogLevel::CRITICAL, $message, $context); + } + + /** + * {@inheritdoc} + */ + public function error(\Stringable|string $message, array $context = []): void + { + $this->log(LogLevel::ERROR, $message, $context); + } + + /** + * {@inheritdoc} + */ + public function warning(\Stringable|string $message, array $context = []): void + { + $this->log(LogLevel::WARNING, $message, $context); + } + + /** + * {@inheritdoc} + */ + public function notice(\Stringable|string $message, array $context = []): void + { + $this->log(LogLevel::NOTICE, $message, $context); + } + + /** + * {@inheritdoc} + */ + public function info(\Stringable|string $message, array $context = []): void + { + $this->log(LogLevel::INFO, $message, $context); + } + + /** + * {@inheritdoc} + */ + public function debug(\Stringable|string $message, array $context = []): void + { + $this->log(LogLevel::DEBUG, $message, $context); + } + + /** + * {@inheritdoc} + */ + public function log($level, \Stringable|string $message, array $context = []): void + { + $collected = []; + + foreach ($this->contextProviders as $provider) { + $collected[] = $provider->get($context); + } + + $this->decorated->log($level, $message, \array_merge($context, ...$collected)); + } + + /** + * Get known log levels defined by psr/log. + * + * @return list + */ + public static function getLogLevels(): array + { + /** @var list|null $levels */ + static $levels; + + if (!isset($levels)) { + /** @var list $levels */ + $levels = \array_values(new \ReflectionClass(LogLevel::class)->getConstants(\ReflectionClassConstant::IS_PUBLIC)); + } + + return $levels; + } + + /** + * @param mixed[] $context + * + * @throws \Throwable + */ + private function logException(\Throwable $exception, \Stringable|string|null $message = null, array $context = [], ?string $level = null): void + { + $message = $message ? (string)$message : $exception->getMessage(); + $level = $level ?? $this->defaultLevel; + + if (!$this->isValidLogLevel($level)) { + throw new \InvalidArgumentException(\sprintf( + 'Provided value "%s" for log level is not in known (known values are "%s").', + $level, + \implode('", "', self::getLogLevels()) + )); + } + + $this->{$level}($message, \array_merge( + $context, + ['exception' => $exception] + )); + } + + /** + * Check if provided log level is defined by psr/log. + */ + private function isValidLogLevel(string $level): bool + { + /** @var array|null $levels */ + static $levels; + + if (!isset($levels)) { + /** @var array $levels */ + $levels = \array_combine(self::getLogLevels(), self::getLogLevels()); + } + + return isset($levels[$level]); + } +} diff --git a/src/RunOpenCode/Component/Logger/tests/LoggerTest.php b/src/RunOpenCode/Component/Logger/tests/LoggerTest.php new file mode 100644 index 0000000..7726231 --- /dev/null +++ b/src/RunOpenCode/Component/Logger/tests/LoggerTest.php @@ -0,0 +1,168 @@ +decorated = $this->createMock(LoggerInterface::class); + } + + protected function tearDown(): void + { + parent::tearDown(); + unset($this->decorated); + } + + /** + * @param non-empty-string $exceptionMessage + * @param non-empty-string|null $loggerMessage + * @param LogLevel::*|null $logLevel + */ + #[Test] + #[DataProvider('get_data_for_exception')] + public function exception( + string $exceptionMessage, + ?string $loggerMessage, + ?string $logLevel, + string $expectedMessage, + ): void { + $exception = new \RuntimeException($exceptionMessage); + + $this + ->decorated + ->expects($this->once()) + ->method('log') + ->with( + $logLevel ?? LogLevel::CRITICAL, + $this->stringContains($expectedMessage), + ['exception' => $exception] + ); + + new Logger($this->decorated)->exception($exception, $loggerMessage, [], $logLevel); + } + + /** + * @return iterable + */ + public static function get_data_for_exception(): iterable + { + yield 'It logs exception with default message and default log level.' => ['Test exception', null, null, 'Test exception']; + yield 'It logs exception with custom message and default log level.' => ['Test exception', 'Custom message', null, 'Custom message']; + yield 'It logs exception with custom message and custom alert level.' => ['Test exception', 'Custom message', LogLevel::ALERT, 'Custom message']; + } + + #[Test] + public function exception_with_context(): void + { + $exception = new \RuntimeException('Test exception'); + $this + ->decorated + ->expects($this->once()) + ->method('log') + ->with( + LogLevel::CRITICAL, + 'Test exception', + ['foo' => 'bar', 'exception' => $exception], + ); + + new Logger($this->decorated)->exception($exception, null, ['foo' => 'bar']); + } + + #[Test] + public function exception_throws_in_debug_mode(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Test exception'); + + $exception = new \RuntimeException('Test exception'); + + $this + ->decorated + ->expects($this->once()) + ->method('log') + ->with( + LogLevel::CRITICAL, + 'Test exception', + ['exception' => $exception] + ); + + new Logger($this->decorated, [], true)->exception($exception); + } + + #[Test] + public function throw(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Test exception'); + + $exception = new \RuntimeException('Test exception'); + + $this + ->decorated + ->expects($this->once()) + ->method('log') + ->with( + LogLevel::CRITICAL, + 'Test exception', + ['exception' => $exception] + ); + + new Logger($this->decorated, [], true)->throw($exception); + } + + /** + * @param LogLevel::* $method + */ + #[Test] + #[DataProvider('get_data_for_level_methods')] + public function level_methods(string $method): void + { + $this + ->decorated + ->expects($this->once()) + ->method('log') + ->with($method, \sprintf('Message for "%s".', $method), []); + + new Logger($this->decorated)->{$method}(\sprintf('Message for "%s".', $method)); + } + + /** + * @return iterable + */ + public static function get_data_for_level_methods(): iterable + { + /** @var list $levels */ + $levels = new \ReflectionClass(LogLevel::class)->getConstants(); + + foreach ($levels as $level) { + yield \sprintf('Method %s()', $level) => [$level]; + } + } + + #[Test] + public function log(): void + { + $this->decorated->expects($this->once()) + ->method('log') + ->with(LogLevel::INFO, 'Log message'); + + $logger = new Logger($this->decorated); + + $logger->log(LogLevel::INFO, 'Log message'); + } +} diff --git a/src/RunOpenCode/Component/Query/src/Doctrine/Dbal/Adapter.php b/src/RunOpenCode/Component/Query/src/Doctrine/Dbal/Adapter.php index 0fc5cbf..1d9929c 100644 --- a/src/RunOpenCode/Component/Query/src/Doctrine/Dbal/Adapter.php +++ b/src/RunOpenCode/Component/Query/src/Doctrine/Dbal/Adapter.php @@ -176,9 +176,7 @@ public function query(string $query, ExecutionInterface $configuration, ?Paramet { // Prepare query invocation closure. $invocation = static function(Connection $connection) use ($query, $configuration, $parameters): ResultInterface { - \assert(null !== $configuration->connection, new LogicException(\sprintf( - 'Connection must be provided in execution configuration.' - ))); + \assert(null !== $configuration->connection, new LogicException('Connection must be provided in execution configuration.')); return new Result(new DbalDataset($configuration->connection, $connection->executeQuery( $query, @@ -197,9 +195,7 @@ public function statement(string $query, ExecutionInterface $configuration, ?Par { // Prepare statement invocation closure. $invocation = static function(Connection $connection) use ($query, $configuration, $parameters): AffectedInterface { - \assert(null !== $configuration->connection, new LogicException(\sprintf( - 'Connection must be provided in execution configuration.' - ))); + \assert(null !== $configuration->connection, new LogicException('Connection must be provided in execution configuration.')); /** @var non-negative-int $affected */ $affected = (int)$connection->executeStatement( diff --git a/src/RunOpenCode/Component/Query/src/functions.php b/src/RunOpenCode/Component/Query/src/functions.php index 50948a1..a8e7cdf 100644 --- a/src/RunOpenCode/Component/Query/src/functions.php +++ b/src/RunOpenCode/Component/Query/src/functions.php @@ -74,8 +74,6 @@ function enum_to_scalar(?\UnitEnum $value): int|string|null * * @param int|string|null $scalar Value to transform to enum. * @param class-string<\UnitEnum> $enum Enum type to use for casting. - * - * @return \UnitEnum|null */ function scalar_to_enum(int|string|null $scalar, string $enum): ?\UnitEnum { diff --git a/src/RunOpenCode/Component/Query/tests/Doctrine/Dbal/Middleware/ConvertedTest.php b/src/RunOpenCode/Component/Query/tests/Doctrine/Dbal/Middleware/ConvertedTest.php index 261694d..034e21c 100644 --- a/src/RunOpenCode/Component/Query/tests/Doctrine/Dbal/Middleware/ConvertedTest.php +++ b/src/RunOpenCode/Component/Query/tests/Doctrine/Dbal/Middleware/ConvertedTest.php @@ -130,7 +130,7 @@ public function vector(string $query, Convert $configuration, array $expected, s $this->assertCount(\count($expected), $vector); - foreach ($expected as $index => $value) { + foreach (\array_keys($expected) as $index) { match ($assert) { 'assertBoolean' => $this->assertBoolean($expected[$index], $vector[$index]), 'assertFloat' => $this->assertFloat($expected[$index], $vector[$index]), diff --git a/src/RunOpenCode/Component/Query/tests/Functions/ToRegexTest.php b/src/RunOpenCode/Component/Query/tests/Functions/ToRegexTest.php index 64fba54..8c7a725 100644 --- a/src/RunOpenCode/Component/Query/tests/Functions/ToRegexTest.php +++ b/src/RunOpenCode/Component/Query/tests/Functions/ToRegexTest.php @@ -30,5 +30,7 @@ public static function get_data_for_glob_matches(): iterable yield '*.twig, @foo/bar.sql.twig' => ['*.twig', '@foo/bar.sql.twig', false]; yield '*.twig, foo.sql.twig' => ['*.twig', 'foo.sql.twig', true]; yield '**/*.twig, @foo/bar.sql.twig' => ['**/*.twig', '@foo/bar.sql.twig', true]; + // Fixed in @see https://github.com/symfony/symfony/issues/62737 + yield '**/*.twig, foo.sql.twig' => ['**/*.twig', 'foo.sql.twig', true]; } }