diff --git a/.gitattributes b/.gitattributes index ed6e29a8a..a1e258dea 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,7 @@ /doc export-ignore /tests export-ignore /.* export-ignore +/phpstan* export-ignore /phpunit.xml.dist export-ignore +/_config.yml export-ignore +/UPGRADE.md export-ignore diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..17efe9939 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: [Seldaek] +tidelift: "packagist/monolog/monolog" diff --git a/.github/ISSUE_TEMPLATE/Bug_Report.md b/.github/ISSUE_TEMPLATE/Bug_Report.md new file mode 100644 index 000000000..df38260f1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Bug_Report.md @@ -0,0 +1,9 @@ +--- +name: Bug Report +about: Create a bug report +labels: Bug +--- + +Monolog version 1|2|3? + +Write your bug report here. diff --git a/.github/ISSUE_TEMPLATE/Feature.md b/.github/ISSUE_TEMPLATE/Feature.md new file mode 100644 index 000000000..9ef03eecd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Feature.md @@ -0,0 +1,7 @@ +--- +name: Feature +about: Suggest a new feature or enhancement +labels: Feature +--- + +Write your suggestion here. diff --git a/.github/ISSUE_TEMPLATE/Question.md b/.github/ISSUE_TEMPLATE/Question.md new file mode 100644 index 000000000..f62632c48 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Question.md @@ -0,0 +1,9 @@ +--- +name: Question +about: Ask a question regarding software usage +labels: Support +--- + +Monolog version 1|2|3? + +Write your question here. diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 000000000..3cc8c1dec --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,6 @@ +# Reporting a vulnerability + +If you have found any issues that might have security implications, +please send a report privately to j.boggiano@seld.be + +Do not report security reports publicly. diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml new file mode 100644 index 000000000..59781f7a7 --- /dev/null +++ b/.github/workflows/continuous-integration.yml @@ -0,0 +1,236 @@ +name: "Continuous Integration" + +on: + - push + - pull_request + +jobs: + tests: + name: "CI (PHP ${{ matrix.php-version }}, ${{ matrix.dependencies }} deps)" + + runs-on: "${{ matrix.operating-system }}" + + strategy: + fail-fast: false + + matrix: + php-version: + - "8.1" + + dependencies: [highest] + + composer-options: [""] + + operating-system: + - "ubuntu-latest" + + include: + - php-version: "8.1" + dependencies: lowest + operating-system: ubuntu-latest + - php-version: "8.2" + dependencies: highest + operating-system: ubuntu-latest + composer-options: "--ignore-platform-req=php+" + + steps: + - name: "Checkout" + uses: "actions/checkout@v2" + + - name: Run CouchDB + timeout-minutes: 3 + continue-on-error: true + uses: "cobot/couchdb-action@master" + with: + couchdb version: '2.3.1' + + - name: Run MongoDB + uses: supercharge/mongodb-github-action@1.7.0 + with: + mongodb-version: 5.0 + + - name: "Install PHP" + uses: "shivammathur/setup-php@v2" + with: + coverage: "none" + php-version: "${{ matrix.php-version }}" + extensions: mongodb, redis, amqp + tools: "composer:v2" + ini-values: "memory_limit=-1" + + - name: Add require for mongodb/mongodb to make tests runnable + run: 'composer require mongodb/mongodb --dev --no-update' + + - name: "Change dependencies" + run: | + composer require --no-update --no-interaction --dev elasticsearch/elasticsearch:^7 + composer config --no-plugins allow-plugins.ocramius/package-versions true + + - name: "Update dependencies with composer" + uses: "ramsey/composer-install@v2" + with: + dependency-versions: "${{ matrix.dependencies }}" + composer-options: "${{ matrix.composer-options }}" + + - name: "Run tests" + run: "composer exec phpunit -- --exclude-group Elasticsearch,Elastica --verbose" + + - name: "Run tests with psr/log 3" + if: "contains(matrix.dependencies, 'highest') && matrix.php-version >= '8.0'" + run: | + composer remove --no-update --dev graylog2/gelf-php ruflin/elastica elasticsearch/elasticsearch rollbar/rollbar + composer require --no-update psr/log:^3 + composer update ${{ matrix.composer-options }} + composer exec phpunit -- --exclude-group Elasticsearch,Elastica --verbose + + tests-es-7: + name: "CI with ES ${{ matrix.es-version }} on PHP ${{ matrix.php-version }}" + + needs: "tests" + + runs-on: "${{ matrix.operating-system }}" + + strategy: + fail-fast: false + + matrix: + operating-system: + - "ubuntu-latest" + + php-version: + - "8.1" + + dependencies: + - "highest" + - "lowest" + + es-version: + - "7.0.0" + - "7.17.0" + + steps: + - name: "Checkout" + uses: "actions/checkout@v2" + + # required for elasticsearch + - name: Configure sysctl limits + run: | + sudo swapoff -a + sudo sysctl -w vm.swappiness=1 + sudo sysctl -w fs.file-max=262144 + sudo sysctl -w vm.max_map_count=262144 + + - name: Run Elasticsearch + timeout-minutes: 3 + uses: elastic/elastic-github-actions/elasticsearch@master + with: + stack-version: "${{ matrix.es-version }}" + + - name: "Install PHP" + uses: "shivammathur/setup-php@v2" + with: + coverage: "none" + php-version: "${{ matrix.php-version }}" + extensions: mongodb, redis, amqp + tools: "composer:v2" + ini-values: "memory_limit=-1" + + - name: "Change dependencies" + run: "composer require --no-update --no-interaction --dev elasticsearch/elasticsearch:^${{ matrix.es-version }}" + + - name: "Allow composer plugin to run" + if: "matrix.php-version == '7.4' && matrix.dependencies == 'lowest'" + run: "composer config allow-plugins.ocramius/package-versions true" + + - name: "Update dependencies with composer" + uses: "ramsey/composer-install@v2" + with: + dependency-versions: "${{ matrix.dependencies }}" + + - name: "Run tests" + run: "composer exec phpunit -- --group Elasticsearch,Elastica --verbose" + + - name: "Run tests with psr/log 3" + if: "contains(matrix.dependencies, 'highest') && matrix.php-version >= '8.0'" + run: | + composer remove --no-update --dev graylog2/gelf-php ruflin/elastica elasticsearch/elasticsearch rollbar/rollbar + composer require --no-update --no-interaction --dev ruflin/elastica elasticsearch/elasticsearch:^7 + composer require --no-update psr/log:^3 + composer update -W + composer exec phpunit -- --group Elasticsearch,Elastica --verbose + + tests-es-8: + name: "CI with ES ${{ matrix.es-version }} on PHP ${{ matrix.php-version }}" + + needs: "tests" + + runs-on: "${{ matrix.operating-system }}" + + strategy: + fail-fast: false + + matrix: + operating-system: + - "ubuntu-latest" + + php-version: + - "8.1" + + dependencies: + - "highest" + - "lowest" + + es-version: + - "8.0.0" + - "8.2.0" + + steps: + - name: "Checkout" + uses: "actions/checkout@v2" + + # required for elasticsearch + - name: Configure sysctl limits + run: | + sudo swapoff -a + sudo sysctl -w vm.swappiness=1 + sudo sysctl -w fs.file-max=262144 + sudo sysctl -w vm.max_map_count=262144 + + - name: Run Elasticsearch + timeout-minutes: 3 + uses: elastic/elastic-github-actions/elasticsearch@master + with: + stack-version: "${{ matrix.es-version }}" + + - name: "Install PHP" + uses: "shivammathur/setup-php@v2" + with: + coverage: "none" + php-version: "${{ matrix.php-version }}" + extensions: mongodb, redis, amqp + tools: "composer:v2" + ini-values: "memory_limit=-1" + + - name: "Change dependencies" + run: | + composer remove --no-update --dev graylog2/gelf-php ruflin/elastica elasticsearch/elasticsearch rollbar/rollbar + composer require --no-update --no-interaction --dev elasticsearch/elasticsearch:^8 + + - name: "Allow composer plugin to run" + if: "matrix.php-version == '7.4' && matrix.dependencies == 'lowest'" + run: "composer config allow-plugins.ocramius/package-versions true" + + - name: "Update dependencies with composer" + uses: "ramsey/composer-install@v2" + with: + dependency-versions: "${{ matrix.dependencies }}" + + - name: "Run tests" + run: "composer exec phpunit -- --group Elasticsearch,Elastica --verbose" + + - name: "Run tests with psr/log 3" + if: "contains(matrix.dependencies, 'highest') && matrix.php-version >= '8.0'" + run: | + composer require --no-update psr/log:^3 + composer update -W + composer exec phpunit -- --group Elasticsearch,Elastica --verbose diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..5011a321b --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,31 @@ +name: "PHP Lint" + +on: + push: + pull_request: + +jobs: + tests: + name: "Lint" + + runs-on: ubuntu-latest + + strategy: + matrix: + php-version: + - "8.1" + + steps: + - name: "Checkout" + uses: "actions/checkout@v2" + + - name: "Install PHP" + uses: "shivammathur/setup-php@v2" + with: + coverage: "none" + extensions: "intl" + ini-values: "memory_limit=-1" + php-version: "${{ matrix.php-version }}" + + - name: "Lint PHP files" + run: "find src/ -type f -name '*.php' -print0 | xargs -0 -L1 -P4 -- php -l -f" diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml new file mode 100644 index 000000000..a5ee083c0 --- /dev/null +++ b/.github/workflows/phpstan.yml @@ -0,0 +1,50 @@ +name: "PHPStan" + +on: + - push + - pull_request + +env: + COMPOSER_FLAGS: "--ansi --no-interaction --no-progress --prefer-dist" + +jobs: + tests: + name: "PHPStan" + + runs-on: ubuntu-latest + + strategy: + matrix: + php-version: + - "8.1" + + steps: + - name: "Checkout" + uses: "actions/checkout@v2" + + - name: "Install PHP" + uses: "shivammathur/setup-php@v2" + with: + coverage: "none" + php-version: "${{ matrix.php-version }}" + extensions: mongodb, redis, amqp + + - name: Get composer cache directory + id: composercache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composercache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Add require for mongodb/mongodb to make tests runnable + run: 'composer require ${{ env.COMPOSER_FLAGS }} mongodb/mongodb --dev --no-update' + + - name: "Install latest dependencies" + run: "composer update ${{ env.COMPOSER_FLAGS }}" + + - name: Run PHPStan + run: composer phpstan diff --git a/.gitignore b/.gitignore index 0640915db..9418cccf3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ composer.phar phpunit.xml composer.lock .DS_Store -.php_cs.cache +.php-cs-fixer.cache .hg +.phpunit.result.cache diff --git a/.php_cs b/.php-cs-fixer.php similarity index 78% rename from .php_cs rename to .php-cs-fixer.php index 6fc3ca2ea..839368490 100644 --- a/.php_cs +++ b/.php-cs-fixer.php @@ -17,26 +17,26 @@ ->in(__DIR__.'/tests') ; -return PhpCsFixer\Config::create() - ->setUsingCache(true) - ->setRiskyAllowed(true) - ->setRules(array( +$config = new PhpCsFixer\Config(); +return $config->setRules(array( '@PSR2' => true, // some rules disabled as long as 1.x branch is maintained - 'binary_operator_spaces' => array( + 'array_syntax' => ['syntax' => 'short'], + 'binary_operator_spaces' => [ 'default' => null, - ), + ], 'blank_line_before_statement' => ['statements' => ['continue', 'declare', 'return', 'throw', 'try']], 'cast_spaces' => ['space' => 'single'], 'header_comment' => ['header' => $header], 'include' => true, - 'class_attributes_separation' => ['elements' => ['method']], + 'class_attributes_separation' => array('elements' => array('method' => 'one', 'trait_import' => 'none')), 'no_blank_lines_after_class_opening' => true, 'no_blank_lines_after_phpdoc' => true, 'no_empty_statement' => true, - 'no_extra_consecutive_blank_lines' => true, + 'no_extra_blank_lines' => true, 'no_leading_import_slash' => true, 'no_leading_namespace_whitespace' => true, + 'no_superfluous_phpdoc_tags' => ['allow_mixed' => true], 'no_trailing_comma_in_singleline_array' => true, 'no_unused_imports' => true, 'no_whitespace_in_blank_line' => true, @@ -49,13 +49,14 @@ //'phpdoc_scalar' => true, 'phpdoc_trim' => true, //'phpdoc_types' => true, - 'psr0' => true, - //'array_syntax' => array('syntax' => 'short'), + 'psr_autoloading' => ['dir' => 'src'], 'declare_strict_types' => true, 'single_blank_line_before_namespace' => true, 'standardize_not_equals' => true, 'ternary_operator_spaces' => true, - 'trailing_comma_in_multiline_array' => true, + 'trailing_comma_in_multiline' => true, )) + ->setUsingCache(true) + ->setRiskyAllowed(true) ->setFinder($finder) ; diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 045161507..000000000 --- a/.travis.yml +++ /dev/null @@ -1,28 +0,0 @@ -language: php - -sudo: false -dist: trusty - -php: - - 7.1 - - 7.2 - - nightly - -cache: - directories: - - $HOME/.composer/cache - -matrix: - include: - - php: 7.1 - env: deps=low - fast_finish: true - -before_script: - - echo "extension = redis.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini - - echo "extension = mongodb.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini - - echo "extension = amqp.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini - - if [ "$deps" == "low" ]; then composer update --prefer-dist --prefer-lowest --prefer-stable; fi - - if [ "$deps" != "low" ]; then composer install --prefer-dist; fi - -script: composer test diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c033f931..fa0acea36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,319 @@ -### 1.24.0 (2018-06-xx) - +### 3.2.0 (2022-07-24) + + * Deprecated `CubeHandler` and `PHPConsoleHandler` as both projects are abandoned and those should not be used anymore (#1734) + * Marked `Logger` `@final` as it should not be extended, prefer composition or talk to us if you are missing something + * Added RFC 5424 level (`7` to `0`) support to `Logger::log` and `Logger::addRecord` to increase interoperability (#1723) + * Added `SyslogFormatter` to output syslog-like files which can be consumed by tools like [lnav](https://lnav.org/) (#1689) + * Added support for `__toString` for objects which are not json serializable in `JsonFormatter` (#1733) + * Added `GoogleCloudLoggingFormatter` (#1719) + * Added support for Predis 2.x (#1732) + * Added `AmqpHandler->setExtraAttributes` to allow configuring attributes when using an AMQPExchange (#1724) + * Fixed serialization/unserialization of handlers to make sure private properties are included (#1727) + * Fixed allowInlineLineBreaks in LineFormatter causing issues with windows paths containing `\n` or `\r` sequences (#1720) + * Fixed max normalization depth not being taken into account when formatting exceptions with a deep chain of previous exceptions (#1726) + * Fixed PHP 8.2 deprecation warnings (#1722) + * Fixed rare race condition or filesystem issue where StreamHandler is unable to create the directory the log should go into yet it exists already (#1678) + +### 3.1.0 (2022-06-09) + + * Added `$datetime` parameter to `Logger::addRecord` as low level API to allow logging into the past or future (#1682) + * Added `Logger::useLoggingLoopDetection` to allow disabling cyclic logging detection in concurrent frameworks (#1681) + * Fixed handling of fatal errors if callPrevious is disabled in ErrorHandler (#1670) + * Fixed interop issue by removing the need for a return type in ProcessorInterface (#1680) + * Marked the reusable `Monolog\Test\TestCase` class as `@internal` to make sure PHPStorm does not show it above PHPUnit, you may still use it to test your own handlers/etc though (#1677) + * Fixed RotatingFileHandler issue when the date format contained slashes (#1671) + +### 3.0.0 (2022-05-10) + +Changes from RC1 + +- The `Monolog\LevelName` enum does not exist anymore, use `Monolog\Level->getName()` instead. + +### 3.0.0-RC1 (2022-05-08) + +This is mostly a cleanup release offering stronger type guarantees for integrators with the +array->object/enum changes, but there is no big new feature for end users. + +See [UPGRADE notes](UPGRADE.md#300) for details on all breaking changes especially if you are extending/implementing Monolog classes/interfaces. + +Noteworthy BC Breaks: + +- The minimum supported PHP version is now `8.1.0`. +- Log records have been converted from an array to a [`Monolog\LogRecord` object](src/Monolog/LogRecord.php) + with public (and mostly readonly) properties. e.g. instead of doing + `$record['context']` use `$record->context`. + In formatters or handlers if you rather need an array to work with you can use `$record->toArray()` + to get back a Monolog 1/2 style record array. This will contain the enum values instead of enum cases + in the `level` and `level_name` keys to be more backwards compatible and use simpler data types. +- `FormatterInterface`, `HandlerInterface`, `ProcessorInterface`, etc. changed to contain `LogRecord $record` + instead of `array $record` parameter types. If you want to support multiple Monolog versions this should + be possible by type-hinting nothing, or `array|LogRecord` if you support PHP 8.0+. You can then code + against the $record using Monolog 2 style as LogRecord implements ArrayAccess for BC. + The interfaces do not require a `LogRecord` return type even where it would be applicable, but if you only + support Monolog 3 in integration code I would recommend you use `LogRecord` return types wherever fitting + to ensure forward compatibility as it may be added in Monolog 4. +- Log levels are now enums [`Monolog\Level`](src/Monolog/Level.php) and [`Monolog\LevelName`](src/Monolog/LevelName.php) +- Removed deprecated SwiftMailerHandler, migrate to SymfonyMailerHandler instead. +- `ResettableInterface::reset()` now requires a void return type. +- All properties have had types added, which may require you to do so as well if you extended + a Monolog class and declared the same property. + +New deprecations: + +- `Logger::DEBUG`, `Logger::ERROR`, etc. are now deprecated in favor of the `Monolog\Level` enum. + e.g. instead of `Logger::WARNING` use `Level::Warning` if you need to pass the enum case + to Monolog or one of its handlers, or `Level::Warning->value` if you need the integer + value equal to what `Logger::WARNING` was giving you. +- `Logger::getLevelName()` is now deprecated. + +### 2.8.0 (2022-07-24) + + * Deprecated `CubeHandler` and `PHPConsoleHandler` as both projects are abandoned and those should not be used anymore (#1734) + * Added RFC 5424 level (`7` to `0`) support to `Logger::log` and `Logger::addRecord` to increase interoperability (#1723) + * Added support for `__toString` for objects which are not json serializable in `JsonFormatter` (#1733) + * Added `GoogleCloudLoggingFormatter` (#1719) + * Added support for Predis 2.x (#1732) + * Added `AmqpHandler->setExtraAttributes` to allow configuring attributes when using an AMQPExchange (#1724) + * Fixed serialization/unserialization of handlers to make sure private properties are included (#1727) + * Fixed allowInlineLineBreaks in LineFormatter causing issues with windows paths containing `\n` or `\r` sequences (#1720) + * Fixed max normalization depth not being taken into account when formatting exceptions with a deep chain of previous exceptions (#1726) + * Fixed PHP 8.2 deprecation warnings (#1722) + * Fixed rare race condition or filesystem issue where StreamHandler is unable to create the directory the log should go into yet it exists already (#1678) + +### 2.7.0 (2022-06-09) + + * Added `$datetime` parameter to `Logger::addRecord` as low level API to allow logging into the past or future (#1682) + * Added `Logger::useLoggingLoopDetection` to allow disabling cyclic logging detection in concurrent frameworks (#1681) + * Fixed handling of fatal errors if callPrevious is disabled in ErrorHandler (#1670) + * Marked the reusable `Monolog\Test\TestCase` class as `@internal` to make sure PHPStorm does not show it above PHPUnit, you may still use it to test your own handlers/etc though (#1677) + * Fixed RotatingFileHandler issue when the date format contained slashes (#1671) + +### 2.6.0 (2022-05-10) + + * Deprecated `SwiftMailerHandler`, use `SymfonyMailerHandler` instead + * Added `SymfonyMailerHandler` (#1663) + * Added ElasticSearch 8.x support to the ElasticsearchHandler (#1662) + * Added a way to filter/modify stack traces in LineFormatter (#1665) + * Fixed UdpSocket not being able to reopen/reconnect after close() + * Fixed infinite loops if a Handler is triggering logging while handling log records + +### 2.5.0 (2022-04-08) + + * Added `callType` to IntrospectionProcessor (#1612) + * Fixed AsMonologProcessor syntax to be compatible with PHP 7.2 (#1651) + +### 2.4.0 (2022-03-14) + + * Added [`Monolog\LogRecord`](src/Monolog/LogRecord.php) interface that can be used to type-hint records like `array|\Monolog\LogRecord $record` to be forward compatible with the upcoming Monolog 3 changes + * Added `includeStacktraces` constructor params to LineFormatter & JsonFormatter (#1603) + * Added `persistent`, `timeout`, `writingTimeout`, `connectionTimeout`, `chunkSize` constructor params to SocketHandler and derivatives (#1600) + * Added `AsMonologProcessor` PHP attribute which can help autowiring / autoconfiguration of processors if frameworks / integrations decide to make use of it. This is useless when used purely with Monolog (#1637) + * Added support for keeping native BSON types as is in MongoDBFormatter (#1620) + * Added support for a `user_agent` key in WebProcessor, disabled by default but you can use it by configuring the $extraFields you want (#1613) + * Added support for username/userIcon in SlackWebhookHandler (#1617) + * Added extension points to BrowserConsoleHandler (#1593) + * Added record message/context/extra info to exceptions thrown when a StreamHandler cannot open its stream to avoid completely losing the data logged (#1630) + * Fixed error handler signature to accept a null $context which happens with internal PHP errors (#1614) + * Fixed a few setter methods not returning `self` (#1609) + * Fixed handling of records going over the max Telegram message length (#1616) + +### 2.3.5 (2021-10-01) + + * Fixed regression in StreamHandler since 2.3.3 on systems with the memory_limit set to >=20GB (#1592) + +### 2.3.4 (2021-09-15) + + * Fixed support for psr/log 3.x (#1589) + +### 2.3.3 (2021-09-14) + + * Fixed memory usage when using StreamHandler and calling stream_get_contents on the resource you passed to it (#1578, #1577) + * Fixed support for psr/log 2.x (#1587) + * Fixed some type annotations + +### 2.3.2 (2021-07-23) + + * Fixed compatibility with PHP 7.2 - 7.4 when experiencing PCRE errors (#1568) + +### 2.3.1 (2021-07-14) + + * Fixed Utils::getClass handling of anonymous classes not being fully compatible with PHP 8 (#1563) + * Fixed some `@inheritDoc` annotations having the wrong case + +### 2.3.0 (2021-07-05) + + * Added a ton of PHPStan type annotations as well as type aliases on Monolog\Logger for Record, Level and LevelName that you can import (#1557) + * Added ability to customize date format when using JsonFormatter (#1561) + * Fixed FilterHandler not calling reset on its internal handler when reset() is called on it (#1531) + * Fixed SyslogUdpHandler not setting the timezone correctly on DateTimeImmutable instances (#1540) + * Fixed StreamHandler thread safety - chunk size set to 2GB now to avoid interlacing when doing concurrent writes (#1553) + +### 2.2.0 (2020-12-14) + + * Added JSON_PARTIAL_OUTPUT_ON_ERROR to default json encoding flags, to avoid dropping entire context data or even records due to an invalid subset of it somewhere + * Added setDateFormat to NormalizerFormatter (and Line/Json formatters by extension) to allow changing this after object creation + * Added RedisPubSubHandler to log records to a Redis channel using PUBLISH + * Added support for Elastica 7, and deprecated the $type argument of ElasticaFormatter which is not in use anymore as of Elastica 7 + * Added support for millisecond write timeouts in SocketHandler, you can now pass floats to setWritingTimeout, e.g. 0.2 is 200ms + * Added support for unix sockets in SyslogUdpHandler (set $port to 0 to make the $host a unix socket) + * Added handleBatch support for TelegramBotHandler + * Added RFC5424e extended date format including milliseconds to SyslogUdpHandler + * Added support for configuring handlers with numeric level values in strings (coming from e.g. env vars) + * Fixed Wildfire/FirePHP/ChromePHP handling of unicode characters + * Fixed PHP 8 issues in SyslogUdpHandler + * Fixed internal type error when mbstring is missing + +### 2.1.1 (2020-07-23) + + * Fixed removing of json encoding options + * Fixed type hint of $level not accepting strings in SendGridHandler and OverflowHandler + * Fixed SwiftMailerHandler not accepting email templates with an empty subject + * Fixed array access on null in RavenHandler + * Fixed unique_id in WebProcessor not being disableable + +### 2.1.0 (2020-05-22) + + * Added `JSON_INVALID_UTF8_SUBSTITUTE` to default json flags, so that invalid UTF8 characters now get converted to [�](https://en.wikipedia.org/wiki/Specials_(Unicode_block)#Replacement_character) instead of being converted from ISO-8859-15 to UTF8 as it was before, which was hardly a comprehensive solution + * Added `$ignoreEmptyContextAndExtra` option to JsonFormatter to skip empty context/extra entirely from the output + * Added `$parseMode`, `$disableWebPagePreview` and `$disableNotification` options to TelegramBotHandler + * Added tentative support for PHP 8 + * NormalizerFormatter::addJsonEncodeOption and removeJsonEncodeOption are now public to allow modifying default json flags + * Fixed GitProcessor type error when there is no git repo present + * Fixed normalization of SoapFault objects containing deeply nested objects as "detail" + * Fixed support for relative paths in RotatingFileHandler + +### 2.0.2 (2019-12-20) + + * Fixed ElasticsearchHandler swallowing exceptions details when failing to index log records + * Fixed normalization of SoapFault objects containing non-strings as "detail" in LineFormatter + * Fixed formatting of resources in JsonFormatter + * Fixed RedisHandler failing to use MULTI properly when passed a proxied Redis instance (e.g. in Symfony with lazy services) + * Fixed FilterHandler triggering a notice when handleBatch was filtering all records passed to it + * Fixed Turkish locale messing up the conversion of level names to their constant values + +### 2.0.1 (2019-11-13) + + * Fixed normalization of Traversables to avoid traversing them as not all of them are rewindable + * Fixed setFormatter/getFormatter to forward to the nested handler in FilterHandler, FingersCrossedHandler, BufferHandler, OverflowHandler and SamplingHandler + * Fixed BrowserConsoleHandler formatting when using multiple styles + * Fixed normalization of exception codes to be always integers even for PDOException which have them as numeric strings + * Fixed normalization of SoapFault objects containing non-strings as "detail" + * Fixed json encoding across all handlers to always attempt recovery of non-UTF-8 strings instead of failing the whole encoding + * Fixed ChromePHPHandler to avoid sending more data than latest Chrome versions allow in headers (4KB down from 256KB). + * Fixed type error in BrowserConsoleHandler when the context array of log records was not associative. + +### 2.0.0 (2019-08-30) + + * BC Break: This is a major release, see [UPGRADE.md](UPGRADE.md) for details if you are coming from a 1.x release + * BC Break: Logger methods log/debug/info/notice/warning/error/critical/alert/emergency now have explicit void return types + * Added FallbackGroupHandler which works like the WhatFailureGroupHandler but stops dispatching log records as soon as one handler accepted it + * Fixed support for UTF-8 when cutting strings to avoid cutting a multibyte-character in half + * Fixed normalizers handling of exception backtraces to avoid serializing arguments in some cases + * Fixed date timezone handling in SyslogUdpHandler + +### 2.0.0-beta2 (2019-07-06) + + * BC Break: This is a major release, see [UPGRADE.md](UPGRADE.md) for details if you are coming from a 1.x release + * BC Break: PHP 7.2 is now the minimum required PHP version. + * BC Break: Removed SlackbotHandler, RavenHandler and HipChatHandler, see [UPGRADE.md](UPGRADE.md) for details + * Added OverflowHandler which will only flush log records to its nested handler when reaching a certain amount of logs (i.e. only pass through when things go really bad) + * Added TelegramBotHandler to log records to a [Telegram](https://core.telegram.org/bots/api) bot account + * Added support for JsonSerializable when normalizing exceptions + * Added support for RFC3164 (outdated BSD syslog protocol) to SyslogUdpHandler + * Added SoapFault details to formatted exceptions + * Fixed DeduplicationHandler silently failing to start when file could not be opened + * Fixed issue in GroupHandler and WhatFailureGroupHandler where setting multiple processors would duplicate records + * Fixed GelfFormatter losing some data when one attachment was too long + * Fixed issue in SignalHandler restarting syscalls functionality + * Improved performance of LogglyHandler when sending multiple logs in a single request + +### 2.0.0-beta1 (2018-12-08) + + * BC Break: This is a major release, see [UPGRADE.md](UPGRADE.md) for details if you are coming from a 1.x release + * BC Break: PHP 7.1 is now the minimum required PHP version. + * BC Break: Quite a few interface changes, only relevant if you implemented your own handlers/processors/formatters + * BC Break: Removed non-PSR-3 methods to add records, all the `add*` (e.g. `addWarning`) methods as well as `emerg`, `crit`, `err` and `warn` + * BC Break: The record timezone is now set per Logger instance and not statically anymore + * BC Break: There is no more default handler configured on empty Logger instances + * BC Break: ElasticSearchHandler renamed to ElasticaHandler + * BC Break: Various handler-specific breaks, see [UPGRADE.md](UPGRADE.md) for details + * Added scalar type hints and return hints in all the places it was possible. Switched strict_types on for more reliability. + * Added DateTimeImmutable support, all record datetime are now immutable, and will toString/json serialize with the correct date format, including microseconds (unless disabled) + * Added timezone and microseconds to the default date format + * Added SendGridHandler to use the SendGrid API to send emails + * Added LogmaticHandler to use the Logmatic.io API to store log records + * Added SqsHandler to send log records to an AWS SQS queue + * Added ElasticsearchHandler to send records via the official ES library. Elastica users should now use ElasticaHandler instead of ElasticSearchHandler + * Added NoopHandler which is similar to the NullHandle but does not prevent the bubbling of log records to handlers further down the configuration, useful for temporarily disabling a handler in configuration files + * Added ProcessHandler to write log output to the STDIN of a given process + * Added HostnameProcessor that adds the machine's hostname to log records + * Added a `$dateFormat` option to the PsrLogMessageProcessor which lets you format DateTime instances nicely + * Added support for the PHP 7.x `mongodb` extension in the MongoDBHandler + * Fixed many minor issues in various handlers, and probably added a few regressions too + +### 1.26.1 (2021-05-28) + + * Fixed PHP 8.1 deprecation warning + +### 1.26.0 (2020-12-14) + + * Added $dateFormat and $removeUsedContextFields arguments to PsrLogMessageProcessor (backport from 2.x) + +### 1.25.5 (2020-07-23) + + * Fixed array access on null in RavenHandler + * Fixed unique_id in WebProcessor not being disableable + +### 1.25.4 (2020-05-22) + + * Fixed GitProcessor type error when there is no git repo present + * Fixed normalization of SoapFault objects containing deeply nested objects as "detail" + * Fixed support for relative paths in RotatingFileHandler + +### 1.25.3 (2019-12-20) + + * Fixed formatting of resources in JsonFormatter + * Fixed RedisHandler failing to use MULTI properly when passed a proxied Redis instance (e.g. in Symfony with lazy services) + * Fixed FilterHandler triggering a notice when handleBatch was filtering all records passed to it + * Fixed Turkish locale messing up the conversion of level names to their constant values + +### 1.25.2 (2019-11-13) + + * Fixed normalization of Traversables to avoid traversing them as not all of them are rewindable + * Fixed setFormatter/getFormatter to forward to the nested handler in FilterHandler, FingersCrossedHandler, BufferHandler and SamplingHandler + * Fixed BrowserConsoleHandler formatting when using multiple styles + * Fixed normalization of exception codes to be always integers even for PDOException which have them as numeric strings + * Fixed normalization of SoapFault objects containing non-strings as "detail" + * Fixed json encoding across all handlers to always attempt recovery of non-UTF-8 strings instead of failing the whole encoding + +### 1.25.1 (2019-09-06) + + * Fixed forward-compatible interfaces to be compatible with Monolog 1.x too. + +### 1.25.0 (2019-09-06) + + * Deprecated SlackbotHandler, use SlackWebhookHandler or SlackHandler instead + * Deprecated RavenHandler, use sentry/sentry 2.x and their Sentry\Monolog\Handler instead + * Deprecated HipChatHandler, migrate to Slack and use SlackWebhookHandler or SlackHandler instead + * Added forward-compatible interfaces and traits FormattableHandlerInterface, FormattableHandlerTrait, ProcessableHandlerInterface, ProcessableHandlerTrait. If you use modern PHP and want to make code compatible with Monolog 1 and 2 this can help. You will have to require at least Monolog 1.25 though. + * Added support for RFC3164 (outdated BSD syslog protocol) to SyslogUdpHandler + * Fixed issue in GroupHandler and WhatFailureGroupHandler where setting multiple processors would duplicate records + * Fixed issue in SignalHandler restarting syscalls functionality + * Fixed normalizers handling of exception backtraces to avoid serializing arguments in some cases + * Fixed ZendMonitorHandler to work with the latest Zend Server versions + * Fixed ChromePHPHandler to avoid sending more data than latest Chrome versions allow in headers (4KB down from 256KB). + +### 1.24.0 (2018-11-05) + + * BC Notice: If you are extending any of the Monolog's Formatters' `normalize` method, make sure you add the new `$depth = 0` argument to your function signature to avoid strict PHP warnings. + * Added a `ResettableInterface` in order to reset/reset/clear/flush handlers and processors + * Added a `ProcessorInterface` as an optional way to label a class as being a processor (mostly useful for autowiring dependency containers) + * Added a way to log signals being received using Monolog\SignalHandler * Added ability to customize error handling at the Logger level using Logger::setExceptionHandler * Added InsightOpsHandler to migrate users of the LogEntriesHandler - * Added protection to NormalizerHandler against circular and very deep structures, it now stops normalizing at a depth of 9 + * Added protection to NormalizerFormatter against circular and very deep structures, it now stops normalizing at a depth of 9 * Added capture of stack traces to ErrorHandler when logging PHP errors + * Added RavenHandler support for a `contexts` context or extra key to forward that to Sentry's contexts * Added forwarding of context info to FluentdFormatter * Added SocketHandler::setChunkSize to override the default chunk size in case you must send large log lines to rsyslog for example * Added ability to extend/override BrowserConsoleHandler @@ -19,6 +329,8 @@ * Fixed table row styling issues in HtmlFormatter * Fixed RavenHandler dropping the message when logging exception * Fixed WhatFailureGroupHandler skipping processors when using handleBatch + and implement it where possible + * Fixed display of anonymous class names ### 1.23.0 (2017-06-19) diff --git a/LICENSE b/LICENSE index 713dc045c..aa2a0426c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2011-2018 Jordi Boggiano +Copyright (c) 2011-2020 Jordi Boggiano Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 82bd4e7ef..87c7f664d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ -# Monolog - Logging for PHP [![Build Status](https://img.shields.io/travis/Seldaek/monolog.svg)](https://travis-ci.org/Seldaek/monolog) +# Monolog - Logging for PHP [![Continuous Integration](https://github.com/Seldaek/monolog/workflows/Continuous%20Integration/badge.svg?branch=main)](https://github.com/Seldaek/monolog/actions) [![Total Downloads](https://img.shields.io/packagist/dt/monolog/monolog.svg)](https://packagist.org/packages/monolog/monolog) [![Latest Stable Version](https://img.shields.io/packagist/v/monolog/monolog.svg)](https://packagist.org/packages/monolog/monolog) +> ⚠ This is the **documentation for Monolog 3.x**, if you are using older releases +> see the documentation for [Monolog 2.x](https://github.com/Seldaek/monolog/blob/2.x/README.md) or [Monolog 1.x](https://github.com/Seldaek/monolog/blob/1.x/README.md) ⚠ Monolog sends your logs to files, sockets, inboxes, databases and various web services. See the complete list of handlers below. Special handlers @@ -28,12 +30,13 @@ $ composer require monolog/monolog ```php pushHandler(new StreamHandler('path/to/your.log', Logger::WARNING)); +$log->pushHandler(new StreamHandler('path/to/your.log', Level::Warning)); // add records to the log $log->warning('Foo'); @@ -48,6 +51,12 @@ $log->error('Bar'); - [Extending Monolog](doc/04-extending.md) - [Log Record Structure](doc/message-structure.md) +## Support Monolog Financially + +Get supported Monolog and help fund the project with the [Tidelift Subscription](https://tidelift.com/subscription/pkg/packagist-monolog-monolog?utm_source=packagist-monolog-monolog&utm_medium=referral&utm_campaign=enterprise) or via [GitHub sponsorship](https://github.com/sponsors/Seldaek). + +Tidelift delivers commercial support and maintenance for the open source dependencies you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact dependencies you use. + ## Third Party Packages Third party handlers, formatters and processors are @@ -58,7 +67,13 @@ can also add your own there if you publish one. ### Requirements -- Monolog 2.x works with PHP 7.1 or above, use Monolog `^1.0` for PHP 5.3+ support. +- Monolog `^3.0` works with PHP 8.1 or above. +- Monolog `^2.5` works with PHP 7.2 or above. +- Monolog `^1.25` works with PHP 5.3 up to 8.1, but is not very maintained anymore and will not receive PHP support fixes anymore. + +### Support + +Monolog 1.x support is somewhat limited at this point and only important fixes will be done. You should migrate to Monolog 2 or 3 where possible to benefit from all the latest features and fixes. ### Submitting bugs and feature requests @@ -68,32 +83,35 @@ Bugs and feature request are tracked on [GitHub](https://github.com/Seldaek/mono - Frameworks and libraries using [PSR-3](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md) can be used very easily with Monolog since it implements the interface. -- [Symfony2](http://symfony.com) comes out of the box with Monolog. -- [Silex](http://silex.sensiolabs.org/) comes out of the box with Monolog. -- [Laravel 4 & 5](http://laravel.com/) come out of the box with Monolog. +- [Symfony](http://symfony.com) comes out of the box with Monolog. +- [Laravel](http://laravel.com/) comes out of the box with Monolog. - [Lumen](http://lumen.laravel.com/) comes out of the box with Monolog. -- [PPI](http://www.ppi.io/) comes out of the box with Monolog. +- [PPI](https://github.com/ppi/framework) comes out of the box with Monolog. - [CakePHP](http://cakephp.org/) is usable with Monolog via the [cakephp-monolog](https://github.com/jadb/cakephp-monolog) plugin. - [Slim](http://www.slimframework.com/) is usable with Monolog via the [Slim-Monolog](https://github.com/Flynsarmy/Slim-Monolog) log writer. - [XOOPS 2.6](http://xoops.org/) comes out of the box with Monolog. - [Aura.Web_Project](https://github.com/auraphp/Aura.Web_Project) comes out of the box with Monolog. -- [Nette Framework](http://nette.org/en/) can be used with Monolog via [Kdyby/Monolog](https://github.com/Kdyby/Monolog) extension. +- [Nette Framework](http://nette.org/en/) is usable with Monolog via the [contributte/monolog](https://github.com/contributte/monolog) or [orisai/nette-monolog](https://github.com/orisai/nette-monolog) extensions. - [Proton Micro Framework](https://github.com/alexbilbie/Proton) comes out of the box with Monolog. - [FuelPHP](http://fuelphp.com/) comes out of the box with Monolog. - [Equip Framework](https://github.com/equip/framework) comes out of the box with Monolog. - [Yii 2](http://www.yiiframework.com/) is usable with Monolog via the [yii2-monolog](https://github.com/merorafael/yii2-monolog) or [yii2-psr-log-target](https://github.com/samdark/yii2-psr-log-target) plugins. - [Hawkbit Micro Framework](https://github.com/HawkBitPhp/hawkbit) comes out of the box with Monolog. +- [SilverStripe 4](https://www.silverstripe.org/) comes out of the box with Monolog. +- [Drupal](https://www.drupal.org/) is usable with Monolog via the [monolog](https://www.drupal.org/project/monolog) module. +- [Aimeos ecommerce framework](https://aimeos.org/) is usable with Monolog via the [ai-monolog](https://github.com/aimeos/ai-monolog) extension. +- [Magento](https://magento.com/) comes out of the box with Monolog. ### Author Jordi Boggiano - -
-See also the list of [contributors](https://github.com/Seldaek/monolog/contributors) which participated in this project. +See also the list of [contributors](https://github.com/Seldaek/monolog/contributors) who participated in this project. ### License -Monolog is licensed under the MIT License - see the `LICENSE` file for details +Monolog is licensed under the MIT License - see the [LICENSE](LICENSE) file for details ### Acknowledgements -This library is heavily inspired by Python's [Logbook](http://packages.python.org/Logbook/) +This library is heavily inspired by Python's [Logbook](https://logbook.readthedocs.io/en/stable/) library, although most concepts have been adjusted to fit to the PHP world. diff --git a/UPGRADE.md b/UPGRADE.md index 81b6e90b2..0446803f7 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,12 +1,115 @@ +### 3.0.0 + +Overall / notable changes: + +- The minimum supported PHP version is now `8.1.0`. +- `Monolog\Logger::API` can be used to distinguish between a Monolog `3`, `2` or `1` + install when writing integration code. +- Log records have been converted from an array to a [`Monolog\LogRecord` object](src/Monolog/LogRecord.php) + with public (and mostly readonly) properties. e.g. instead of doing + `$record['context']` use `$record->context`. + In formatters or handlers if you rather need an array to work with you can use `$record->toArray()` + to get back a Monolog 1/2 style record array. This will contain the enum values instead of enum cases + in the `level` and `level_name` keys to be more backwards compatible and use simpler data types. +- `FormatterInterface`, `HandlerInterface`, `ProcessorInterface`, etc. changed to contain `LogRecord $record` + instead of `array $record` parameter types. If you want to support multiple Monolog versions this should + be possible by type-hinting nothing, or `array|LogRecord` if you support PHP 8.0+. You can then code + against the $record using Monolog 2 style as LogRecord implements ArrayAccess for BC. + The interfaces do not require a `LogRecord` return type even where it would be applicable, but if you only + support Monolog 3 in integration code I would recommend you use `LogRecord` return types wherever fitting + to ensure forward compatibility as it may be added in Monolog 4. +- Log levels are now stored as an enum [`Monolog\Level`](src/Monolog/Level.php) +- All properties have had types added, which may require you to do so as well if you extended + a Monolog class and declared the same property. + +#### Logger + +- `Logger::DEBUG`, `Logger::ERROR`, etc. are now deprecated in favor of the `Level` enum. + e.g. instead of `Logger::WARNING` use `Level::Warning` if you need to pass the enum case + to Monolog or one of its handlers, or `Level::Warning->value` if you need the integer + value equal to what `Logger::WARNING` was giving you. +- `Logger::$levels` has been removed. +- `Logger::getLevels` has been removed in favor of `Monolog\Level::VALUES` or `Monolog\Level::cases()`. +- `setExceptionHandler` now requires a `Closure` instance and not just any `callable`. + +#### HtmlFormatter + +- If you redefined colors in the `$logLevels` property you must now override the + `getLevelColor` method instead. + +#### NormalizerFormatter + +- A new `normalizeRecord` method is available as an extension point which is called + only when converting the LogRecord to an array. You may need this if you overrode + `format` previously as `parent::format` now needs to receive a LogRecord still + so you cannot modify it before. + +#### AbstractSyslogHandler + +- If you redefined syslog levels in the `$logLevels` property you must now override the + `toSyslogPriority` method instead. + +#### DynamoDbHandler + +- Dropped support for AWS SDK v2 + +#### FilterHandler + +- The factory callable to lazy load the nested handler must now be a `Closure` instance + and not just a `callable`. + +#### FingersCrossedHandler + +- The factory callable to lazy load the nested handler must now be a `Closure` instance + and not just a `callable`. + +#### GelfHandler + +- Dropped support for Gelf <1.1 and added support for graylog2/gelf-php v2.x. File, level + and facility are now passed in as additional fields (#1664)[https://github.com/Seldaek/monolog/pull/1664]. + +#### RollbarHandler + +- If you redefined rollbar levels in the `$logLevels` property you must now override the + `toRollbarLevel` method instead. + +#### SamplingHandler + +- The factory callable to lazy load the nested handler must now be a `Closure` instance + and not just a `callable`. + +#### SwiftMailerHandler + +- Removed deprecated SwiftMailer handler, migrate to SymfonyMailerHandler instead. + +#### ZendMonitorHandler + +- If you redefined zend monitor levels in the `$levelMap` property you must now override the + `toZendMonitorLevel` method instead. + +#### ResettableInterface + +- `reset()` now requires a void return type. + ### 2.0.0 -- The timezone is now set per Logger instance and not statically, either - via ->setTimezone or passed in the constructor. Calls to Logger::setTimezone - should be converted. +- `Monolog\Logger::API` can be used to distinguish between a Monolog `1` and `2` + install of Monolog when writing integration code. - Removed non-PSR-3 methods to add records, all the `add*` (e.g. `addWarning`) methods as well as `emerg`, `crit`, `err` and `warn`. +- DateTime are now formatted with a timezone and microseconds (unless disabled). + Various formatters and log output might be affected, which may mess with log parsing + in some cases. + +- The `datetime` in every record array is now a DateTimeImmutable, not that you + should have been modifying these anyway. + +- The timezone is now set per Logger instance and not statically, either + via ->setTimezone or passed in the constructor. Calls to Logger::setTimezone + should be converted. + - `HandlerInterface` has been split off and two new interfaces now exist for more granular controls: `ProcessableHandlerInterface` and `FormattableHandlerInterface`. Handlers not extending `AbstractHandler` @@ -14,4 +117,49 @@ - `HandlerInterface` now requires the `close` method to be implemented. This only impacts you if you implement the interface yourself, but you can extend - the new `Monolog\Handler\Handler` base class. + the new `Monolog\Handler\Handler` base class too. + +- There is no more default handler configured on empty Logger instances, if + you were relying on that you will not get any output anymore, make sure to + configure the handler you need. + +#### LogglyFormatter + +- The records' `datetime` is not sent anymore. Only `timestamp` is sent to Loggly. + +#### AmqpHandler + +- Log levels are not shortened to 4 characters anymore. e.g. a warning record + will be sent using the `warning.channel` routing key instead of `warn.channel` + as in 1.x. +- The exchange name does not default to 'log' anymore, and it is completely ignored + now for the AMQP extension users. Only PHPAmqpLib uses it if provided. + +#### RotatingFileHandler + +- The file name format must now contain `{date}` and the date format must be set + to one of the predefined FILE_PER_* constants to avoid issues with file rotation. + See `setFilenameFormat`. + +#### LogstashFormatter + +- Removed Logstash V0 support +- Context/extra prefix has been removed in favor of letting users configure the exact key being sent +- Context/extra data are now sent as an object instead of single keys + +#### HipChatHandler + +- Removed deprecated HipChat handler, migrate to Slack and use SlackWebhookHandler or SlackHandler instead + +#### SlackbotHandler + +- Removed deprecated SlackbotHandler handler, use SlackWebhookHandler or SlackHandler instead + +#### RavenHandler + +- Removed deprecated RavenHandler handler, use sentry/sentry 2.x and their Sentry\Monolog\Handler instead + +#### ElasticSearchHandler + +- As support for the official Elasticsearch library was added, the former ElasticSearchHandler has been + renamed to ElasticaHandler and the new one added as ElasticsearchHandler. diff --git a/_config.yml b/_config.yml new file mode 100644 index 000000000..c74188174 --- /dev/null +++ b/_config.yml @@ -0,0 +1 @@ +theme: jekyll-theme-slate \ No newline at end of file diff --git a/composer.json b/composer.json index bd253ee6b..3a48e6db4 100644 --- a/composer.json +++ b/composer.json @@ -2,47 +2,54 @@ "name": "monolog/monolog", "description": "Sends your logs to files, sockets, inboxes, databases and various web services", "keywords": ["log", "logging", "psr-3"], - "homepage": "http://github.com/Seldaek/monolog", + "homepage": "https://github.com/Seldaek/monolog", "type": "library", "license": "MIT", "authors": [ { "name": "Jordi Boggiano", "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" + "homepage": "https://seld.be" } ], "require": { - "php": "^7.1", - "psr/log": "^1.0.1" + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" }, "require-dev": { - "phpunit/phpunit": "^6.5", - "graylog2/gelf-php": "^1.4.2", - "sentry/sentry": "^0.13", - "ruflin/elastica": ">=0.90 <3.0", + "ext-json": "*", + "aws/aws-sdk-php": "^3.0", "doctrine/couchdb": "~1.0@dev", - "aws/aws-sdk-php": "^2.4.9 || ^3.0", - "php-amqplib/php-amqplib": "~2.4", - "swiftmailer/swiftmailer": "^5.3|^6.0", - "php-console/php-console": "^3.1.3", - "jakub-onderka/php-parallel-lint": "^0.9", + "elasticsearch/elasticsearch": "^7 || ^8", + "graylog2/gelf-php": "^1.4.2", + "guzzlehttp/guzzle": "^7.4", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "phpstan/phpstan": "^1.4", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^9.5.16", "predis/predis": "^1.1", - "phpspec/prophecy": "^1.6.1", - "rollbar/rollbar": "^1.3" + "ruflin/elastica": "^7", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" }, "suggest": { "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", - "sentry/sentry": "Allow sending log messages to a Sentry server", "doctrine/couchdb": "Allow sending log messages to a CouchDB server", "ruflin/elastica": "Allow sending log messages to an Elastic Search server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", "rollbar/rollbar": "Allow sending log messages to Rollbar", - "php-console/php-console": "Allow sending log messages to Google Chrome" + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-openssl": "Required to send log messages using SSL" }, "autoload": { "psr-4": {"Monolog\\": "src/Monolog"} @@ -51,17 +58,20 @@ "psr-4": {"Monolog\\": "tests/Monolog"} }, "provide": { - "psr/log-implementation": "1.0.0" + "psr/log-implementation": "3.0.0" }, "extra": { "branch-alias": { - "dev-master": "2.x-dev" + "dev-main": "3.x-dev" } }, "scripts": { - "test": [ - "parallel-lint . --exclude vendor", - "phpunit" - ] + "test": "@php vendor/bin/phpunit", + "phpstan": "@php vendor/bin/phpstan analyse" + }, + "config": { + "lock": false, + "sort-packages": true, + "platform-check": false } } diff --git a/doc/01-usage.md b/doc/01-usage.md index ec9bbbb1b..5cd82e1d9 100644 --- a/doc/01-usage.md +++ b/doc/01-usage.md @@ -7,6 +7,7 @@ - [Adding extra data in the records](#adding-extra-data-in-the-records) - [Leveraging channels](#leveraging-channels) - [Customizing the log format](#customizing-the-log-format) +- [Long running processes and avoiding memory leaks](#long-running-processes-and-avoiding-memory-leaks) ## Installation @@ -17,10 +18,6 @@ and as such installable via [Composer](http://getcomposer.org/). composer require monolog/monolog ``` -If you do not use Composer, you can grab the code from GitHub, and use any -PSR-0 compatible autoloader (e.g. the [Symfony2 ClassLoader component](https://github.com/symfony/ClassLoader)) -to load Monolog classes. - ## Core Concepts Every `Logger` instance has a channel (name) and a stack of handlers. Whenever @@ -47,7 +44,7 @@ incoming records so that they can be used by the handlers to output useful information. Custom severity levels are not available. Only the eight -[RFC 5424](http://tools.ietf.org/html/rfc5424) levels (debug, info, notice, +[RFC 5424](https://datatracker.ietf.org/doc/html/rfc5424) levels (debug, info, notice, warning, error, critical, alert, emergency) are present for basic filtering purposes, but for sorting and other use cases that would require flexibility, you should add Processors to the Logger that can add extra @@ -55,7 +52,7 @@ information (tags, user ip, ..) to the records before they are handled. ## Log Levels -Monolog supports the logging levels described by [RFC 5424](http://tools.ietf.org/html/rfc5424). +Monolog supports the logging levels described by [RFC 5424](https://datatracker.ietf.org/doc/html/rfc5424). - **DEBUG** (100): Detailed debug information. @@ -86,6 +83,7 @@ Here is a basic setup to log to a file and to firephp on the DEBUG level: ```php pushHandler(new StreamHandler(__DIR__.'/my_app.log', Logger::DEBUG)); +$logger->pushHandler(new StreamHandler(__DIR__.'/my_app.log', Level::Debug)); $logger->pushHandler(new FirePHPHandler()); // You can now use your logger @@ -112,10 +110,6 @@ Note that the FirePHPHandler is called first as it is added on top of the stack. This allows you to temporarily add a logger with bubbling disabled if you want to override other configured loggers. -> If you use Monolog standalone and are looking for an easy way to -> configure many handlers, the [theorchard/monolog-cascade](https://github.com/theorchard/monolog-cascade) -> can help you build complex logging configs via PHP arrays, yaml or json configs. - ## Adding extra data in the records Monolog provides two different ways to add extra information along the simple @@ -129,12 +123,12 @@ record: ```php info('Adding a new user', array('username' => 'Seldaek')); +$logger->info('Adding a new user', ['username' => 'Seldaek']); ``` Simple handlers (like the StreamHandler for instance) will simply format the array to a string but richer handlers can take advantage of the context -(FirePHP is able to display arrays in pretty way for instance). +(FirePHP is able to display arrays in a pretty way for instance). ### Using processors @@ -147,14 +141,14 @@ write a processor adding some dummy data in the record: pushProcessor(function ($record) { - $record['extra']['dummy'] = 'Hello world!'; + $record->extra['dummy'] = 'Hello world!'; return $record; }); ``` Monolog provides some built-in processors that can be used in your project. -Look at the [dedicated chapter](https://github.com/Seldaek/monolog/blob/master/doc/02-handlers-formatters-processors.md#processors) for the list. +Look at the [dedicated chapter](https://github.com/Seldaek/monolog/blob/main/doc/02-handlers-formatters-processors.md#processors) for the list. > Tip: processors can also be registered on a specific handler instead of the logger to apply only for this handler. @@ -163,7 +157,7 @@ Look at the [dedicated chapter](https://github.com/Seldaek/monolog/blob/master/d Channels are a great way to identify to which part of the application a record is related. This is useful in big applications (and is leveraged by -MonologBundle in Symfony2). +MonologBundle in Symfony). Picture two loggers sharing a handler that writes to a single log file. Channels would allow you to identify the logger that issued every record. @@ -172,12 +166,13 @@ You can easily grep through the log files filtering this or that channel. ```php withName('security'); ## Customizing the log format In Monolog it's easy to customize the format of the logs written into files, -sockets, mails, databases and other handlers. Most of the handlers use the +sockets, mails, databases and other handlers; by the use of "Formatters". +As mentioned before, a *Formatter* is attached to a *Handler*, and as a general convention, most of the handlers use the ```php -$record['formatted'] +$record->formatted ``` - -value to be automatically put into the log device. This value depends on the -formatter settings. You can choose between predefined formatter classes or -write your own (e.g. a multiline text file for human-readable output). - -To configure a predefined formatter class, just set it as the handler's field: +property in the log record to store its formatted value. + +You can choose between predefined formatter classes or write your own (e.g. a multiline text file for human-readable output). + +> Note: +> +> A very useful formatter to look at, is the `LineFormatter`. +> +> This formatter, as its name might indicate, is able to return a lineal string representation of the log record provided. +> +> It is also capable to interpolate values from the log record, into the output format template used by the formatter to generate +> the final result, and in order to do it, you need to provide the log record values you are interested in, in the output template +> string using the form %value%, e.g: "'%context.foo% => %extra.foo%'" , in this example the values `$record->context["foo"]` +> and `$record->extra["foo"]` will be rendered as part of the final result. + +In the following example, we demonstrate how to: +1. Create a `LineFormatter` instance and set a custom output format template. +2. Create a new *Handler*. +3. Attach the *Formatter* to the *Handler*. +4. Create a new *Logger* object. +5. Attach the *Handler* to the *Logger* object. ```php -// the default date format is "Y-m-d H:i:s" + %level_name% > %message% %context% %extra%\n"; + // finally, create a formatter $formatter = new LineFormatter($output, $dateFormat); // Create a handler -$stream = new StreamHandler(__DIR__.'/my_app.log', Logger::DEBUG); +$stream = new StreamHandler(__DIR__.'/my_app.log', Level::Debug); $stream->setFormatter($formatter); + // bind it to a logger object $securityLogger = new Logger('security'); $securityLogger->pushHandler($stream); @@ -228,4 +245,22 @@ $securityLogger->pushHandler($stream); You may also reuse the same formatter between multiple handlers and share those handlers between multiple loggers. +## Long running processes and avoiding memory leaks + +When logging lots of data or especially when running background workers which +are long-lived processes and do lots of logging over long periods of time, the +memory usage of buffered handlers like FingersCrossedHandler or BufferHandler +can rise quickly. + +Monolog provides the `ResettableInterface` for this use case, allowing you to +end a log cycle and get things back to their initial state. + +Calling `$logger->reset();` means flushing/cleaning all buffers, resetting internal +state, and getting it back to a state in which it can receive log records again. + +This is the conceptual equivalent of ending a web request, and can be done +between every background job you process, or whenever appropriate. It reduces memory +usage and also helps keep logs focused on the task at hand, avoiding log leaks +between different jobs. + [Handlers, Formatters and Processors](02-handlers-formatters-processors.md) → diff --git a/doc/02-handlers-formatters-processors.md b/doc/02-handlers-formatters-processors.md index fb7add9d2..b799006a0 100644 --- a/doc/02-handlers-formatters-processors.md +++ b/doc/02-handlers-formatters-processors.md @@ -15,87 +15,87 @@ ### Log to files and syslog -- [_StreamHandler_](../src/Monolog/Handler/StreamHandler.php): Logs records into any PHP stream, use this for log files. -- [_RotatingFileHandler_](../src/Monolog/Handler/RotatingFileHandler.php): Logs records to a file and creates one logfile per day. +- [_StreamHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/StreamHandler.php): Logs records into any PHP stream, use this for log files. +- [_RotatingFileHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/RotatingFileHandler.php): Logs records to a file and creates one log file per day. It will also delete files older than `$maxFiles`. You should use - [logrotate](http://linuxcommand.org/man_pages/logrotate8.html) for high profile + [logrotate](https://linux.die.net/man/8/logrotate) for high profile setups though, this is just meant as a quick and dirty solution. -- [_SyslogHandler_](../src/Monolog/Handler/SyslogHandler.php): Logs records to the syslog. -- [_ErrorLogHandler_](../src/Monolog/Handler/ErrorLogHandler.php): Logs records to PHP's +- [_SyslogHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/SyslogHandler.php): Logs records to the syslog. +- [_ErrorLogHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/ErrorLogHandler.php): Logs records to PHP's [`error_log()`](http://docs.php.net/manual/en/function.error-log.php) function. -- [_ProcessHandler_](../src/Monolog/Handler/ProcessHandler.php): Logs records to the [STDIN](https://en.wikipedia.org/wiki/Standard_streams#Standard_input_.28stdin.29) of any process, specified by a command. +- [_ProcessHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/ProcessHandler.php): Logs records to the [STDIN](https://en.wikipedia.org/wiki/Standard_streams#Standard_input_.28stdin.29) of any process, specified by a command. ### Send alerts and emails -- [_NativeMailerHandler_](../src/Monolog/Handler/NativeMailerHandler.php): Sends emails using PHP's +- [_NativeMailerHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/NativeMailerHandler.php): Sends emails using PHP's [`mail()`](http://php.net/manual/en/function.mail.php) function. -- [_SwiftMailerHandler_](../src/Monolog/Handler/SwiftMailerHandler.php): Sends emails using a [`Swift_Mailer`](http://swiftmailer.org/) instance. -- [_PushoverHandler_](../src/Monolog/Handler/PushoverHandler.php): Sends mobile notifications via the [Pushover](https://www.pushover.net/) API. -- [_HipChatHandler_](../src/Monolog/Handler/HipChatHandler.php): Logs records to a [HipChat](http://hipchat.com) chat room using its API. -- [_FlowdockHandler_](../src/Monolog/Handler/FlowdockHandler.php): Logs records to a [Flowdock](https://www.flowdock.com/) account. -- [_SlackbotHandler_](../src/Monolog/Handler/SlackbotHandler.php): Logs records to a [Slack](https://www.slack.com/) account using the Slackbot incoming hook. -- [_SlackWebhookHandler_](../src/Monolog/Handler/SlackWebhookHandler.php): Logs records to a [Slack](https://www.slack.com/) account using Slack Webhooks. -- [_SlackHandler_](../src/Monolog/Handler/SlackHandler.php): Logs records to a [Slack](https://www.slack.com/) account using the Slack API (complex setup). -- [_SendGridHandler_](../src/Monolog/Handler/SendGridHandler.php): Sends emails via the SendGrid API. -- [_MandrillHandler_](../src/Monolog/Handler/MandrillHandler.php): Sends emails via the Mandrill API using a [`Swift_Message`](http://swiftmailer.org/) instance. -- [_FleepHookHandler_](../src/Monolog/Handler/FleepHookHandler.php): Logs records to a [Fleep](https://fleep.io/) conversation using Webhooks. -- [_IFTTTHandler_](../src/Monolog/Handler/IFTTTHandler.php): Notifies an [IFTTT](https://ifttt.com/maker) trigger with the log channel, level name and message. +- [_SymfonyMailerHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/SymfonyMailerHandler.php): Sends emails using a [`symfony/mailer`](https://symfony.com/doc/current/mailer.html) instance. +- [_PushoverHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/PushoverHandler.php): Sends mobile notifications via the [Pushover](https://www.pushover.net/) API. +- [_FlowdockHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/FlowdockHandler.php): Logs records to a [Flowdock](https://www.flowdock.com/) account. +- [_SlackWebhookHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/SlackWebhookHandler.php): Logs records to a [Slack](https://www.slack.com/) account using Slack Webhooks. +- [_SlackHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/SlackHandler.php): Logs records to a [Slack](https://www.slack.com/) account using the Slack API (complex setup). +- [_SendGridHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/SendGridHandler.php): Sends emails via the SendGrid API. +- [_MandrillHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/MandrillHandler.php): Sends emails via the [`Mandrill API`](https://mandrillapp.com/api/docs/) using a [`Swift_Message`](http://swiftmailer.org/) instance. +- [_FleepHookHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/FleepHookHandler.php): Logs records to a [Fleep](https://fleep.io/) conversation using Webhooks. +- [_IFTTTHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/IFTTTHandler.php): Notifies an [IFTTT](https://ifttt.com/maker) trigger with the log channel, level name and message. +- [_TelegramBotHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/TelegramBotHandler.php): Logs records to a [Telegram](https://core.telegram.org/bots/api) bot account. +- [_HipChatHandler_](https://github.com/Seldaek/monolog/blob/1.x/src/Monolog/Handler/HipChatHandler.php): Logs records to a [HipChat](http://hipchat.com) chat room using its API. **Deprecated** and removed in Monolog 2.0, use Slack handlers instead, see [Atlassian's announcement](https://www.atlassian.com/partnerships/slack) +- [_SwiftMailerHandler_](https://github.com/Seldaek/monolog/blob/2.x/src/Monolog/Handler/SwiftMailerHandler.php): Sends emails using a [`Swift_Mailer`](http://swiftmailer.org/) instance. **Deprecated** and removed in Monolog 3.0. Use SymfonyMailerHandler instead. ### Log specific servers and networked logging -- [_SocketHandler_](../src/Monolog/Handler/SocketHandler.php): Logs records to [sockets](http://php.net/fsockopen), use this +- [_SocketHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/SocketHandler.php): Logs records to [sockets](http://php.net/fsockopen), use this for UNIX and TCP sockets. See an [example](sockets.md). -- [_AmqpHandler_](../src/Monolog/Handler/AmqpHandler.php): Logs records to an [AMQP](http://www.amqp.org/) compatible - server. Requires the [php-amqp](http://pecl.php.net/package/amqp) extension (1.0+) or +- [_AmqpHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/AmqpHandler.php): Logs records to an [AMQP](http://www.amqp.org/) compatible + server. Requires the [php-amqp](http://pecl.php.net/package/amqp) extension (1.0+) or [php-amqplib](https://github.com/php-amqplib/php-amqplib) library. -- [_GelfHandler_](../src/Monolog/Handler/GelfHandler.php): Logs records to a [Graylog2](http://www.graylog2.org) server. +- [_GelfHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/GelfHandler.php): Logs records to a [Graylog2](http://www.graylog2.org) server. Requires package [graylog2/gelf-php](https://github.com/bzikarsky/gelf-php). -- [_CubeHandler_](../src/Monolog/Handler/CubeHandler.php): Logs records to a [Cube](http://square.github.com/cube/) server. -- [_RavenHandler_](../src/Monolog/Handler/RavenHandler.php): Logs records to a [Sentry](http://getsentry.com/) server using - [raven](https://packagist.org/packages/raven/raven). -- [_ZendMonitorHandler_](../src/Monolog/Handler/ZendMonitorHandler.php): Logs records to the Zend Monitor present in Zend Server. -- [_NewRelicHandler_](../src/Monolog/Handler/NewRelicHandler.php): Logs records to a [NewRelic](http://newrelic.com/) application. -- [_LogglyHandler_](../src/Monolog/Handler/LogglyHandler.php): Logs records to a [Loggly](http://www.loggly.com/) account. -- [_RollbarHandler_](../src/Monolog/Handler/RollbarHandler.php): Logs records to a [Rollbar](https://rollbar.com/) account. -- [_SyslogUdpHandler_](../src/Monolog/Handler/SyslogUdpHandler.php): Logs records to a remote [Syslogd](http://www.rsyslog.com/) server. -- [_LogEntriesHandler_](../src/Monolog/Handler/LogEntriesHandler.php): Logs records to a [LogEntries](http://logentries.com/) account. -- [_InsightOpsHandler_](../src/Monolog/Handler/InsightOpsHandler.php): Logs records to a [InsightOps](https://www.rapid7.com/products/insightops/) account. -- [_LogmaticHandler_](../src/Monolog/Handler/LogmaticHandler.php): Logs records to a [Logmatic](http://logmatic.io/) account. -- [_SqsHandler_](../src/Monolog/Handler/SqsHandler.php): Logs records to an [AWS SQS](http://docs.aws.amazon.com/aws-sdk-php/v2/guide/service-sqs.html) queue. +- [_ZendMonitorHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/ZendMonitorHandler.php): Logs records to the Zend Monitor present in Zend Server. +- [_NewRelicHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/NewRelicHandler.php): Logs records to a [NewRelic](http://newrelic.com/) application. +- [_LogglyHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/LogglyHandler.php): Logs records to a [Loggly](http://www.loggly.com/) account. +- [_RollbarHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/RollbarHandler.php): Logs records to a [Rollbar](https://rollbar.com/) account. +- [_SyslogUdpHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/SyslogUdpHandler.php): Logs records to a remote [Syslogd](http://www.rsyslog.com/) server. +- [_LogEntriesHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/LogEntriesHandler.php): Logs records to a [LogEntries](http://logentries.com/) account. +- [_InsightOpsHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/InsightOpsHandler.php): Logs records to an [InsightOps](https://www.rapid7.com/products/insightops/) account. +- [_LogmaticHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/LogmaticHandler.php): Logs records to a [Logmatic](http://logmatic.io/) account. +- [_SqsHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/SqsHandler.php): Logs records to an [AWS SQS](http://docs.aws.amazon.com/aws-sdk-php/v2/guide/service-sqs.html) queue. +- [_RavenHandler_](https://github.com/Seldaek/monolog/blob/1.x/src/Monolog/Handler/RavenHandler.php): Logs records to a [Sentry](http://getsentry.com/) server using + [raven](https://packagist.org/packages/raven/raven). **Deprecated** and removed in Monolog 2.0, use sentry/sentry 2.x and the [Sentry\Monolog\Handler](https://github.com/getsentry/sentry-php/blob/master/src/Monolog/Handler.php) class instead. ### Logging in development -- [_FirePHPHandler_](../src/Monolog/Handler/FirePHPHandler.php): Handler for [FirePHP](http://www.firephp.org/), providing +- [_FirePHPHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/FirePHPHandler.php): Handler for [FirePHP](http://www.firephp.org/), providing inline `console` messages within [FireBug](http://getfirebug.com/). -- [_ChromePHPHandler_](../src/Monolog/Handler/ChromePHPHandler.php): Handler for [ChromePHP](http://www.chromephp.com/), providing +- [_ChromePHPHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/ChromePHPHandler.php): Handler for [ChromePHP](http://www.chromephp.com/), providing inline `console` messages within Chrome. -- [_BrowserConsoleHandler_](../src/Monolog/Handler/BrowserConsoleHandler.php): Handler to send logs to browser's Javascript `console` with +- [_BrowserConsoleHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/BrowserConsoleHandler.php): Handler to send logs to browser's Javascript `console` with no browser extension required. Most browsers supporting `console` API are supported. -- [_PHPConsoleHandler_](../src/Monolog/Handler/PHPConsoleHandler.php): Handler for [PHP Console](https://chrome.google.com/webstore/detail/php-console/nfhmhhlpfleoednkpnnnkolmclajemef), providing - inline `console` and notification popup messages within Chrome. ### Log to databases -- [_RedisHandler_](../src/Monolog/Handler/RedisHandler.php): Logs records to a [redis](http://redis.io) server. -- [_MongoDBHandler_](../src/Monolog/Handler/MongoDBHandler.php): Handler to write records in MongoDB via a +- [_RedisHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/RedisHandler.php): Logs records to a [redis](http://redis.io) server's key via RPUSH. +- [_RedisPubSubHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/RedisPubSubHandler.php): Logs records to a [redis](http://redis.io) server's channel via PUBLISH. +- [_MongoDBHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/MongoDBHandler.php): Handler to write records in MongoDB via a [Mongo](http://pecl.php.net/package/mongo) extension connection. -- [_CouchDBHandler_](../src/Monolog/Handler/CouchDBHandler.php): Logs records to a CouchDB server. -- [_DoctrineCouchDBHandler_](../src/Monolog/Handler/DoctrineCouchDBHandler.php): Logs records to a CouchDB server via the Doctrine CouchDB ODM. -- [_ElasticSearchHandler_](../src/Monolog/Handler/ElasticSearchHandler.php): Logs records to an Elastic Search server. -- [_DynamoDbHandler_](../src/Monolog/Handler/DynamoDbHandler.php): Logs records to a DynamoDB table with the [AWS SDK](https://github.com/aws/aws-sdk-php). +- [_CouchDBHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/CouchDBHandler.php): Logs records to a CouchDB server. +- [_DoctrineCouchDBHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/DoctrineCouchDBHandler.php): Logs records to a CouchDB server via the Doctrine CouchDB ODM. +- [_ElasticaHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/ElasticaHandler.php): Logs records to an Elasticsearch server using [ruflin/elastica](https://elastica.io/). +- [_ElasticsearchHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/ElasticsearchHandler.php): Logs records to an Elasticsearch server. +- [_DynamoDbHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/DynamoDbHandler.php): Logs records to a DynamoDB table with the [AWS SDK](https://github.com/aws/aws-sdk-php). ### Wrappers / Special Handlers -- [_FingersCrossedHandler_](../src/Monolog/Handler/FingersCrossedHandler.php): A very interesting wrapper. It takes a logger as - parameter and will accumulate log records of all levels until a record +- [_FingersCrossedHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/FingersCrossedHandler.php): A very interesting wrapper. It takes a handler as + a parameter and will accumulate log records of all levels until a record exceeds the defined severity level. At which point it delivers all records, including those of lower severity, to the handler it wraps. This means that until an error actually happens you will not see anything in your logs, but when it happens you will have the full information, including debug and info records. This provides you with all the information you need, but only when you need it. -- [_DeduplicationHandler_](../src/Monolog/Handler/DeduplicationHandler.php): Useful if you are sending notifications or emails - when critical errors occur. It takes a logger as parameter and will +- [_DeduplicationHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/DeduplicationHandler.php): Useful if you are sending notifications or emails + when critical errors occur. It takes a handler as a parameter and will accumulate log records of all levels until the end of the request (or `flush()` is called). At that point it delivers all records to the handler it wraps, but only if the records are unique over a given time period @@ -104,61 +104,74 @@ database is unreachable for example all your requests will fail and that can result in a lot of notifications being sent. Adding this handler reduces the amount of notifications to a manageable level. -- [_WhatFailureGroupHandler_](../src/Monolog/Handler/WhatFailureGroupHandler.php): This handler extends the _GroupHandler_ ignoring +- [_WhatFailureGroupHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/WhatFailureGroupHandler.php): This handler extends the _GroupHandler_ ignoring exceptions raised by each child handler. This allows you to ignore issues where a remote tcp connection may have died but you do not want your entire application to crash and may wish to continue to log to other handlers. -- [_BufferHandler_](../src/Monolog/Handler/BufferHandler.php): This handler will buffer all the log records it receives +- [_FallbackGroupHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/FallbackGroupHandler.php): This handler extends the _GroupHandler_ ignoring + exceptions raised by each child handler, until one has handled without throwing. + This allows you to ignore issues where a remote tcp connection may have died + but you do not want your entire application to crash and may wish to continue + to attempt logging to other handlers, until one does not throw an exception. +- [_BufferHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/BufferHandler.php): This handler will buffer all the log records it receives until `close()` is called at which point it will call `handleBatch()` on the handler it wraps with all the log messages at once. This is very useful to send an email with all records at once for example instead of having one mail for every log record. -- [_GroupHandler_](../src/Monolog/Handler/GroupHandler.php): This handler groups other handlers. Every record received is +- [_GroupHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/GroupHandler.php): This handler groups other handlers. Every record received is sent to all the handlers it is configured with. -- [_FilterHandler_](../src/Monolog/Handler/FilterHandler.php): This handler only lets records of the given levels through +- [_FilterHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/FilterHandler.php): This handler only lets records of the given levels through to the wrapped handler. -- [_SamplingHandler_](../src/Monolog/Handler/SamplingHandler.php): Wraps around another handler and lets you sample records +- [_SamplingHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/SamplingHandler.php): Wraps around another handler and lets you sample records if you only want to store some of them. -- [_NoopHandler_](../src/Monolog/Handler/NoopHandler.php): This handler handles anything by doing nothing. It does not stop +- [_NoopHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/NoopHandler.php): This handler handles anything by doing nothing. It does not stop processing the rest of the stack. This can be used for testing, or to disable a handler when overriding a configuration. -- [_NullHandler_](../src/Monolog/Handler/NullHandler.php): Any record it can handle will be thrown away. This can be used +- [_NullHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/NullHandler.php): Any record it can handle will be thrown away. This can be used to put on top of an existing handler stack to disable it temporarily. -- [_PsrHandler_](../src/Monolog/Handler/PsrHandler.php): Can be used to forward log records to an existing PSR-3 logger -- [_TestHandler_](../src/Monolog/Handler/TestHandler.php): Used for testing, it records everything that is sent to it and +- [_PsrHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/PsrHandler.php): Can be used to forward log records to an existing PSR-3 logger +- [_TestHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/TestHandler.php): Used for testing, it records everything that is sent to it and has accessors to read out the information. -- [_HandlerWrapper_](../src/Monolog/Handler/HandlerWrapper.php): A simple handler wrapper you can inherit from to create +- [_HandlerWrapper_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/HandlerWrapper.php): A simple handler wrapper you can inherit from to create your own wrappers easily. +- [_OverflowHandler_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/OverflowHandler.php): This handler will buffer all the log messages it + receives, up until a configured threshold of number of messages of a certain level is reached, after it will pass all + log messages to the wrapped handler. Useful for applying in batch processing when you're only interested in significant + failures instead of minor, single erroneous events. ## Formatters -- [_LineFormatter_](../src/Monolog/Formatter/LineFormatter.php): Formats a log record into a one-line string. -- [_HtmlFormatter_](../src/Monolog/Formatter/HtmlFormatter.php): Used to format log records into a human readable html table, mainly suitable for emails. -- [_NormalizerFormatter_](../src/Monolog/Formatter/NormalizerFormatter.php): Normalizes objects/resources down to strings so a record can easily be serialized/encoded. -- [_ScalarFormatter_](../src/Monolog/Formatter/ScalarFormatter.php): Used to format log records into an associative array of scalar values. -- [_JsonFormatter_](../src/Monolog/Formatter/JsonFormatter.php): Encodes a log record into json. -- [_WildfireFormatter_](../src/Monolog/Formatter/WildfireFormatter.php): Used to format log records into the Wildfire/FirePHP protocol, only useful for the FirePHPHandler. -- [_ChromePHPFormatter_](../src/Monolog/Formatter/ChromePHPFormatter.php): Used to format log records into the ChromePHP format, only useful for the ChromePHPHandler. -- [_GelfMessageFormatter_](../src/Monolog/Formatter/GelfMessageFormatter.php): Used to format log records into Gelf message instances, only useful for the GelfHandler. -- [_LogstashFormatter_](../src/Monolog/Formatter/LogstashFormatter.php): Used to format log records into [logstash](http://logstash.net/) event json, useful for any handler listed under inputs [here](http://logstash.net/docs/latest). -- [_ElasticaFormatter_](../src/Monolog/Formatter/ElasticaFormatter.php): Used to format log records into an Elastica\Document object, only useful for the ElasticSearchHandler. -- [_LogglyFormatter_](../src/Monolog/Formatter/LogglyFormatter.php): Used to format log records into Loggly messages, only useful for the LogglyHandler. -- [_FlowdockFormatter_](../src/Monolog/Formatter/FlowdockFormatter.php): Used to format log records into Flowdock messages, only useful for the FlowdockHandler. -- [_MongoDBFormatter_](../src/Monolog/Formatter/MongoDBFormatter.php): Converts \DateTime instances to \MongoDate and objects recursively to arrays, only useful with the MongoDBHandler. -- [_LogmaticFormatter_](../src/Monolog/Formatter/LogmaticFormatter.php): User to format log records to [Logmatic](http://logmatic.io/) messages, only useful for the LogmaticHandler. +- [_LineFormatter_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Formatter/LineFormatter.php): Formats a log record into a one-line string. +- [_HtmlFormatter_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Formatter/HtmlFormatter.php): Used to format log records into a human readable html table, mainly suitable for emails. +- [_NormalizerFormatter_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Formatter/NormalizerFormatter.php): Normalizes objects/resources down to strings so a record can easily be serialized/encoded. +- [_ScalarFormatter_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Formatter/ScalarFormatter.php): Used to format log records into an associative array of scalar values. +- [_JsonFormatter_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Formatter/JsonFormatter.php): Encodes a log record into json. +- [_WildfireFormatter_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Formatter/WildfireFormatter.php): Used to format log records into the Wildfire/FirePHP protocol, only useful for the FirePHPHandler. +- [_ChromePHPFormatter_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Formatter/ChromePHPFormatter.php): Used to format log records into the ChromePHP format, only useful for the ChromePHPHandler. +- [_GelfMessageFormatter_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Formatter/GelfMessageFormatter.php): Used to format log records into Gelf message instances, only useful for the GelfHandler. +- [_LogstashFormatter_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Formatter/LogstashFormatter.php): Used to format log records into [logstash](http://logstash.net/) event json, useful for any handler listed under inputs [here](http://logstash.net/docs/latest). +- [_ElasticaFormatter_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Formatter/ElasticaFormatter.php): Used to format log records into an Elastica\Document object, only useful for the ElasticaHandler. +- [_ElasticsearchFormatter_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Formatter/ElasticsearchFormatter.php): Used to add index and type keys to log records, only useful for the ElasticsearchHandler. +- [_LogglyFormatter_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Formatter/LogglyFormatter.php): Used to format log records into Loggly messages, only useful for the LogglyHandler. +- [_FlowdockFormatter_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Formatter/FlowdockFormatter.php): Used to format log records into Flowdock messages, only useful for the FlowdockHandler. +- [_MongoDBFormatter_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Formatter/MongoDBFormatter.php): Converts \DateTime instances to \MongoDate and objects recursively to arrays, only useful with the MongoDBHandler. +- [_LogmaticFormatter_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Formatter/LogmaticFormatter.php): Used to format log records to [Logmatic](http://logmatic.io/) messages, only useful for the LogmaticHandler. +- [_FluentdFormatter_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Formatter/FluentdFormatter.php): Used to format log records to [Fluentd](https://www.fluentd.org/) logs, only useful with the SocketHandler. +- [_GoogleCloudLoggingFormatter_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Formatter/GoogleCloudLoggingFormatter.php): Used to format log records for Google Cloud Logging. It works like a JsonFormatter with some minor tweaks. +- [_SyslogFormatter_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Formatter/SyslogFormatter.php): Used to format log records in RFC 5424 / syslog format. This can be used to output a syslog-style file that can then be consumed by tools like [lnav](https://lnav.org/). ## Processors -- [_PsrLogMessageProcessor_](../src/Monolog/Processor/PsrLogMessageProcessor.php): Processes a log record's message according to PSR-3 rules, replacing `{foo}` with the value from `$context['foo']`. -- [_IntrospectionProcessor_](../src/Monolog/Processor/IntrospectionProcessor.php): Adds the line/file/class/method from which the log call originated. -- [_WebProcessor_](../src/Monolog/Processor/WebProcessor.php): Adds the current request URI, request method and client IP to a log record. -- [_MemoryUsageProcessor_](../src/Monolog/Processor/MemoryUsageProcessor.php): Adds the current memory usage to a log record. -- [_MemoryPeakUsageProcessor_](../src/Monolog/Processor/MemoryPeakUsageProcessor.php): Adds the peak memory usage to a log record. -- [_ProcessIdProcessor_](../src/Monolog/Processor/ProcessIdProcessor.php): Adds the process id to a log record. -- [_UidProcessor_](../src/Monolog/Processor/UidProcessor.php): Adds a unique identifier to a log record. -- [_GitProcessor_](../src/Monolog/Processor/GitProcessor.php): Adds the current git branch and commit to a log record. -- [_MercurialProcessor_](../src/Monolog/Processor/MercurialProcessor.php): Adds the current hg branch and commit to a log record. -- [_TagProcessor_](../src/Monolog/Processor/TagProcessor.php): Adds an array of predefined tags to a log record. -- [_HostnameProcessor_](../src/Monolog/Processor/HostnameProcessor.php): Adds the current hostname to a log record. +- [_PsrLogMessageProcessor_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Processor/PsrLogMessageProcessor.php): Processes a log record's message according to PSR-3 rules, replacing `{foo}` with the value from `$context['foo']`. +- [_IntrospectionProcessor_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Processor/IntrospectionProcessor.php): Adds the line/file/class/method from which the log call originated. +- [_WebProcessor_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Processor/WebProcessor.php): Adds the current request URI, request method and client IP to a log record. +- [_MemoryUsageProcessor_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Processor/MemoryUsageProcessor.php): Adds the current memory usage to a log record. +- [_MemoryPeakUsageProcessor_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Processor/MemoryPeakUsageProcessor.php): Adds the peak memory usage to a log record. +- [_ProcessIdProcessor_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Processor/ProcessIdProcessor.php): Adds the process id to a log record. +- [_UidProcessor_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Processor/UidProcessor.php): Adds a unique identifier to a log record. +- [_GitProcessor_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Processor/GitProcessor.php): Adds the current git branch and commit to a log record. +- [_MercurialProcessor_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Processor/MercurialProcessor.php): Adds the current hg branch and commit to a log record. +- [_TagProcessor_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Processor/TagProcessor.php): Adds an array of predefined tags to a log record. +- [_HostnameProcessor_](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Processor/HostnameProcessor.php): Adds the current hostname to a log record. ## Third Party Packages diff --git a/doc/03-utilities.md b/doc/03-utilities.md index c62aa4161..fd3fd0e7d 100644 --- a/doc/03-utilities.md +++ b/doc/03-utilities.md @@ -5,6 +5,8 @@ help in some older codebases or for ease of use. - _ErrorHandler_: The `Monolog\ErrorHandler` class allows you to easily register a Logger instance as an exception handler, error handler or fatal error handler. +- _SignalHandler_: The `Monolog\SignalHandler` class allows you to easily register + a Logger instance as a POSIX signal handler. - _ErrorLevelActivationStrategy_: Activates a FingersCrossedHandler when a certain log level is reached. - _ChannelLevelActivationStrategy_: Activates a FingersCrossedHandler when a certain diff --git a/doc/04-extending.md b/doc/04-extending.md index ffb070094..2235fd3b1 100644 --- a/doc/04-extending.md +++ b/doc/04-extending.md @@ -21,32 +21,33 @@ abstract class provided by Monolog to keep things DRY. ```php pdo = $pdo; parent::__construct($level, $bubble); } - protected function write(array $record) + protected function write(LogRecord $record): void { if (!$this->initialized) { $this->initialize(); } $this->statement->execute(array( - 'channel' => $record['channel'], - 'level' => $record['level'], - 'message' => $record['formatted'], - 'time' => $record['datetime']->format('U'), + 'channel' => $record->channel, + 'level' => $record->level, + 'message' => $record->formatted, + 'time' => $record->datetime->format('U'), )); } @@ -78,6 +79,6 @@ $logger->info('My logger is now ready'); The `Monolog\Handler\AbstractProcessingHandler` class provides most of the logic needed for the handler, including the use of processors and the formatting -of the record (which is why we use ``$record['formatted']`` instead of ``$record['message']``). +of the record (which is why we use ``$record->formatted`` instead of ``$record->message``). ← [Utility classes](03-utilities.md) diff --git a/doc/message-structure.md b/doc/message-structure.md index 63c6f19bd..68a8c7a2e 100644 --- a/doc/message-structure.md +++ b/doc/message-structure.md @@ -1,18 +1,24 @@ # Log message structure -Within monolog log messages are passed around as arrays, for example to processors or handlers. -The table below describes which keys are always available for every log message. +Within monolog log messages are passed around as [Monolog\LogRecord](../src/Monolog/LogRecord.php) objects, +for example to processors or handlers. -key | type | description +The table below describes the properties available. + +property | type | description -----------|---------------------------|------------------------------------------------------------------------------- message | string | The log message. When the `PsrLogMessageProcessor` is used this string may contain placeholders that will be replaced by variables from the context, e.g., "User {username} logged in" with `['username' => 'John']` as context will be written as "User John logged in". -level | int | Severity of the log message. See log levels described in [01-usage.md](01-usage.md). -level_name | string | String representation of log level. +level | Monolog\Level case | Severity of the log message. See log levels described in [01-usage.md](01-usage.md#log-levels). context | array | Arbitrary data passed with the construction of the message. For example the username of the current user or their IP address. channel | string | The channel this message was logged to. This is the name that was passed when the logger was created with `new Logger($channel)`. datetime | Monolog\DateTimeImmutable | Date and time when the message was logged. Class extends `\DateTimeImmutable`. extra | array | A placeholder array where processors can put additional data. Always available, but empty if there are no processors registered. At first glance `context` and `extra` look very similar, and they are in the sense that they both carry arbitrary data that is related to the log message somehow. -The main difference is that `context` can be supplied in user land (it is the 3rd parameter to `Logger::addRecord()`) whereas `extra` is internal only and can be filled by processors. -The reason processors write to `extra` and not to `context` is to prevent overriding any user provided data in `context`. +The main difference is that `context` can be supplied in user land (it is the 3rd parameter to `Psr\Log\LoggerInterface` methods) whereas `extra` is internal only +and can be filled by processors. The reason processors write to `extra` and not to `context` is to prevent overriding any user-provided data in `context`. + +All properties except `extra` are read-only. + +> Note: For BC reasons with Monolog 1 and 2 which used arrays, `LogRecord` implements `ArrayAccess` so you can access the above properties +> using `$record['message']` for example, with the notable exception of `level->getName()` which must be referred to as `level_name` for BC. diff --git a/doc/sockets.md b/doc/sockets.md index c1190c235..d02f722de 100644 --- a/doc/sockets.md +++ b/doc/sockets.md @@ -26,7 +26,7 @@ $handler = new SocketHandler('unix:///var/log/httpd_app_log.socket'); $handler->setPersistent(true); // Now add the handler -$logger->pushHandler($handler, Logger::DEBUG); +$logger->pushHandler($handler, Level::Debug); // You can now use your logger $logger->info('My logger is now ready'); @@ -36,4 +36,3 @@ $logger->info('My logger is now ready'); In this example, using syslog-ng, you should see the log on the log server: cweb1 [2012-02-26 00:12:03] my_logger.INFO: My logger is now ready [] [] - diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 000000000..9661ca202 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,122 @@ +parameters: + ignoreErrors: + - + message: "#^Property Monolog\\\\ErrorHandler\\:\\:\\$reservedMemory is never read, only written\\.$#" + count: 1 + path: src/Monolog/ErrorHandler.php + + - + message: "#^Return type \\(array\\\\|bool\\|float\\|int\\|object\\|string\\|null\\) of method Monolog\\\\Formatter\\\\JsonFormatter\\:\\:normalize\\(\\) should be covariant with return type \\(array\\\\|bool\\|float\\|int\\|string\\|null\\) of method Monolog\\\\Formatter\\\\NormalizerFormatter\\:\\:normalize\\(\\)$#" + count: 1 + path: src/Monolog/Formatter/JsonFormatter.php + + - + message: "#^Cannot access offset 'table' on array\\\\|bool\\|float\\|int\\|object\\|string\\.$#" + count: 1 + path: src/Monolog/Formatter/WildfireFormatter.php + + - + message: "#^Return type \\(array\\\\|bool\\|float\\|int\\|object\\|string\\|null\\) of method Monolog\\\\Formatter\\\\WildfireFormatter\\:\\:normalize\\(\\) should be covariant with return type \\(array\\\\|bool\\|float\\|int\\|string\\|null\\) of method Monolog\\\\Formatter\\\\NormalizerFormatter\\:\\:normalize\\(\\)$#" + count: 1 + path: src/Monolog/Formatter/WildfireFormatter.php + + - + message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" + count: 1 + path: src/Monolog/Handler/BrowserConsoleHandler.php + + - + message: "#^Instanceof between Monolog\\\\Handler\\\\HandlerInterface and Monolog\\\\Handler\\\\HandlerInterface will always evaluate to true\\.$#" + count: 1 + path: src/Monolog/Handler/FilterHandler.php + + - + message: "#^Instanceof between Monolog\\\\Handler\\\\HandlerInterface and Monolog\\\\Handler\\\\HandlerInterface will always evaluate to true\\.$#" + count: 1 + path: src/Monolog/Handler/FingersCrossedHandler.php + + - + message: "#^Short ternary operator is not allowed\\. Use null coalesce operator if applicable or consider using long ternary\\.$#" + count: 1 + path: src/Monolog/Handler/FingersCrossedHandler.php + + - + message: "#^Call to method setBody\\(\\) on an unknown class Swift_Message\\.$#" + count: 1 + path: src/Monolog/Handler/MandrillHandler.php + + - + message: "#^Call to method setDate\\(\\) on an unknown class Swift_Message\\.$#" + count: 1 + path: src/Monolog/Handler/MandrillHandler.php + + - + message: "#^Class Swift_Message not found\\.$#" + count: 2 + path: src/Monolog/Handler/MandrillHandler.php + + - + message: "#^Cloning object of an unknown class Swift_Message\\.$#" + count: 1 + path: src/Monolog/Handler/MandrillHandler.php + + - + message: "#^Instanceof between Swift_Message and Swift_Message will always evaluate to true\\.$#" + count: 1 + path: src/Monolog/Handler/MandrillHandler.php + + - + message: "#^Parameter \\$message of method Monolog\\\\Handler\\\\MandrillHandler\\:\\:__construct\\(\\) has invalid type Swift_Message\\.$#" + count: 3 + path: src/Monolog/Handler/MandrillHandler.php + + - + message: "#^Property Monolog\\\\Handler\\\\MandrillHandler\\:\\:\\$message has unknown class Swift_Message as its type\\.$#" + count: 1 + path: src/Monolog/Handler/MandrillHandler.php + + - + message: "#^Instanceof between Monolog\\\\Handler\\\\HandlerInterface and Monolog\\\\Handler\\\\HandlerInterface will always evaluate to true\\.$#" + count: 1 + path: src/Monolog/Handler/SamplingHandler.php + + - + message: "#^Match expression does not handle remaining value\\: 'EMERGENCY'$#" + count: 1 + path: src/Monolog/Level.php + + - + message: "#^Variable property access on \\$this\\(Monolog\\\\LogRecord\\)\\.$#" + count: 4 + path: src/Monolog/LogRecord.php + + - + message: "#^Parameter \\#1 \\$level \\('alert'\\|'critical'\\|'debug'\\|'emergency'\\|'error'\\|'info'\\|'notice'\\|'warning'\\|Monolog\\\\Level\\) of method Monolog\\\\Logger\\:\\:log\\(\\) should be contravariant with parameter \\$level \\(mixed\\) of method Psr\\\\Log\\\\LoggerInterface\\:\\:log\\(\\)$#" + count: 1 + path: src/Monolog/Logger.php + + - + message: "#^Comparison operation \"\\<\" between int\\<1, 32\\> and 1 is always false\\.$#" + count: 1 + path: src/Monolog/Processor/UidProcessor.php + + - + message: "#^Comparison operation \"\\>\" between int\\<1, 32\\> and 32 is always false\\.$#" + count: 1 + path: src/Monolog/Processor/UidProcessor.php + + - + message: "#^Method Monolog\\\\Processor\\\\UidProcessor\\:\\:generateUid\\(\\) should return non\\-empty\\-string but returns string\\.$#" + count: 1 + path: src/Monolog/Processor/UidProcessor.php + + - + message: "#^Parameter \\#1 \\$length of function random_bytes expects int\\<1, max\\>, int given\\.$#" + count: 1 + path: src/Monolog/Processor/UidProcessor.php + + - + message: "#^Parameter \\#2 \\$callback of function preg_replace_callback expects callable\\(array\\\\)\\: string, Closure\\(mixed\\)\\: array\\\\|string\\|false given\\.$#" + count: 1 + path: src/Monolog/Utils.php + diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 000000000..580b169cc --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,34 @@ +parameters: + level: 8 + + treatPhpDocTypesAsCertain: false + reportUnmatchedIgnoredErrors: true + + paths: + - src/ +# - tests/ + + excludePaths: + - 'src/Monolog/Handler/PHPConsoleHandler.php' + + ignoreErrors: + - '#zend_monitor_|ZEND_MONITOR_#' + - '#MongoDB\\(Client|Collection)#' + # Invalid type info on Redis multi + - '#^Cannot call method ltrim\(\) on int\|false.$#' + + # Cannot resolve this cleanly as different normalizers return different types but it is safe + - message: '#Return type \(string\) of method Monolog\\Formatter\\LineFormatter::normalizeException\(\) should be compatible with return type \(array\) of method Monolog\\Formatter\\NormalizerFormatter::normalizeException\(\)#' + paths: + - src/Monolog/Formatter/LineFormatter.php + + # can be removed when rollbar/rollbar can be added as dev require again (needs to allow monolog 3.x) + - '#Rollbar\\RollbarLogger#' + + # legacy elasticsearch namespace failures + - '# Elastic\\Elasticsearch\\#' + +includes: + - phpstan-baseline.neon + - vendor/phpstan/phpstan-strict-rules/rules.neon + - vendor/phpstan/phpstan-deprecation-rules/rules.neon diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 1a676b24b..1104f47ef 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,6 +1,11 @@ - + tests/Monolog/ diff --git a/src/Monolog/Attribute/AsMonologProcessor.php b/src/Monolog/Attribute/AsMonologProcessor.php new file mode 100644 index 000000000..f8b250217 --- /dev/null +++ b/src/Monolog/Attribute/AsMonologProcessor.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Attribute; + +/** + * A reusable attribute to help configure a class or a method as a processor. + * + * Using it offers no guarantee: it needs to be leveraged by a Monolog third-party consumer. + * + * Using it with the Monolog library only has no effect at all: processors should still be turned into a callable if + * needed and manually pushed to the loggers and to the processable handlers. + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +class AsMonologProcessor +{ + /** + * @param string|null $channel The logging channel the processor should be pushed to. + * @param string|null $handler The handler the processor should be pushed to. + * @param string|null $method The method that processes the records (if the attribute is used at the class level). + */ + public function __construct( + public readonly ?string $channel = null, + public readonly ?string $handler = null, + public readonly ?string $method = null + ) { + } +} diff --git a/src/Monolog/DateTimeImmutable.php b/src/Monolog/DateTimeImmutable.php index 6e7a5fc66..274b73ea1 100644 --- a/src/Monolog/DateTimeImmutable.php +++ b/src/Monolog/DateTimeImmutable.php @@ -11,6 +11,8 @@ namespace Monolog; +use DateTimeZone; + /** * Overrides default json encoding of date time objects * @@ -19,9 +21,9 @@ */ class DateTimeImmutable extends \DateTimeImmutable implements \JsonSerializable { - private $useMicroseconds; + private bool $useMicroseconds; - public function __construct($useMicroseconds, \DateTimeZone $timezone = null) + public function __construct(bool $useMicroseconds, ?DateTimeZone $timezone = null) { $this->useMicroseconds = $useMicroseconds; diff --git a/src/Monolog/ErrorHandler.php b/src/Monolog/ErrorHandler.php index d32c25d53..2ed460357 100644 --- a/src/Monolog/ErrorHandler.php +++ b/src/Monolog/ErrorHandler.php @@ -11,6 +11,7 @@ namespace Monolog; +use Closure; use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; @@ -25,24 +26,33 @@ */ class ErrorHandler { - private $logger; + private Closure|null $previousExceptionHandler = null; - private $previousExceptionHandler; - private $uncaughtExceptionLevelMap; + /** @var array an array of class name to LogLevel::* constant mapping */ + private array $uncaughtExceptionLevelMap = []; - private $previousErrorHandler; - private $errorLevelMap; - private $handleOnlyReportedErrors; + /** @var Closure|true|null */ + private Closure|bool|null $previousErrorHandler = null; - private $hasFatalErrorHandler; - private $fatalLevel; - private $reservedMemory; - private $lastFatalTrace; - private static $fatalErrors = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR]; + /** @var array an array of E_* constant to LogLevel::* constant mapping */ + private array $errorLevelMap = []; - public function __construct(LoggerInterface $logger) - { - $this->logger = $logger; + private bool $handleOnlyReportedErrors = true; + + private bool $hasFatalErrorHandler = false; + + private string $fatalLevel = LogLevel::ALERT; + + private string|null $reservedMemory = null; + + /** @var ?array{type: int, message: string, file: string, line: int, trace: mixed} */ + private array|null $lastFatalData = null; + + private const FATAL_ERRORS = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR]; + + public function __construct( + private LoggerInterface $logger + ) { } /** @@ -50,17 +60,14 @@ public function __construct(LoggerInterface $logger) * * By default it will handle errors, exceptions and fatal errors * - * @param LoggerInterface $logger - * @param array|false $errorLevelMap an array of E_* constant to LogLevel::* constant mapping, or false to disable error handling - * @param array|false $exceptionLevelMap an array of class name to LogLevel::* constant mapping, or false to disable exception handling - * @param string|null|false $fatalLevel a LogLevel::* constant, null to use the default LogLevel::ALERT or false to disable fatal error handling + * @param array|false $errorLevelMap an array of E_* constant to LogLevel::* constant mapping, or false to disable error handling + * @param array|false $exceptionLevelMap an array of class name to LogLevel::* constant mapping, or false to disable exception handling + * @param LogLevel::*|null|false $fatalLevel a LogLevel::* constant, null to use the default LogLevel::ALERT or false to disable fatal error handling * @return ErrorHandler */ public static function register(LoggerInterface $logger, $errorLevelMap = [], $exceptionLevelMap = [], $fatalLevel = null): self { - //Forces the autoloader to run for LogLevel. Fixes an autoload issue at compile-time on PHP5.3. See https://github.com/Seldaek/monolog/pull/929 - class_exists('\\Psr\\Log\\LogLevel', true); - + /** @phpstan-ignore-next-line */ $handler = new static($logger); if ($errorLevelMap !== false) { $handler->registerErrorHandler($errorLevelMap); @@ -75,28 +82,40 @@ class_exists('\\Psr\\Log\\LogLevel', true); return $handler; } - public function registerExceptionHandler($levelMap = [], $callPrevious = true): self + /** + * @param array $levelMap an array of class name to LogLevel::* constant mapping + * @return $this + */ + public function registerExceptionHandler(array $levelMap = [], bool $callPrevious = true): self { - $prev = set_exception_handler([$this, 'handleException']); + $prev = set_exception_handler(function (\Throwable $e): void { + $this->handleException($e); + }); $this->uncaughtExceptionLevelMap = $levelMap; foreach ($this->defaultExceptionLevelMap() as $class => $level) { if (!isset($this->uncaughtExceptionLevelMap[$class])) { $this->uncaughtExceptionLevelMap[$class] = $level; } } - if ($callPrevious && $prev) { - $this->previousExceptionHandler = $prev; + if ($callPrevious && null !== $prev) { + $this->previousExceptionHandler = $prev(...); } return $this; } - public function registerErrorHandler(array $levelMap = [], $callPrevious = true, $errorTypes = -1, $handleOnlyReportedErrors = true): self + /** + * @param array $levelMap an array of E_* constant to LogLevel::* constant mapping + * @return $this + */ + public function registerErrorHandler(array $levelMap = [], bool $callPrevious = true, int $errorTypes = -1, bool $handleOnlyReportedErrors = true): self { - $prev = set_error_handler([$this, 'handleError'], $errorTypes); + $prev = set_error_handler($this->handleError(...), $errorTypes); $this->errorLevelMap = array_replace($this->defaultErrorLevelMap(), $levelMap); if ($callPrevious) { - $this->previousErrorHandler = $prev ?: true; + $this->previousErrorHandler = $prev !== null ? $prev(...) : true; + } else { + $this->previousErrorHandler = null; } $this->handleOnlyReportedErrors = $handleOnlyReportedErrors; @@ -105,20 +124,23 @@ public function registerErrorHandler(array $levelMap = [], $callPrevious = true, } /** - * @param string|null $level a LogLevel::* constant, null to use the default LogLevel::ALERT or false to disable fatal error handling - * @param int $reservedMemorySize Amount of KBs to reserve in memory so that it can be freed when handling fatal errors giving Monolog some room in memory to get its job done + * @param LogLevel::*|null $level a LogLevel::* constant, null to use the default LogLevel::ALERT + * @param int $reservedMemorySize Amount of KBs to reserve in memory so that it can be freed when handling fatal errors giving Monolog some room in memory to get its job done */ public function registerFatalHandler($level = null, int $reservedMemorySize = 20): self { - register_shutdown_function([$this, 'handleFatalError']); + register_shutdown_function($this->handleFatalError(...)); $this->reservedMemory = str_repeat(' ', 1024 * $reservedMemorySize); - $this->fatalLevel = $level; + $this->fatalLevel = null === $level ? LogLevel::ALERT : $level; $this->hasFatalErrorHandler = true; return $this; } + /** + * @return array + */ protected function defaultExceptionLevelMap(): array { return [ @@ -127,6 +149,9 @@ protected function defaultExceptionLevelMap(): array ]; } + /** + * @return array + */ protected function defaultErrorLevelMap(): array { return [ @@ -148,10 +173,7 @@ protected function defaultErrorLevelMap(): array ]; } - /** - * @private - */ - public function handleException($e) + private function handleException(\Throwable $e): never { $level = LogLevel::ERROR; foreach ($this->uncaughtExceptionLevelMap as $class => $candidate) { @@ -163,60 +185,65 @@ public function handleException($e) $this->logger->log( $level, - sprintf('Uncaught Exception %s: "%s" at %s line %s', get_class($e), $e->getMessage(), $e->getFile(), $e->getLine()), + sprintf('Uncaught Exception %s: "%s" at %s line %s', Utils::getClass($e), $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e] ); - if ($this->previousExceptionHandler) { - call_user_func($this->previousExceptionHandler, $e); + if (null !== $this->previousExceptionHandler) { + ($this->previousExceptionHandler)($e); } - if (!headers_sent() && ini_get('display_errors') === 0) { + if (!headers_sent() && !(bool) ini_get('display_errors')) { http_response_code(500); } exit(255); } - /** - * @private - */ - public function handleError($code, $message, $file = '', $line = 0, $context = []) + private function handleError(int $code, string $message, string $file = '', int $line = 0): bool { - if ($this->handleOnlyReportedErrors && !(error_reporting() & $code)) { - return; + if ($this->handleOnlyReportedErrors && 0 === (error_reporting() & $code)) { + return false; } // fatal error codes are ignored if a fatal error handler is present as well to avoid duplicate log entries - if (!$this->hasFatalErrorHandler || !in_array($code, self::$fatalErrors, true)) { + if (!$this->hasFatalErrorHandler || !in_array($code, self::FATAL_ERRORS, true)) { $level = $this->errorLevelMap[$code] ?? LogLevel::CRITICAL; $this->logger->log($level, self::codeToString($code).': '.$message, ['code' => $code, 'message' => $message, 'file' => $file, 'line' => $line]); } else { $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); array_shift($trace); // Exclude handleError from trace - $this->lastFatalTrace = $trace; + $this->lastFatalData = ['type' => $code, 'message' => $message, 'file' => $file, 'line' => $line, 'trace' => $trace]; } if ($this->previousErrorHandler === true) { return false; - } elseif ($this->previousErrorHandler) { - return call_user_func($this->previousErrorHandler, $code, $message, $file, $line, $context); } + if ($this->previousErrorHandler instanceof Closure) { + return (bool) ($this->previousErrorHandler)($code, $message, $file, $line); + } + + return true; } /** * @private */ - public function handleFatalError() + public function handleFatalError(): void { $this->reservedMemory = ''; - $lastError = error_get_last(); - if ($lastError && in_array($lastError['type'], self::$fatalErrors, true)) { + if (is_array($this->lastFatalData)) { + $lastError = $this->lastFatalData; + } else { + $lastError = error_get_last(); + } + if (is_array($lastError) && in_array($lastError['type'], self::FATAL_ERRORS, true)) { + $trace = $lastError['trace'] ?? null; $this->logger->log( - $this->fatalLevel === null ? LogLevel::ALERT : $this->fatalLevel, + $this->fatalLevel, 'Fatal Error ('.self::codeToString($lastError['type']).'): '.$lastError['message'], - ['code' => $lastError['type'], 'message' => $lastError['message'], 'file' => $lastError['file'], 'line' => $lastError['line'], 'trace' => $this->lastFatalTrace] + ['code' => $lastError['type'], 'message' => $lastError['message'], 'file' => $lastError['file'], 'line' => $lastError['line'], 'trace' => $trace] ); if ($this->logger instanceof Logger) { @@ -227,41 +254,25 @@ public function handleFatalError() } } - private static function codeToString($code): string + private static function codeToString(int $code): string { - switch ($code) { - case E_ERROR: - return 'E_ERROR'; - case E_WARNING: - return 'E_WARNING'; - case E_PARSE: - return 'E_PARSE'; - case E_NOTICE: - return 'E_NOTICE'; - case E_CORE_ERROR: - return 'E_CORE_ERROR'; - case E_CORE_WARNING: - return 'E_CORE_WARNING'; - case E_COMPILE_ERROR: - return 'E_COMPILE_ERROR'; - case E_COMPILE_WARNING: - return 'E_COMPILE_WARNING'; - case E_USER_ERROR: - return 'E_USER_ERROR'; - case E_USER_WARNING: - return 'E_USER_WARNING'; - case E_USER_NOTICE: - return 'E_USER_NOTICE'; - case E_STRICT: - return 'E_STRICT'; - case E_RECOVERABLE_ERROR: - return 'E_RECOVERABLE_ERROR'; - case E_DEPRECATED: - return 'E_DEPRECATED'; - case E_USER_DEPRECATED: - return 'E_USER_DEPRECATED'; - } - - return 'Unknown PHP error'; + return match ($code) { + E_ERROR => 'E_ERROR', + E_WARNING => 'E_WARNING', + E_PARSE => 'E_PARSE', + E_NOTICE => 'E_NOTICE', + E_CORE_ERROR => 'E_CORE_ERROR', + E_CORE_WARNING => 'E_CORE_WARNING', + E_COMPILE_ERROR => 'E_COMPILE_ERROR', + E_COMPILE_WARNING => 'E_COMPILE_WARNING', + E_USER_ERROR => 'E_USER_ERROR', + E_USER_WARNING => 'E_USER_WARNING', + E_USER_NOTICE => 'E_USER_NOTICE', + E_STRICT => 'E_STRICT', + E_RECOVERABLE_ERROR => 'E_RECOVERABLE_ERROR', + E_DEPRECATED => 'E_DEPRECATED', + E_USER_DEPRECATED => 'E_USER_DEPRECATED', + default => 'Unknown PHP error', + }; } } diff --git a/src/Monolog/Formatter/ChromePHPFormatter.php b/src/Monolog/Formatter/ChromePHPFormatter.php index 2b4d649c8..3f1d45829 100644 --- a/src/Monolog/Formatter/ChromePHPFormatter.php +++ b/src/Monolog/Formatter/ChromePHPFormatter.php @@ -11,7 +11,8 @@ namespace Monolog\Formatter; -use Monolog\Logger; +use Monolog\Level; +use Monolog\LogRecord; /** * Formats a log message according to the ChromePHP array format @@ -22,51 +23,56 @@ class ChromePHPFormatter implements FormatterInterface { /** * Translates Monolog log levels to Wildfire levels. + * + * @return 'log'|'info'|'warn'|'error' */ - private $logLevels = [ - Logger::DEBUG => 'log', - Logger::INFO => 'info', - Logger::NOTICE => 'info', - Logger::WARNING => 'warn', - Logger::ERROR => 'error', - Logger::CRITICAL => 'error', - Logger::ALERT => 'error', - Logger::EMERGENCY => 'error', - ]; + private function toWildfireLevel(Level $level): string + { + return match ($level) { + Level::Debug => 'log', + Level::Info => 'info', + Level::Notice => 'info', + Level::Warning => 'warn', + Level::Error => 'error', + Level::Critical => 'error', + Level::Alert => 'error', + Level::Emergency => 'error', + }; + } /** - * {@inheritdoc} + * @inheritDoc */ - public function format(array $record) + public function format(LogRecord $record) { // Retrieve the line and file if set and remove them from the formatted extra $backtrace = 'unknown'; - if (isset($record['extra']['file'], $record['extra']['line'])) { - $backtrace = $record['extra']['file'].' : '.$record['extra']['line']; - unset($record['extra']['file'], $record['extra']['line']); + if (isset($record->extra['file'], $record->extra['line'])) { + $backtrace = $record->extra['file'].' : '.$record->extra['line']; + unset($record->extra['file'], $record->extra['line']); } - $message = ['message' => $record['message']]; - if ($record['context']) { - $message['context'] = $record['context']; + $message = ['message' => $record->message]; + if (\count($record->context) > 0) { + $message['context'] = $record->context; } - if ($record['extra']) { - $message['extra'] = $record['extra']; + if (\count($record->extra) > 0) { + $message['extra'] = $record->extra; } if (count($message) === 1) { $message = reset($message); } return [ - $record['channel'], + $record->channel, $message, $backtrace, - $this->logLevels[$record['level']], + $this->toWildfireLevel($record->level), ]; } /** - * {@inheritdoc} + * @inheritDoc */ public function formatBatch(array $records) { diff --git a/src/Monolog/Formatter/ElasticaFormatter.php b/src/Monolog/Formatter/ElasticaFormatter.php index a6354f508..160510ad8 100644 --- a/src/Monolog/Formatter/ElasticaFormatter.php +++ b/src/Monolog/Formatter/ElasticaFormatter.php @@ -12,6 +12,7 @@ namespace Monolog\Formatter; use Elastica\Document; +use Monolog\LogRecord; /** * Format a log message into an Elastica Document @@ -23,18 +24,18 @@ class ElasticaFormatter extends NormalizerFormatter /** * @var string Elastic search index name */ - protected $index; + protected string $index; /** - * @var string Elastic search document type + * @var string|null Elastic search document type */ - protected $type; + protected string|null $type; /** - * @param string $index Elastic Search index name - * @param string $type Elastic Search document type + * @param string $index Elastic Search index name + * @param ?string $type Elastic Search document type, deprecated as of Elastica 7 */ - public function __construct(string $index, string $type) + public function __construct(string $index, ?string $type) { // elasticsearch requires a ISO 8601 format date with optional millisecond precision. parent::__construct('Y-m-d\TH:i:s.uP'); @@ -44,9 +45,9 @@ public function __construct(string $index, string $type) } /** - * {@inheritdoc} + * @inheritDoc */ - public function format(array $record) + public function format(LogRecord $record) { $record = parent::format($record); @@ -58,19 +59,27 @@ public function getIndex(): string return $this->index; } + /** + * @deprecated since Elastica 7 type has no effect + */ public function getType(): string { + /** @phpstan-ignore-next-line */ return $this->type; } /** * Convert a log message into an Elastica Document + * + * @param mixed[] $record */ protected function getDocument(array $record): Document { $document = new Document(); $document->setData($record); - $document->setType($this->type); + if (method_exists($document, 'setType')) { + $document->setType($this->type); + } $document->setIndex($this->index); return $document; diff --git a/src/Monolog/Formatter/ElasticsearchFormatter.php b/src/Monolog/Formatter/ElasticsearchFormatter.php new file mode 100644 index 000000000..6c3eb9b2a --- /dev/null +++ b/src/Monolog/Formatter/ElasticsearchFormatter.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Formatter; + +use DateTimeInterface; +use Monolog\LogRecord; + +/** + * Format a log message into an Elasticsearch record + * + * @author Avtandil Kikabidze + */ +class ElasticsearchFormatter extends NormalizerFormatter +{ + /** + * @var string Elasticsearch index name + */ + protected string $index; + + /** + * @var string Elasticsearch record type + */ + protected string $type; + + /** + * @param string $index Elasticsearch index name + * @param string $type Elasticsearch record type + */ + public function __construct(string $index, string $type) + { + // Elasticsearch requires an ISO 8601 format date with optional millisecond precision. + parent::__construct(DateTimeInterface::ISO8601); + + $this->index = $index; + $this->type = $type; + } + + /** + * @inheritDoc + */ + public function format(LogRecord $record) + { + $record = parent::format($record); + + return $this->getDocument($record); + } + + /** + * Getter index + */ + public function getIndex(): string + { + return $this->index; + } + + /** + * Getter type + */ + public function getType(): string + { + return $this->type; + } + + /** + * Convert a log message into an Elasticsearch record + * + * @param mixed[] $record Log message + * @return mixed[] + */ + protected function getDocument(array $record): array + { + $record['_index'] = $this->index; + $record['_type'] = $this->type; + + return $record; + } +} diff --git a/src/Monolog/Formatter/FlowdockFormatter.php b/src/Monolog/Formatter/FlowdockFormatter.php index 301b74b24..b8f7be522 100644 --- a/src/Monolog/Formatter/FlowdockFormatter.php +++ b/src/Monolog/Formatter/FlowdockFormatter.php @@ -11,6 +11,8 @@ namespace Monolog\Formatter; +use Monolog\LogRecord; + /** * formats the record to be used in the FlowdockHandler * @@ -18,15 +20,9 @@ */ class FlowdockFormatter implements FormatterInterface { - /** - * @var string - */ - private $source; + private string $source; - /** - * @var string - */ - private $sourceEmail; + private string $sourceEmail; public function __construct(string $source, string $sourceEmail) { @@ -35,41 +31,43 @@ public function __construct(string $source, string $sourceEmail) } /** - * {@inheritdoc} + * @inheritDoc + * + * @return mixed[] */ - public function format(array $record): array + public function format(LogRecord $record): array { $tags = [ '#logs', - '#' . strtolower($record['level_name']), - '#' . $record['channel'], + '#' . $record->level->toPsrLogLevel(), + '#' . $record->channel, ]; - foreach ($record['extra'] as $value) { + foreach ($record->extra as $value) { $tags[] = '#' . $value; } $subject = sprintf( 'in %s: %s - %s', $this->source, - $record['level_name'], - $this->getShortMessage($record['message']) + $record->level->getName(), + $this->getShortMessage($record->message) ); - $record['flowdock'] = [ + return [ 'source' => $this->source, 'from_address' => $this->sourceEmail, 'subject' => $subject, - 'content' => $record['message'], + 'content' => $record->message, 'tags' => $tags, 'project' => $this->source, ]; - - return $record; } /** - * {@inheritdoc} + * @inheritDoc + * + * @return mixed[][] */ public function formatBatch(array $records): array { diff --git a/src/Monolog/Formatter/FluentdFormatter.php b/src/Monolog/Formatter/FluentdFormatter.php index f8df18508..9bd2c1604 100644 --- a/src/Monolog/Formatter/FluentdFormatter.php +++ b/src/Monolog/Formatter/FluentdFormatter.php @@ -11,6 +11,9 @@ namespace Monolog\Formatter; +use Monolog\Utils; +use Monolog\LogRecord; + /** * Class FluentdFormatter * @@ -37,7 +40,7 @@ class FluentdFormatter implements FormatterInterface /** * @var bool $levelTag should message level be a part of the fluentd tag */ - protected $levelTag = false; + protected bool $levelTag = false; public function __construct(bool $levelTag = false) { @@ -53,25 +56,25 @@ public function isUsingLevelsInTag(): bool return $this->levelTag; } - public function format(array $record): string + public function format(LogRecord $record): string { - $tag = $record['channel']; + $tag = $record->channel; if ($this->levelTag) { - $tag .= '.' . strtolower($record['level_name']); + $tag .= '.' . $record->level->toPsrLogLevel(); } $message = [ - 'message' => $record['message'], - 'context' => $record['context'], - 'extra' => $record['extra'], + 'message' => $record->message, + 'context' => $record->context, + 'extra' => $record->extra, ]; if (!$this->levelTag) { - $message['level'] = $record['level']; - $message['level_name'] = $record['level_name']; + $message['level'] = $record->level->value; + $message['level_name'] = $record->level->getName(); } - return json_encode([$tag, $record['datetime']->getTimestamp(), $message]); + return Utils::jsonEncode([$tag, $record->datetime->getTimestamp(), $message]); } public function formatBatch(array $records): string diff --git a/src/Monolog/Formatter/FormatterInterface.php b/src/Monolog/Formatter/FormatterInterface.php index 7442134ec..3413a4b05 100644 --- a/src/Monolog/Formatter/FormatterInterface.php +++ b/src/Monolog/Formatter/FormatterInterface.php @@ -11,6 +11,8 @@ namespace Monolog\Formatter; +use Monolog\LogRecord; + /** * Interface for formatters * @@ -21,16 +23,16 @@ interface FormatterInterface /** * Formats a log record. * - * @param array $record A record to format - * @return mixed The formatted record + * @param LogRecord $record A record to format + * @return mixed The formatted record */ - public function format(array $record); + public function format(LogRecord $record); /** * Formats a set of log records. * - * @param array $records A set of records to format - * @return mixed The formatted set of records + * @param array $records A set of records to format + * @return mixed The formatted set of records */ public function formatBatch(array $records); } diff --git a/src/Monolog/Formatter/GelfMessageFormatter.php b/src/Monolog/Formatter/GelfMessageFormatter.php index 4e95a6e4f..33116a261 100644 --- a/src/Monolog/Formatter/GelfMessageFormatter.php +++ b/src/Monolog/Formatter/GelfMessageFormatter.php @@ -11,130 +11,140 @@ namespace Monolog\Formatter; -use Monolog\Logger; +use Monolog\Level; use Gelf\Message; +use Monolog\Utils; +use Monolog\LogRecord; /** * Serializes a log message to GELF - * @see http://www.graylog2.org/about/gelf + * @see http://docs.graylog.org/en/latest/pages/gelf.html * * @author Matt Lehner */ class GelfMessageFormatter extends NormalizerFormatter { - const DEFAULT_MAX_LENGTH = 32766; + protected const DEFAULT_MAX_LENGTH = 32766; /** * @var string the name of the system for the Gelf log message */ - protected $systemName; + protected string $systemName; /** * @var string a prefix for 'extra' fields from the Monolog record (optional) */ - protected $extraPrefix; + protected string $extraPrefix; /** * @var string a prefix for 'context' fields from the Monolog record (optional) */ - protected $contextPrefix; + protected string $contextPrefix; /** * @var int max length per field */ - protected $maxLength; + protected int $maxLength; /** * Translates Monolog log levels to Graylog2 log priorities. */ - private $logLevels = [ - Logger::DEBUG => 7, - Logger::INFO => 6, - Logger::NOTICE => 5, - Logger::WARNING => 4, - Logger::ERROR => 3, - Logger::CRITICAL => 2, - Logger::ALERT => 1, - Logger::EMERGENCY => 0, - ]; - - public function __construct(string $systemName = null, string $extraPrefix = null, string $contextPrefix = 'ctxt_', int $maxLength = null) + private function getGraylog2Priority(Level $level): int { + return match ($level) { + Level::Debug => 7, + Level::Info => 6, + Level::Notice => 5, + Level::Warning => 4, + Level::Error => 3, + Level::Critical => 2, + Level::Alert => 1, + Level::Emergency => 0, + }; + } + + public function __construct(?string $systemName = null, ?string $extraPrefix = null, string $contextPrefix = 'ctxt_', ?int $maxLength = null) + { + if (!class_exists(Message::class)) { + throw new \RuntimeException('Composer package graylog2/gelf-php is required to use Monolog\'s GelfMessageFormatter'); + } + parent::__construct('U.u'); - $this->systemName = $systemName ?: gethostname(); + $this->systemName = (null === $systemName || $systemName === '') ? (string) gethostname() : $systemName; - $this->extraPrefix = $extraPrefix; + $this->extraPrefix = null === $extraPrefix ? '' : $extraPrefix; $this->contextPrefix = $contextPrefix; - $this->maxLength = is_null($maxLength) ? self::DEFAULT_MAX_LENGTH : $maxLength; + $this->maxLength = null === $maxLength ? self::DEFAULT_MAX_LENGTH : $maxLength; } /** - * {@inheritdoc} + * @inheritDoc */ - public function format(array $record): Message + public function format(LogRecord $record): Message { - if (isset($record['context'])) { - $record['context'] = parent::format($record['context']); + $context = $extra = []; + if (isset($record->context)) { + /** @var mixed[] $context */ + $context = parent::normalize($record->context); } - if (isset($record['extra'])) { - $record['extra'] = parent::format($record['extra']); - } - - if (!isset($record['datetime'], $record['message'], $record['level'])) { - throw new \InvalidArgumentException('The record should at least contain datetime, message and level keys, '.var_export($record, true).' given'); + if (isset($record->extra)) { + /** @var mixed[] $extra */ + $extra = parent::normalize($record->extra); } $message = new Message(); $message - ->setTimestamp($record['datetime']) - ->setShortMessage((string) $record['message']) + ->setTimestamp($record->datetime) + ->setShortMessage($record->message) ->setHost($this->systemName) - ->setLevel($this->logLevels[$record['level']]); + ->setLevel($this->getGraylog2Priority($record->level)); // message length + system name length + 200 for padding / metadata - $len = 200 + strlen((string) $record['message']) + strlen($this->systemName); + $len = 200 + strlen($record->message) + strlen($this->systemName); if ($len > $this->maxLength) { - $message->setShortMessage(substr($record['message'], 0, $this->maxLength)); + $message->setShortMessage(Utils::substr($record->message, 0, $this->maxLength)); } - if (isset($record['channel'])) { - $message->setFacility($record['channel']); + if (isset($record->channel)) { + $message->setAdditional('facility', $record->channel); } - if (isset($record['extra']['line'])) { - $message->setLine($record['extra']['line']); - unset($record['extra']['line']); + if (isset($extra['line'])) { + $message->setAdditional('line', $extra['line']); + unset($extra['line']); } - if (isset($record['extra']['file'])) { - $message->setFile($record['extra']['file']); - unset($record['extra']['file']); + if (isset($extra['file'])) { + $message->setAdditional('file', $extra['file']); + unset($extra['file']); } - foreach ($record['extra'] as $key => $val) { + foreach ($extra as $key => $val) { $val = is_scalar($val) || null === $val ? $val : $this->toJson($val); $len = strlen($this->extraPrefix . $key . $val); if ($len > $this->maxLength) { - $message->setAdditional($this->extraPrefix . $key, substr($val, 0, $this->maxLength)); - break; + $message->setAdditional($this->extraPrefix . $key, Utils::substr((string) $val, 0, $this->maxLength)); + + continue; } $message->setAdditional($this->extraPrefix . $key, $val); } - foreach ($record['context'] as $key => $val) { + foreach ($context as $key => $val) { $val = is_scalar($val) || null === $val ? $val : $this->toJson($val); $len = strlen($this->contextPrefix . $key . $val); if ($len > $this->maxLength) { - $message->setAdditional($this->contextPrefix . $key, substr($val, 0, $this->maxLength)); - break; + $message->setAdditional($this->contextPrefix . $key, Utils::substr((string) $val, 0, $this->maxLength)); + + continue; } $message->setAdditional($this->contextPrefix . $key, $val); } - if (null === $message->getFile() && isset($record['context']['exception']['file'])) { - if (preg_match("/^(.+):([0-9]+)$/", $record['context']['exception']['file'], $matches)) { - $message->setFile($matches[1]); - $message->setLine($matches[2]); + if (!$message->hasAdditional('file') && isset($context['exception']['file'])) { + if (1 === preg_match("/^(.+):([0-9]+)$/", $context['exception']['file'], $matches)) { + $message->setAdditional('file', $matches[1]); + $message->setAdditional('line', $matches[2]); } } diff --git a/src/Monolog/Formatter/GoogleCloudLoggingFormatter.php b/src/Monolog/Formatter/GoogleCloudLoggingFormatter.php new file mode 100644 index 000000000..d37d1e0cc --- /dev/null +++ b/src/Monolog/Formatter/GoogleCloudLoggingFormatter.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Formatter; + +use DateTimeInterface; +use Monolog\LogRecord; + +/** + * Encodes message information into JSON in a format compatible with Cloud logging. + * + * @see https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry + * + * @author Luís Cobucci + */ +final class GoogleCloudLoggingFormatter extends JsonFormatter +{ + protected function normalizeRecord(LogRecord $record): array + { + $normalized = parent::normalizeRecord($record); + + // Re-key level for GCP logging + $normalized['severity'] = $normalized['level_name']; + $normalized['timestamp'] = $record->datetime->format(DateTimeInterface::RFC3339_EXTENDED); + + // Remove keys that are not used by GCP + unset($normalized['level'], $normalized['level_name'], $normalized['datetime']); + + return $normalized; + } +} diff --git a/src/Monolog/Formatter/HtmlFormatter.php b/src/Monolog/Formatter/HtmlFormatter.php index 26f74fa99..bf1c61da3 100644 --- a/src/Monolog/Formatter/HtmlFormatter.php +++ b/src/Monolog/Formatter/HtmlFormatter.php @@ -11,7 +11,9 @@ namespace Monolog\Formatter; -use Monolog\Logger; +use Monolog\Level; +use Monolog\Utils; +use Monolog\LogRecord; /** * Formats incoming records into an HTML table @@ -25,21 +27,24 @@ class HtmlFormatter extends NormalizerFormatter /** * Translates Monolog log levels to html color priorities. */ - protected $logLevels = [ - Logger::DEBUG => '#cccccc', - Logger::INFO => '#468847', - Logger::NOTICE => '#3a87ad', - Logger::WARNING => '#c09853', - Logger::ERROR => '#f0ad4e', - Logger::CRITICAL => '#FF7708', - Logger::ALERT => '#C12A19', - Logger::EMERGENCY => '#000000', - ]; + protected function getLevelColor(Level $level): string + { + return match ($level) { + Level::Debug => '#CCCCCC', + Level::Info => '#28A745', + Level::Notice => '#17A2B8', + Level::Warning => '#FFC107', + Level::Error => '#FD7E14', + Level::Critical => '#DC3545', + Level::Alert => '#821722', + Level::Emergency => '#000000', + }; + } /** - * @param string $dateFormat The format of the timestamp: one supported by DateTime::format + * @param string|null $dateFormat The format of the timestamp: one supported by DateTime::format */ - public function __construct(string $dateFormat = null) + public function __construct(?string $dateFormat = null) { parent::__construct($dateFormat); } @@ -47,10 +52,9 @@ public function __construct(string $dateFormat = null) /** * Creates an HTML table row * - * @param string $th Row header content - * @param string $td Row standard cell content - * @param bool $escapeTd false if td content must not be html escaped - * @return string + * @param string $th Row header content + * @param string $td Row standard cell content + * @param bool $escapeTd false if td content must not be html escaped */ protected function addRow(string $th, string $td = ' ', bool $escapeTd = true): string { @@ -65,43 +69,40 @@ protected function addRow(string $th, string $td = ' ', bool $escapeTd = true): /** * Create a HTML h1 tag * - * @param string $title Text to be in the h1 - * @param int $level Error level - * @return string + * @param string $title Text to be in the h1 */ - protected function addTitle(string $title, int $level) + protected function addTitle(string $title, Level $level): string { $title = htmlspecialchars($title, ENT_NOQUOTES, 'UTF-8'); - return '

'.$title.'

'; + return '

'.$title.'

'; } /** * Formats a log record. * - * @param array $record A record to format - * @return mixed The formatted record + * @return string The formatted record */ - public function format(array $record): string + public function format(LogRecord $record): string { - $output = $this->addTitle($record['level_name'], $record['level']); + $output = $this->addTitle($record->level->getName(), $record->level); $output .= ''; - $output .= $this->addRow('Message', (string) $record['message']); - $output .= $this->addRow('Time', $this->formatDate($record['datetime'])); - $output .= $this->addRow('Channel', $record['channel']); - if ($record['context']) { + $output .= $this->addRow('Message', $record->message); + $output .= $this->addRow('Time', $this->formatDate($record->datetime)); + $output .= $this->addRow('Channel', $record->channel); + if (\count($record->context) > 0) { $embeddedTable = '
'; - foreach ($record['context'] as $key => $value) { - $embeddedTable .= $this->addRow($key, $this->convertToString($value)); + foreach ($record->context as $key => $value) { + $embeddedTable .= $this->addRow((string) $key, $this->convertToString($value)); } $embeddedTable .= '
'; $output .= $this->addRow('Context', $embeddedTable, false); } - if ($record['extra']) { + if (\count($record->extra) > 0) { $embeddedTable = ''; - foreach ($record['extra'] as $key => $value) { - $embeddedTable .= $this->addRow($key, $this->convertToString($value)); + foreach ($record->extra as $key => $value) { + $embeddedTable .= $this->addRow((string) $key, $this->convertToString($value)); } $embeddedTable .= '
'; $output .= $this->addRow('Extra', $embeddedTable, false); @@ -113,8 +114,7 @@ public function format(array $record): string /** * Formats a set of log records. * - * @param array $records A set of records to format - * @return mixed The formatted set of records + * @return string The formatted set of records */ public function formatBatch(array $records): string { @@ -126,6 +126,9 @@ public function formatBatch(array $records): string return $message; } + /** + * @param mixed $data + */ protected function convertToString($data): string { if (null === $data || is_scalar($data)) { @@ -134,6 +137,6 @@ protected function convertToString($data): string $data = $this->normalize($data); - return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + return Utils::jsonEncode($data, JSON_PRETTY_PRINT | Utils::DEFAULT_JSON_FLAGS, true); } } diff --git a/src/Monolog/Formatter/JsonFormatter.php b/src/Monolog/Formatter/JsonFormatter.php index e0d5449e4..b2fc5bcbc 100644 --- a/src/Monolog/Formatter/JsonFormatter.php +++ b/src/Monolog/Formatter/JsonFormatter.php @@ -11,7 +11,9 @@ namespace Monolog\Formatter; +use Stringable; use Throwable; +use Monolog\LogRecord; /** * Encodes whatever record data is passed to it as json @@ -22,21 +24,29 @@ */ class JsonFormatter extends NormalizerFormatter { - const BATCH_MODE_JSON = 1; - const BATCH_MODE_NEWLINES = 2; + public const BATCH_MODE_JSON = 1; + public const BATCH_MODE_NEWLINES = 2; - protected $batchMode; - protected $appendNewline; + /** @var self::BATCH_MODE_* */ + protected int $batchMode; + + protected bool $appendNewline; + + protected bool $ignoreEmptyContextAndExtra; + + protected bool $includeStacktraces = false; /** - * @var bool + * @param self::BATCH_MODE_* $batchMode */ - protected $includeStacktraces = false; - - public function __construct(int $batchMode = self::BATCH_MODE_JSON, bool $appendNewline = true) + public function __construct(int $batchMode = self::BATCH_MODE_JSON, bool $appendNewline = true, bool $ignoreEmptyContextAndExtra = false, bool $includeStacktraces = false) { $this->batchMode = $batchMode; $this->appendNewline = $appendNewline; + $this->ignoreEmptyContextAndExtra = $ignoreEmptyContextAndExtra; + $this->includeStacktraces = $includeStacktraces; + + parent::__construct(); } /** @@ -60,43 +70,52 @@ public function isAppendingNewlines(): bool } /** - * {@inheritdoc} + * @inheritDoc */ - public function format(array $record): string + public function format(LogRecord $record): string { - $normalized = $this->normalize($record); + $normalized = parent::format($record); + if (isset($normalized['context']) && $normalized['context'] === []) { - $normalized['context'] = new \stdClass; + if ($this->ignoreEmptyContextAndExtra) { + unset($normalized['context']); + } else { + $normalized['context'] = new \stdClass; + } } if (isset($normalized['extra']) && $normalized['extra'] === []) { - $normalized['extra'] = new \stdClass; + if ($this->ignoreEmptyContextAndExtra) { + unset($normalized['extra']); + } else { + $normalized['extra'] = new \stdClass; + } } return $this->toJson($normalized, true) . ($this->appendNewline ? "\n" : ''); } /** - * {@inheritdoc} + * @inheritDoc */ public function formatBatch(array $records): string { - switch ($this->batchMode) { - case static::BATCH_MODE_NEWLINES: - return $this->formatBatchNewlines($records); - - case static::BATCH_MODE_JSON: - default: - return $this->formatBatchJson($records); - } + return match ($this->batchMode) { + static::BATCH_MODE_NEWLINES => $this->formatBatchNewlines($records), + default => $this->formatBatchJson($records), + }; } - public function includeStacktraces(bool $include = true) + public function includeStacktraces(bool $include = true): self { $this->includeStacktraces = $include; + + return $this; } /** * Return a JSON-encoded array of records. + * + * @phpstan-param LogRecord[] $records */ protected function formatBatchJson(array $records): string { @@ -106,35 +125,31 @@ protected function formatBatchJson(array $records): string /** * Use new lines to separate records instead of a * JSON-encoded array. + * + * @phpstan-param LogRecord[] $records */ protected function formatBatchNewlines(array $records): string { - $instance = $this; - $oldNewline = $this->appendNewline; $this->appendNewline = false; - array_walk($records, function (&$value, $key) use ($instance) { - $value = $instance->format($value); - }); + $formatted = array_map(fn (LogRecord $record) => $this->format($record), $records); $this->appendNewline = $oldNewline; - return implode("\n", $records); + return implode("\n", $formatted); } /** * Normalizes given $data. * - * @param mixed $data - * - * @return mixed + * @return null|scalar|array|object */ - protected function normalize($data, int $depth = 0) + protected function normalize(mixed $data, int $depth = 0): mixed { if ($depth > $this->maxNormalizeDepth) { return 'Over '.$this->maxNormalizeDepth.' levels deep, aborting normalization'; } - if (is_array($data) || $data instanceof \Traversable) { + if (is_array($data)) { $normalized = []; $count = 1; @@ -150,8 +165,29 @@ protected function normalize($data, int $depth = 0) return $normalized; } - if ($data instanceof Throwable) { - return $this->normalizeException($data, $depth); + if (is_object($data)) { + if ($data instanceof \DateTimeInterface) { + return $this->formatDate($data); + } + + if ($data instanceof Throwable) { + return $this->normalizeException($data, $depth); + } + + // if the object has specific json serializability we want to make sure we skip the __toString treatment below + if ($data instanceof \JsonSerializable) { + return $data; + } + + if ($data instanceof Stringable) { + return $data->__toString(); + } + + return $data; + } + + if (is_resource($data)) { + return parent::normalize($data); } return $data; @@ -160,33 +196,14 @@ protected function normalize($data, int $depth = 0) /** * Normalizes given exception with or without its own stack trace based on * `includeStacktraces` property. + * + * @inheritDoc */ protected function normalizeException(Throwable $e, int $depth = 0): array { - $data = [ - 'class' => get_class($e), - 'message' => $e->getMessage(), - 'code' => $e->getCode(), - 'file' => $e->getFile().':'.$e->getLine(), - ]; - - if ($this->includeStacktraces) { - $trace = $e->getTrace(); - foreach ($trace as $frame) { - if (isset($frame['file'])) { - $data['trace'][] = $frame['file'].':'.$frame['line']; - } elseif (isset($frame['function']) && $frame['function'] === '{closure}') { - // We should again normalize the frames, because it might contain invalid items - $data['trace'][] = $frame['function']; - } else { - // We should again normalize the frames, because it might contain invalid items - $data['trace'][] = $this->normalize($frame); - } - } - } - - if ($previous = $e->getPrevious()) { - $data['previous'] = $this->normalizeException($previous, $depth + 1); + $data = parent::normalizeException($e, $depth); + if (!$this->includeStacktraces) { + unset($data['trace']); } return $data; diff --git a/src/Monolog/Formatter/LineFormatter.php b/src/Monolog/Formatter/LineFormatter.php index 6cbdb5209..19fb72c53 100644 --- a/src/Monolog/Formatter/LineFormatter.php +++ b/src/Monolog/Formatter/LineFormatter.php @@ -11,6 +11,10 @@ namespace Monolog\Formatter; +use Closure; +use Monolog\Utils; +use Monolog\LogRecord; + /** * Formats incoming records into a one-line string * @@ -21,54 +25,61 @@ */ class LineFormatter extends NormalizerFormatter { - const SIMPLE_FORMAT = "[%datetime%] %channel%.%level_name%: %message% %context% %extra%\n"; + public const SIMPLE_FORMAT = "[%datetime%] %channel%.%level_name%: %message% %context% %extra%\n"; - protected $format; - protected $allowInlineLineBreaks; - protected $ignoreEmptyContextAndExtra; - protected $includeStacktraces; + protected string $format; + protected bool $allowInlineLineBreaks; + protected bool $ignoreEmptyContextAndExtra; + protected bool $includeStacktraces; + protected Closure|null $stacktracesParser = null; /** - * @param string $format The format of the message - * @param string $dateFormat The format of the timestamp: one supported by DateTime::format - * @param bool $allowInlineLineBreaks Whether to allow inline line breaks in log entries - * @param bool $ignoreEmptyContextAndExtra + * @param string|null $format The format of the message + * @param string|null $dateFormat The format of the timestamp: one supported by DateTime::format + * @param bool $allowInlineLineBreaks Whether to allow inline line breaks in log entries */ - public function __construct(string $format = null, string $dateFormat = null, bool $allowInlineLineBreaks = false, bool $ignoreEmptyContextAndExtra = false) + public function __construct(?string $format = null, ?string $dateFormat = null, bool $allowInlineLineBreaks = false, bool $ignoreEmptyContextAndExtra = false, bool $includeStacktraces = false) { - $this->format = $format ?: static::SIMPLE_FORMAT; + $this->format = $format === null ? static::SIMPLE_FORMAT : $format; $this->allowInlineLineBreaks = $allowInlineLineBreaks; $this->ignoreEmptyContextAndExtra = $ignoreEmptyContextAndExtra; + $this->includeStacktraces($includeStacktraces); parent::__construct($dateFormat); } - public function includeStacktraces(bool $include = true) + public function includeStacktraces(bool $include = true, ?Closure $parser = null): self { $this->includeStacktraces = $include; if ($this->includeStacktraces) { $this->allowInlineLineBreaks = true; + $this->stacktracesParser = $parser; } + + return $this; } - public function allowInlineLineBreaks(bool $allow = true) + public function allowInlineLineBreaks(bool $allow = true): self { $this->allowInlineLineBreaks = $allow; + + return $this; } - public function ignoreEmptyContextAndExtra(bool $ignore = true) + public function ignoreEmptyContextAndExtra(bool $ignore = true): self { $this->ignoreEmptyContextAndExtra = $ignore; + + return $this; } /** - * {@inheritdoc} + * @inheritDoc */ - public function format(array $record): string + public function format(LogRecord $record): string { $vars = parent::format($record); $output = $this->format; - foreach ($vars['extra'] as $var => $val) { if (false !== strpos($output, '%extra.'.$var.'%')) { $output = str_replace('%extra.'.$var.'%', $this->stringify($val), $output); @@ -84,12 +95,12 @@ public function format(array $record): string } if ($this->ignoreEmptyContextAndExtra) { - if (empty($vars['context'])) { + if (\count($vars['context']) === 0) { unset($vars['context']); $output = str_replace('%context%', '', $output); } - if (empty($vars['extra'])) { + if (\count($vars['extra']) === 0) { unset($vars['extra']); $output = str_replace('%extra%', '', $output); } @@ -104,6 +115,11 @@ public function format(array $record): string // remove leftover %extra.xxx% and %context.xxx% if any if (false !== strpos($output, '%')) { $output = preg_replace('/%(?:extra|context)\..+?%/', '', $output); + if (null === $output) { + $pcreErrorCode = preg_last_error(); + + throw new \RuntimeException('Failed to run preg_replace: ' . $pcreErrorCode . ' / ' . Utils::pcreLastErrorMessage($pcreErrorCode)); + } } return $output; @@ -119,6 +135,9 @@ public function formatBatch(array $records): string return $message; } + /** + * @param mixed $value + */ public function stringify($value): string { return $this->replaceNewlines($this->convertToString($value)); @@ -128,8 +147,14 @@ protected function normalizeException(\Throwable $e, int $depth = 0): string { $str = $this->formatException($e); - if ($previous = $e->getPrevious()) { + if (($previous = $e->getPrevious()) instanceof \Throwable) { do { + $depth++; + if ($depth > $this->maxNormalizeDepth) { + $str .= '\n[previous exception] Over ' . $this->maxNormalizeDepth . ' levels deep, aborting normalization'; + break; + } + $str .= "\n[previous exception] " . $this->formatException($previous); } while ($previous = $previous->getPrevious()); } @@ -137,6 +162,9 @@ protected function normalizeException(\Throwable $e, int $depth = 0): string return $str; } + /** + * @param mixed $data + */ protected function convertToString($data): string { if (null === $data || is_bool($data)) { @@ -147,14 +175,18 @@ protected function convertToString($data): string return (string) $data; } - return (string) $this->toJson($data, true); + return $this->toJson($data, true); } protected function replaceNewlines(string $str): string { if ($this->allowInlineLineBreaks) { if (0 === strpos($str, '{')) { - return str_replace(array('\r', '\n'), array("\r", "\n"), $str); + $str = preg_replace('/(?getCode() . '): ' . $e->getMessage() . ' at ' . $e->getFile() . ':' . $e->getLine() . ')'; + $str = '[object] (' . Utils::getClass($e) . '(code: ' . $e->getCode(); + if ($e instanceof \SoapFault) { + if (isset($e->faultcode)) { + $str .= ' faultcode: ' . $e->faultcode; + } + + if (isset($e->faultactor)) { + $str .= ' faultactor: ' . $e->faultactor; + } + + if (isset($e->detail)) { + if (is_string($e->detail)) { + $str .= ' detail: ' . $e->detail; + } elseif (is_object($e->detail) || is_array($e->detail)) { + $str .= ' detail: ' . $this->toJson($e->detail, true); + } + } + } + $str .= '): ' . $e->getMessage() . ' at ' . $e->getFile() . ':' . $e->getLine() . ')'; + if ($this->includeStacktraces) { - $str .= "\n[stacktrace]\n" . $e->getTraceAsString() . "\n"; + $str .= $this->stacktracesParser($e); } return $str; } + + private function stacktracesParser(\Throwable $e): string + { + $trace = $e->getTraceAsString(); + + if ($this->stacktracesParser !== null) { + $trace = $this->stacktracesParserCustom($trace); + } + + return "\n[stacktrace]\n" . $trace . "\n"; + } + + private function stacktracesParserCustom(string $trace): string + { + return implode("\n", array_filter(array_map($this->stacktracesParser, explode("\n", $trace)))); + } } diff --git a/src/Monolog/Formatter/LogglyFormatter.php b/src/Monolog/Formatter/LogglyFormatter.php index 29841aa38..5f0b6a453 100644 --- a/src/Monolog/Formatter/LogglyFormatter.php +++ b/src/Monolog/Formatter/LogglyFormatter.php @@ -11,6 +11,8 @@ namespace Monolog\Formatter; +use Monolog\LogRecord; + /** * Encodes message information into JSON in a format compatible with Loggly. * @@ -33,13 +35,13 @@ public function __construct(int $batchMode = self::BATCH_MODE_NEWLINES, bool $ap * @see https://www.loggly.com/docs/automated-parsing/#json * @see \Monolog\Formatter\JsonFormatter::format() */ - public function format(array $record): string + protected function normalizeRecord(LogRecord $record): array { - if (isset($record["datetime"]) && ($record["datetime"] instanceof \DateTimeInterface)) { - $record["timestamp"] = $record["datetime"]->format("Y-m-d\TH:i:s.uO"); - unset($record["datetime"]); - } + $recordData = parent::normalizeRecord($record); + + $recordData["timestamp"] = $record->datetime->format("Y-m-d\TH:i:s.uO"); + unset($recordData["datetime"]); - return parent::format($record); + return $recordData; } } diff --git a/src/Monolog/Formatter/LogmaticFormatter.php b/src/Monolog/Formatter/LogmaticFormatter.php index 7a75e00f3..10ad0d9c0 100644 --- a/src/Monolog/Formatter/LogmaticFormatter.php +++ b/src/Monolog/Formatter/LogmaticFormatter.php @@ -11,6 +11,8 @@ namespace Monolog\Formatter; +use Monolog\LogRecord; + /** * Encodes message information into JSON in a format compatible with Logmatic. * @@ -18,36 +20,24 @@ */ class LogmaticFormatter extends JsonFormatter { - const MARKERS = ["sourcecode", "php"]; + protected const MARKERS = ["sourcecode", "php"]; - /** - * @param string - */ - protected $hostname = ''; + protected string $hostname = ''; - /** - * @param string - */ - protected $appname = ''; + protected string $appName = ''; - /** - * Set hostname - * - * @param string $hostname - */ - public function setHostname(string $hostname) + public function setHostname(string $hostname): self { $this->hostname = $hostname; + + return $this; } - /** - * Set appname - * - * @param string $appname - */ - public function setAppname(string $appname) + public function setAppName(string $appName): self { - $this->appname = $appname; + $this->appName = $appName; + + return $this; } /** @@ -56,17 +46,19 @@ public function setAppname(string $appname) * @see http://doc.logmatic.io/docs/basics-to-send-data * @see \Monolog\Formatter\JsonFormatter::format() */ - public function format(array $record): string + public function normalizeRecord(LogRecord $record): array { - if (!empty($this->hostname)) { + $record = parent::normalizeRecord($record); + + if ($this->hostname !== '') { $record["hostname"] = $this->hostname; } - if (!empty($this->appname)) { - $record["appname"] = $this->appname; + if ($this->appName !== '') { + $record["appname"] = $this->appName; } - $record["@marker"] = self::MARKERS; + $record["@marker"] = static::MARKERS; - return parent::format($record); + return $record; } } diff --git a/src/Monolog/Formatter/LogstashFormatter.php b/src/Monolog/Formatter/LogstashFormatter.php index 74464e57f..d0e8749e3 100644 --- a/src/Monolog/Formatter/LogstashFormatter.php +++ b/src/Monolog/Formatter/LogstashFormatter.php @@ -11,6 +11,8 @@ namespace Monolog\Formatter; +use Monolog\LogRecord; + /** * Serializes a log message to Logstash Event Format * @@ -24,76 +26,73 @@ class LogstashFormatter extends NormalizerFormatter /** * @var string the name of the system for the Logstash log message, used to fill the @source field */ - protected $systemName; + protected string $systemName; /** * @var string an application name for the Logstash log message, used to fill the @type field */ - protected $applicationName; + protected string $applicationName; /** * @var string the key for 'extra' fields from the Monolog record */ - protected $extraKey; + protected string $extraKey; /** * @var string the key for 'context' fields from the Monolog record */ - protected $contextKey; + protected string $contextKey; /** - * @param string $applicationName the application that sends the data, used as the "type" field of logstash - * @param string $systemName the system/machine name, used as the "source" field of logstash, defaults to the hostname of the machine - * @param string $extraKey the key for extra keys inside logstash "fields", defaults to extra - * @param string $contextKey the key for context keys inside logstash "fields", defaults to context + * @param string $applicationName The application that sends the data, used as the "type" field of logstash + * @param string|null $systemName The system/machine name, used as the "source" field of logstash, defaults to the hostname of the machine + * @param string $extraKey The key for extra keys inside logstash "fields", defaults to extra + * @param string $contextKey The key for context keys inside logstash "fields", defaults to context */ - public function __construct(string $applicationName, string $systemName = null, string $extraKey = 'extra', string $contextKey = 'context') + public function __construct(string $applicationName, ?string $systemName = null, string $extraKey = 'extra', string $contextKey = 'context') { // logstash requires a ISO 8601 format date with optional millisecond precision. parent::__construct('Y-m-d\TH:i:s.uP'); - $this->systemName = $systemName ?: gethostname(); + $this->systemName = $systemName === null ? (string) gethostname() : $systemName; $this->applicationName = $applicationName; $this->extraKey = $extraKey; $this->contextKey = $contextKey; } /** - * {@inheritdoc} + * @inheritDoc */ - public function format(array $record): string + public function format(LogRecord $record): string { - $record = parent::format($record); + $recordData = parent::format($record); - if (empty($record['datetime'])) { - $record['datetime'] = gmdate('c'); - } $message = [ - '@timestamp' => $record['datetime'], + '@timestamp' => $recordData['datetime'], '@version' => 1, 'host' => $this->systemName, ]; - if (isset($record['message'])) { - $message['message'] = $record['message']; + if (isset($recordData['message'])) { + $message['message'] = $recordData['message']; } - if (isset($record['channel'])) { - $message['type'] = $record['channel']; - $message['channel'] = $record['channel']; + if (isset($recordData['channel'])) { + $message['type'] = $recordData['channel']; + $message['channel'] = $recordData['channel']; } - if (isset($record['level_name'])) { - $message['level'] = $record['level_name']; + if (isset($recordData['level_name'])) { + $message['level'] = $recordData['level_name']; } - if (isset($record['level'])) { - $message['monolog_level'] = $record['level']; + if (isset($recordData['level'])) { + $message['monolog_level'] = $recordData['level']; } - if ($this->applicationName) { + if ('' !== $this->applicationName) { $message['type'] = $this->applicationName; } - if (!empty($record['extra'])) { - $message[$this->extraKey] = $record['extra']; + if (\count($recordData['extra']) > 0) { + $message[$this->extraKey] = $recordData['extra']; } - if (!empty($record['context'])) { - $message[$this->contextKey] = $record['context']; + if (\count($recordData['context']) > 0) { + $message[$this->contextKey] = $recordData['context']; } return $this->toJson($message) . "\n"; diff --git a/src/Monolog/Formatter/MongoDBFormatter.php b/src/Monolog/Formatter/MongoDBFormatter.php index 8c40e3ec8..a3bdd4f87 100644 --- a/src/Monolog/Formatter/MongoDBFormatter.php +++ b/src/Monolog/Formatter/MongoDBFormatter.php @@ -11,7 +11,10 @@ namespace Monolog\Formatter; +use MongoDB\BSON\Type; use MongoDB\BSON\UTCDateTime; +use Monolog\Utils; +use Monolog\LogRecord; /** * Formats a record for use with the MongoDBHandler. @@ -20,12 +23,12 @@ */ class MongoDBFormatter implements FormatterInterface { - private $exceptionTraceAsString; - private $maxNestingLevel; - private $isLegacyMongoExt; + private bool $exceptionTraceAsString; + private int $maxNestingLevel; + private bool $isLegacyMongoExt; /** - * @param int $maxNestingLevel 0 means infinite nesting, the $record itself is level 1, $record['context'] is 2 + * @param int $maxNestingLevel 0 means infinite nesting, the $record itself is level 1, $record->context is 2 * @param bool $exceptionTraceAsString set to false to log exception traces as a sub documents instead of strings */ public function __construct(int $maxNestingLevel = 3, bool $exceptionTraceAsString = true) @@ -33,67 +36,83 @@ public function __construct(int $maxNestingLevel = 3, bool $exceptionTraceAsStri $this->maxNestingLevel = max($maxNestingLevel, 0); $this->exceptionTraceAsString = $exceptionTraceAsString; - $this->isLegacyMongoExt = version_compare(phpversion('mongodb'), '1.1.9', '<='); + $this->isLegacyMongoExt = extension_loaded('mongodb') && version_compare((string) phpversion('mongodb'), '1.1.9', '<='); } /** - * {@inheritDoc} + * @inheritDoc + * + * @return mixed[] */ - public function format(array $record): array + public function format(LogRecord $record): array { - return $this->formatArray($record); + /** @var mixed[] $res */ + $res = $this->formatArray($record->toArray()); + + return $res; } /** - * {@inheritDoc} + * @inheritDoc + * + * @return array */ public function formatBatch(array $records): array { + $formatted = []; foreach ($records as $key => $record) { - $records[$key] = $this->format($record); + $formatted[$key] = $this->format($record); } - return $records; + return $formatted; } /** - * @return array|string Array except when max nesting level is reached then a string "[...]" + * @param mixed[] $array + * @return mixed[]|string Array except when max nesting level is reached then a string "[...]" */ - protected function formatArray(array $record, int $nestingLevel = 0) + protected function formatArray(array $array, int $nestingLevel = 0) { - if ($this->maxNestingLevel == 0 || $nestingLevel <= $this->maxNestingLevel) { - foreach ($record as $name => $value) { - if ($value instanceof \DateTimeInterface) { - $record[$name] = $this->formatDate($value, $nestingLevel + 1); - } elseif ($value instanceof \Throwable) { - $record[$name] = $this->formatException($value, $nestingLevel + 1); - } elseif (is_array($value)) { - $record[$name] = $this->formatArray($value, $nestingLevel + 1); - } elseif (is_object($value)) { - $record[$name] = $this->formatObject($value, $nestingLevel + 1); - } + if ($this->maxNestingLevel > 0 && $nestingLevel > $this->maxNestingLevel) { + return '[...]'; + } + + foreach ($array as $name => $value) { + if ($value instanceof \DateTimeInterface) { + $array[$name] = $this->formatDate($value, $nestingLevel + 1); + } elseif ($value instanceof \Throwable) { + $array[$name] = $this->formatException($value, $nestingLevel + 1); + } elseif (is_array($value)) { + $array[$name] = $this->formatArray($value, $nestingLevel + 1); + } elseif (is_object($value) && !$value instanceof Type) { + $array[$name] = $this->formatObject($value, $nestingLevel + 1); } - } else { - $record = '[...]'; } - return $record; + return $array; } + /** + * @param mixed $value + * @return mixed[]|string + */ protected function formatObject($value, int $nestingLevel) { $objectVars = get_object_vars($value); - $objectVars['class'] = get_class($value); + $objectVars['class'] = Utils::getClass($value); return $this->formatArray($objectVars, $nestingLevel); } + /** + * @return mixed[]|string + */ protected function formatException(\Throwable $exception, int $nestingLevel) { $formattedException = [ - 'class' => get_class($exception), + 'class' => Utils::getClass($exception), 'message' => $exception->getMessage(), - 'code' => $exception->getCode(), + 'code' => (int) $exception->getCode(), 'file' => $exception->getFile() . ':' . $exception->getLine(), ]; @@ -117,7 +136,7 @@ protected function formatDate(\DateTimeInterface $value, int $nestingLevel): UTC private function getMongoDbDateTime(\DateTimeInterface $value): UTCDateTime { - return new UTCDateTime((int) (string) floor($value->format('U.u') * 1000)); + return new UTCDateTime((int) floor(((float) $value->format('U.u')) * 1000)); } /** @@ -129,12 +148,13 @@ private function getMongoDbDateTime(\DateTimeInterface $value): UTCDateTime */ private function legacyGetMongoDbDateTime(\DateTimeInterface $value): UTCDateTime { - $milliseconds = floor($value->format('U.u') * 1000); + $milliseconds = floor(((float) $value->format('U.u')) * 1000); $milliseconds = (PHP_INT_SIZE == 8) //64-bit OS? ? (int) $milliseconds : (string) $milliseconds; + // @phpstan-ignore-next-line return new UTCDateTime($milliseconds); } } diff --git a/src/Monolog/Formatter/NormalizerFormatter.php b/src/Monolog/Formatter/NormalizerFormatter.php index 2177f91e4..1323587b5 100644 --- a/src/Monolog/Formatter/NormalizerFormatter.php +++ b/src/Monolog/Formatter/NormalizerFormatter.php @@ -11,8 +11,10 @@ namespace Monolog\Formatter; -use Throwable; use Monolog\DateTimeImmutable; +use Monolog\Utils; +use Throwable; +use Monolog\LogRecord; /** * Normalizes incoming records to remove objects/resources so it's easier to dump to various targets @@ -21,16 +23,18 @@ */ class NormalizerFormatter implements FormatterInterface { - const SIMPLE_DATE = "Y-m-d\TH:i:sP"; + public const SIMPLE_DATE = "Y-m-d\TH:i:sP"; + + protected string $dateFormat; + protected int $maxNormalizeDepth = 9; + protected int $maxNormalizeItemCount = 1000; - protected $dateFormat; - protected $maxNormalizeDepth = 9; - protected $maxNormalizeItemCount = 1000; + private int $jsonEncodeOptions = Utils::DEFAULT_JSON_FLAGS; /** - * @param string $dateFormat The format of the timestamp: one supported by DateTime::format + * @param string|null $dateFormat The format of the timestamp: one supported by DateTime::format */ - public function __construct(string $dateFormat = null) + public function __construct(?string $dateFormat = null) { $this->dateFormat = null === $dateFormat ? static::SIMPLE_DATE : $dateFormat; if (!function_exists('json_encode')) { @@ -39,15 +43,25 @@ public function __construct(string $dateFormat = null) } /** - * {@inheritdoc} + * @inheritDoc */ - public function format(array $record) + public function format(LogRecord $record) { - return $this->normalize($record); + return $this->normalizeRecord($record); } /** - * {@inheritdoc} + * Normalize an arbitrary value to a scalar|array|null + * + * @return null|scalar|array + */ + public function normalizeValue(mixed $data): mixed + { + return $this->normalize($data); + } + + /** + * @inheritDoc */ public function formatBatch(array $records) { @@ -58,6 +72,18 @@ public function formatBatch(array $records) return $records; } + public function getDateFormat(): string + { + return $this->dateFormat; + } + + public function setDateFormat(string $dateFormat): self + { + $this->dateFormat = $dateFormat; + + return $this; + } + /** * The maximum number of normalization levels to go through */ @@ -66,9 +92,11 @@ public function getMaxNormalizeDepth(): int return $this->maxNormalizeDepth; } - public function setMaxNormalizeDepth(int $maxNormalizeDepth): void + public function setMaxNormalizeDepth(int $maxNormalizeDepth): self { $this->maxNormalizeDepth = $maxNormalizeDepth; + + return $this; } /** @@ -79,16 +107,47 @@ public function getMaxNormalizeItemCount(): int return $this->maxNormalizeItemCount; } - public function setMaxNormalizeItemCount(int $maxNormalizeItemCount): void + public function setMaxNormalizeItemCount(int $maxNormalizeItemCount): self { $this->maxNormalizeItemCount = $maxNormalizeItemCount; + + return $this; + } + + /** + * Enables `json_encode` pretty print. + */ + public function setJsonPrettyPrint(bool $enable): self + { + if ($enable) { + $this->jsonEncodeOptions |= JSON_PRETTY_PRINT; + } else { + $this->jsonEncodeOptions &= ~JSON_PRETTY_PRINT; + } + + return $this; + } + + /** + * Provided as extension point + * + * Because normalize is called with sub-values of context data etc, normalizeRecord can be + * extended when data needs to be appended on the record array but not to other normalized data. + * + * @return array + */ + protected function normalizeRecord(LogRecord $record): array + { + /** @var array $normalized */ + $normalized = $this->normalize($record->toArray()); + + return $normalized; } /** - * @param mixed $data - * @return int|bool|string|null|array + * @return null|scalar|array */ - protected function normalize($data, int $depth = 0) + protected function normalize(mixed $data, int $depth = 0): mixed { if ($depth > $this->maxNormalizeDepth) { return 'Over ' . $this->maxNormalizeDepth . ' levels deep, aborting normalization'; @@ -133,20 +192,18 @@ protected function normalize($data, int $depth = 0) } if ($data instanceof \JsonSerializable) { + /** @var null|scalar|array $value */ $value = $data->jsonSerialize(); } elseif (method_exists($data, '__toString')) { + /** @var string $value */ $value = $data->__toString(); } else { // the rest is normalized by json encoding and decoding it - $encoded = $this->toJson($data, true); - if ($encoded === false) { - $value = 'JSON_ERROR'; - } else { - $value = json_decode($encoded, true); - } + /** @var null|scalar|array $value */ + $value = json_decode($this->toJson($data, true), true); } - return [get_class($data) => $value]; + return [Utils::getClass($data) => $value]; } if (is_resource($data)) { @@ -157,14 +214,22 @@ protected function normalize($data, int $depth = 0) } /** - * @return array + * @return mixed[] */ protected function normalizeException(Throwable $e, int $depth = 0) { + if ($depth > $this->maxNormalizeDepth) { + return ['Over ' . $this->maxNormalizeDepth . ' levels deep, aborting normalization']; + } + + if ($e instanceof \JsonSerializable) { + return (array) $e->jsonSerialize(); + } + $data = [ - 'class' => get_class($e), + 'class' => Utils::getClass($e), 'message' => $e->getMessage(), - 'code' => $e->getCode(), + 'code' => (int) $e->getCode(), 'file' => $e->getFile().':'.$e->getLine(), ]; @@ -178,35 +243,22 @@ protected function normalizeException(Throwable $e, int $depth = 0) } if (isset($e->detail)) { - $data['detail'] = $e->detail; + if (is_string($e->detail)) { + $data['detail'] = $e->detail; + } elseif (is_object($e->detail) || is_array($e->detail)) { + $data['detail'] = $this->toJson($e->detail, true); + } } } $trace = $e->getTrace(); foreach ($trace as $frame) { - if (isset($frame['file'])) { + if (isset($frame['file'], $frame['line'])) { $data['trace'][] = $frame['file'].':'.$frame['line']; - } elseif (isset($frame['function']) && $frame['function'] === '{closure}') { - // Simplify closures handling - $data['trace'][] = $frame['function']; - } else { - if (isset($frame['args'])) { - // Make sure that objects present as arguments are not serialized nicely but rather only - // as a class name to avoid any unexpected leak of sensitive information - $frame['args'] = array_map(function ($arg) { - if (is_object($arg) && !$arg instanceof \DateTimeInterface) { - return sprintf("[object] (%s)", get_class($arg)); - } - - return $arg; - }, $frame['args']); - } - // We should again normalize the frames, because it might contain invalid items - $data['trace'][] = $this->toJson($this->normalize($frame, $depth + 1), true); } } - if ($previous = $e->getPrevious()) { + if (($previous = $e->getPrevious()) instanceof \Throwable) { $data['previous'] = $this->normalizeException($previous, $depth + 1); } @@ -218,140 +270,35 @@ protected function normalizeException(Throwable $e, int $depth = 0) * * @param mixed $data * @throws \RuntimeException if encoding fails and errors are not ignored - * @return string|bool + * @return string if encoding fails and ignoreErrors is true 'null' is returned */ - protected function toJson($data, bool $ignoreErrors = false) + protected function toJson($data, bool $ignoreErrors = false): string { - // suppress json_encode errors since it's twitchy with some inputs - if ($ignoreErrors) { - return @$this->jsonEncode($data); - } - - $json = $this->jsonEncode($data); - - if ($json === false) { - $json = $this->handleJsonError(json_last_error(), $data); - } - - return $json; + return Utils::jsonEncode($data, $this->jsonEncodeOptions, $ignoreErrors); } - /** - * @param mixed $data - * @return string|bool JSON encoded data or false on failure - */ - private function jsonEncode($data) - { - return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION); - } - - /** - * Handle a json_encode failure. - * - * If the failure is due to invalid string encoding, try to clean the - * input and encode again. If the second encoding attempt fails, the - * initial error is not encoding related or the input can't be cleaned then - * raise a descriptive exception. - * - * @param int $code return code of json_last_error function - * @param mixed $data data that was meant to be encoded - * @throws \RuntimeException if failure can't be corrected - * @return string JSON encoded data after error correction - */ - private function handleJsonError(int $code, $data): string + protected function formatDate(\DateTimeInterface $date): string { - if ($code !== JSON_ERROR_UTF8) { - $this->throwEncodeError($code, $data); - } - - if (is_string($data)) { - $this->detectAndCleanUtf8($data); - } elseif (is_array($data)) { - array_walk_recursive($data, [$this, 'detectAndCleanUtf8']); - } else { - $this->throwEncodeError($code, $data); - } - - $json = $this->jsonEncode($data); - - if ($json === false) { - $this->throwEncodeError(json_last_error(), $data); + // in case the date format isn't custom then we defer to the custom DateTimeImmutable + // formatting logic, which will pick the right format based on whether useMicroseconds is on + if ($this->dateFormat === self::SIMPLE_DATE && $date instanceof DateTimeImmutable) { + return (string) $date; } - return $json; + return $date->format($this->dateFormat); } - /** - * Throws an exception according to a given code with a customized message - * - * @param int $code return code of json_last_error function - * @param mixed $data data that was meant to be encoded - * @throws \RuntimeException - */ - private function throwEncodeError(int $code, $data) + public function addJsonEncodeOption(int $option): self { - switch ($code) { - case JSON_ERROR_DEPTH: - $msg = 'Maximum stack depth exceeded'; - break; - case JSON_ERROR_STATE_MISMATCH: - $msg = 'Underflow or the modes mismatch'; - break; - case JSON_ERROR_CTRL_CHAR: - $msg = 'Unexpected control character found'; - break; - case JSON_ERROR_UTF8: - $msg = 'Malformed UTF-8 characters, possibly incorrectly encoded'; - break; - default: - $msg = 'Unknown error'; - } + $this->jsonEncodeOptions |= $option; - throw new \RuntimeException('JSON encoding failed: '.$msg.'. Encoding: '.var_export($data, true)); + return $this; } - /** - * Detect invalid UTF-8 string characters and convert to valid UTF-8. - * - * Valid UTF-8 input will be left unmodified, but strings containing - * invalid UTF-8 codepoints will be reencoded as UTF-8 with an assumed - * original encoding of ISO-8859-15. This conversion may result in - * incorrect output if the actual encoding was not ISO-8859-15, but it - * will be clean UTF-8 output and will not rely on expensive and fragile - * detection algorithms. - * - * Function converts the input in place in the passed variable so that it - * can be used as a callback for array_walk_recursive. - * - * @param mixed &$data Input to check and convert if needed - * @private - */ - public function detectAndCleanUtf8(&$data) + public function removeJsonEncodeOption(int $option): self { - if (is_string($data) && !preg_match('//u', $data)) { - $data = preg_replace_callback( - '/[\x80-\xFF]+/', - function ($m) { - return utf8_encode($m[0]); - }, - $data - ); - $data = str_replace( - ['¤', '¦', '¨', '´', '¸', '¼', '½', '¾'], - ['€', 'Š', 'š', 'Ž', 'ž', 'Œ', 'œ', 'Ÿ'], - $data - ); - } - } - - protected function formatDate(\DateTimeInterface $date) - { - // in case the date format isn't custom then we defer to the custom DateTimeImmutable - // formatting logic, which will pick the right format based on whether useMicroseconds is on - if ($this->dateFormat === self::SIMPLE_DATE && $date instanceof DateTimeImmutable) { - return (string) $date; - } + $this->jsonEncodeOptions &= ~$option; - return $date->format($this->dateFormat); + return $this; } } diff --git a/src/Monolog/Formatter/ScalarFormatter.php b/src/Monolog/Formatter/ScalarFormatter.php index 8d560e77c..4bc20a08c 100644 --- a/src/Monolog/Formatter/ScalarFormatter.php +++ b/src/Monolog/Formatter/ScalarFormatter.php @@ -11,8 +11,10 @@ namespace Monolog\Formatter; +use Monolog\LogRecord; + /** - * Formats data into an associative array of scalar values. + * Formats data into an associative array of scalar (+ null) values. * Objects and arrays will be JSON encoded. * * @author Andrew Lawson @@ -20,26 +22,25 @@ class ScalarFormatter extends NormalizerFormatter { /** - * {@inheritdoc} + * @inheritDoc + * + * @phpstan-return array $record */ - public function format(array $record): array + public function format(LogRecord $record): array { - foreach ($record as $key => $value) { - $record[$key] = $this->normalizeValue($value); + $result = []; + foreach ($record->toArray() as $key => $value) { + $result[$key] = $this->toScalar($value); } - return $record; + return $result; } - /** - * @param mixed $value - * @return mixed - */ - protected function normalizeValue($value) + protected function toScalar(mixed $value): string|int|float|bool|null { $normalized = $this->normalize($value); - if (is_array($normalized) || is_object($normalized)) { + if (is_array($normalized)) { return $this->toJson($normalized, true); } diff --git a/src/Monolog/Formatter/SyslogFormatter.php b/src/Monolog/Formatter/SyslogFormatter.php new file mode 100644 index 000000000..6ed7e92ef --- /dev/null +++ b/src/Monolog/Formatter/SyslogFormatter.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Formatter; + +use Monolog\Level; +use Monolog\LogRecord; + +/** + * Serializes a log message according to RFC 5424 + * + * @author Dalibor Karlović + * @author Renat Gabdullin + */ +class SyslogFormatter extends LineFormatter +{ + private const SYSLOG_FACILITY_USER = 1; + private const FORMAT = "<%extra.priority%>1 %datetime% %extra.hostname% %extra.app-name% %extra.procid% %channel% %extra.structured-data% %level_name%: %message% %context% %extra%\n"; + private const NILVALUE = '-'; + + private string $hostname; + private int $procid; + + public function __construct(private string $applicationName = self::NILVALUE) + { + parent::__construct(self::FORMAT, 'Y-m-d\TH:i:s.uP', true, true); + $this->hostname = (string) gethostname(); + $this->procid = (int) getmypid(); + } + + public function format(LogRecord $record): string + { + $record->extra = $this->formatExtra($record); + + return parent::format($record); + } + + /** + * @param LogRecord $record + * @return array + */ + private function formatExtra(LogRecord $record): array + { + $extra = $record->extra; + $extra['app-name'] = $this->applicationName; + $extra['hostname'] = $this->hostname; + $extra['procid'] = $this->procid; + $extra['priority'] = self::calculatePriority($record->level); + $extra['structured-data'] = self::NILVALUE; + + return $extra; + } + + private static function calculatePriority(Level $level): int + { + return (self::SYSLOG_FACILITY_USER * 8) + $level->toRFC5424Level(); + } +} diff --git a/src/Monolog/Formatter/WildfireFormatter.php b/src/Monolog/Formatter/WildfireFormatter.php index c8a3bb4a7..8ef7b7d14 100644 --- a/src/Monolog/Formatter/WildfireFormatter.php +++ b/src/Monolog/Formatter/WildfireFormatter.php @@ -11,7 +11,8 @@ namespace Monolog\Formatter; -use Monolog\Logger; +use Monolog\Level; +use Monolog\LogRecord; /** * Serializes a log message according to Wildfire's header requirements @@ -22,60 +23,73 @@ */ class WildfireFormatter extends NormalizerFormatter { - const TABLE = 'table'; + /** + * @param string|null $dateFormat The format of the timestamp: one supported by DateTime::format + */ + public function __construct(?string $dateFormat = null) + { + parent::__construct($dateFormat); + + // http headers do not like non-ISO-8559-1 characters + $this->removeJsonEncodeOption(JSON_UNESCAPED_UNICODE); + } /** * Translates Monolog log levels to Wildfire levels. + * + * @return 'LOG'|'INFO'|'WARN'|'ERROR' */ - private $logLevels = [ - Logger::DEBUG => 'LOG', - Logger::INFO => 'INFO', - Logger::NOTICE => 'INFO', - Logger::WARNING => 'WARN', - Logger::ERROR => 'ERROR', - Logger::CRITICAL => 'ERROR', - Logger::ALERT => 'ERROR', - Logger::EMERGENCY => 'ERROR', - ]; + private function toWildfireLevel(Level $level): string + { + return match ($level) { + Level::Debug => 'LOG', + Level::Info => 'INFO', + Level::Notice => 'INFO', + Level::Warning => 'WARN', + Level::Error => 'ERROR', + Level::Critical => 'ERROR', + Level::Alert => 'ERROR', + Level::Emergency => 'ERROR', + }; + } /** - * {@inheritdoc} + * @inheritDoc */ - public function format(array $record): string + public function format(LogRecord $record): string { // Retrieve the line and file if set and remove them from the formatted extra $file = $line = ''; - if (isset($record['extra']['file'])) { - $file = $record['extra']['file']; - unset($record['extra']['file']); + if (isset($record->extra['file'])) { + $file = $record->extra['file']; + unset($record->extra['file']); } - if (isset($record['extra']['line'])) { - $line = $record['extra']['line']; - unset($record['extra']['line']); + if (isset($record->extra['line'])) { + $line = $record->extra['line']; + unset($record->extra['line']); } - $record = $this->normalize($record); - $message = ['message' => $record['message']]; + $message = ['message' => $record->message]; $handleError = false; - if ($record['context']) { - $message['context'] = $record['context']; + if (count($record->context) > 0) { + $message['context'] = $this->normalize($record->context); $handleError = true; } - if ($record['extra']) { - $message['extra'] = $record['extra']; + if (count($record->extra) > 0) { + $message['extra'] = $this->normalize($record->extra); $handleError = true; } if (count($message) === 1) { $message = reset($message); } - if (isset($record['context'][self::TABLE])) { + if (is_array($message) && isset($message['context']['table'])) { $type = 'TABLE'; - $label = $record['channel'] .': '. $record['message']; - $message = $record['context'][self::TABLE]; + $label = $record->channel .': '. $record->message; + $message = $message['context']['table']; } else { - $type = $this->logLevels[$record['level']]; - $label = $record['channel']; + $type = $this->toWildfireLevel($record->level); + $label = $record->channel; } // Create JSON object describing the appearance of the message in the console @@ -91,14 +105,16 @@ public function format(array $record): string // The message itself is a serialization of the above JSON object + it's length return sprintf( - '%s|%s|', + '%d|%s|', strlen($json), $json ); } /** - * {@inheritdoc} + * @inheritDoc + * + * @phpstan-return never */ public function formatBatch(array $records) { @@ -106,9 +122,11 @@ public function formatBatch(array $records) } /** - * {@inheritdoc} + * @inheritDoc + * + * @return null|scalar|array|object */ - protected function normalize($data, int $depth = 0) + protected function normalize(mixed $data, int $depth = 0): mixed { if (is_object($data) && !$data instanceof \DateTimeInterface) { return $data; diff --git a/src/Monolog/Handler/AbstractHandler.php b/src/Monolog/Handler/AbstractHandler.php index d47710842..3399a54e2 100644 --- a/src/Monolog/Handler/AbstractHandler.php +++ b/src/Monolog/Handler/AbstractHandler.php @@ -11,43 +11,50 @@ namespace Monolog\Handler; +use Monolog\Level; use Monolog\Logger; +use Monolog\ResettableInterface; +use Psr\Log\LogLevel; +use Monolog\LogRecord; /** * Base Handler class providing basic level/bubble support * * @author Jordi Boggiano */ -abstract class AbstractHandler extends Handler +abstract class AbstractHandler extends Handler implements ResettableInterface { - protected $level = Logger::DEBUG; - protected $bubble = true; + protected Level $level = Level::Debug; + protected bool $bubble = true; /** - * @param int|string $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not + * @param int|string|Level|LogLevel::* $level The minimum logging level at which this handler will be triggered + * @param bool $bubble Whether the messages that are handled can bubble up the stack or not + * + * @phpstan-param value-of|value-of|Level|LogLevel::* $level */ - public function __construct($level = Logger::DEBUG, $bubble = true) + public function __construct(int|string|Level $level = Level::Debug, bool $bubble = true) { $this->setLevel($level); $this->bubble = $bubble; } /** - * {@inheritdoc} + * @inheritDoc */ - public function isHandling(array $record): bool + public function isHandling(LogRecord $record): bool { - return $record['level'] >= $this->level; + return $record->level->value >= $this->level->value; } /** * Sets minimum logging level at which this handler will be triggered. * - * @param int|string $level Level or level name - * @return self + * @param Level|LogLevel::* $level Level or level name + * + * @phpstan-param value-of|value-of|Level|LogLevel::* $level */ - public function setLevel($level): self + public function setLevel(int|string|Level $level): self { $this->level = Logger::toMonologLevel($level); @@ -56,10 +63,8 @@ public function setLevel($level): self /** * Gets minimum logging level at which this handler will be triggered. - * - * @return int */ - public function getLevel(): int + public function getLevel(): Level { return $this->level; } @@ -67,9 +72,8 @@ public function getLevel(): int /** * Sets the bubbling behavior. * - * @param bool $bubble true means that this handler allows bubbling. - * false means that bubbling is not permitted. - * @return self + * @param bool $bubble true means that this handler allows bubbling. + * false means that bubbling is not permitted. */ public function setBubble(bool $bubble): self { @@ -88,4 +92,11 @@ public function getBubble(): bool { return $this->bubble; } + + /** + * @inheritDoc + */ + public function reset(): void + { + } } diff --git a/src/Monolog/Handler/AbstractProcessingHandler.php b/src/Monolog/Handler/AbstractProcessingHandler.php index 654e67181..de13a76be 100644 --- a/src/Monolog/Handler/AbstractProcessingHandler.php +++ b/src/Monolog/Handler/AbstractProcessingHandler.php @@ -11,8 +11,10 @@ namespace Monolog\Handler; +use Monolog\LogRecord; + /** - * Base Handler class providing the Handler structure + * Base Handler class providing the Handler structure, including processors and formatters * * Classes extending it should (in most cases) only implement write($record) * @@ -25,19 +27,19 @@ abstract class AbstractProcessingHandler extends AbstractHandler implements Proc use FormattableHandlerTrait; /** - * {@inheritdoc} + * @inheritDoc */ - public function handle(array $record): bool + public function handle(LogRecord $record): bool { if (!$this->isHandling($record)) { return false; } - if ($this->processors) { + if (\count($this->processors) > 0) { $record = $this->processRecord($record); } - $record['formatted'] = $this->getFormatter()->format($record); + $record->formatted = $this->getFormatter()->format($record); $this->write($record); @@ -45,10 +47,14 @@ public function handle(array $record): bool } /** - * Writes the record down to the log of the implementing handler - * - * @param array $record - * @return void + * Writes the (already formatted) record down to the log of the implementing handler */ - abstract protected function write(array $record); + abstract protected function write(LogRecord $record): void; + + public function reset(): void + { + parent::reset(); + + $this->resetProcessors(); + } } diff --git a/src/Monolog/Handler/AbstractSyslogHandler.php b/src/Monolog/Handler/AbstractSyslogHandler.php index 0c692aa98..695a1c07f 100644 --- a/src/Monolog/Handler/AbstractSyslogHandler.php +++ b/src/Monolog/Handler/AbstractSyslogHandler.php @@ -11,7 +11,7 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; use Monolog\Formatter\FormatterInterface; use Monolog\Formatter\LineFormatter; @@ -20,57 +20,50 @@ */ abstract class AbstractSyslogHandler extends AbstractProcessingHandler { - protected $facility; + protected int $facility; /** - * Translates Monolog log levels to syslog log priorities. + * List of valid log facility names. + * @var array */ - protected $logLevels = [ - Logger::DEBUG => LOG_DEBUG, - Logger::INFO => LOG_INFO, - Logger::NOTICE => LOG_NOTICE, - Logger::WARNING => LOG_WARNING, - Logger::ERROR => LOG_ERR, - Logger::CRITICAL => LOG_CRIT, - Logger::ALERT => LOG_ALERT, - Logger::EMERGENCY => LOG_EMERG, + protected array $facilities = [ + 'auth' => \LOG_AUTH, + 'authpriv' => \LOG_AUTHPRIV, + 'cron' => \LOG_CRON, + 'daemon' => \LOG_DAEMON, + 'kern' => \LOG_KERN, + 'lpr' => \LOG_LPR, + 'mail' => \LOG_MAIL, + 'news' => \LOG_NEWS, + 'syslog' => \LOG_SYSLOG, + 'user' => \LOG_USER, + 'uucp' => \LOG_UUCP, ]; /** - * List of valid log facility names. + * Translates Monolog log levels to syslog log priorities. */ - protected $facilities = [ - 'auth' => LOG_AUTH, - 'authpriv' => LOG_AUTHPRIV, - 'cron' => LOG_CRON, - 'daemon' => LOG_DAEMON, - 'kern' => LOG_KERN, - 'lpr' => LOG_LPR, - 'mail' => LOG_MAIL, - 'news' => LOG_NEWS, - 'syslog' => LOG_SYSLOG, - 'user' => LOG_USER, - 'uucp' => LOG_UUCP, - ]; + protected function toSyslogPriority(Level $level): int + { + return $level->toRFC5424Level(); + } /** - * @param mixed $facility - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not + * @param string|int $facility Either one of the names of the keys in $this->facilities, or a LOG_* facility constant */ - public function __construct($facility = LOG_USER, $level = Logger::DEBUG, $bubble = true) + public function __construct(string|int $facility = \LOG_USER, int|string|Level $level = Level::Debug, bool $bubble = true) { parent::__construct($level, $bubble); if (!defined('PHP_WINDOWS_VERSION_BUILD')) { - $this->facilities['local0'] = LOG_LOCAL0; - $this->facilities['local1'] = LOG_LOCAL1; - $this->facilities['local2'] = LOG_LOCAL2; - $this->facilities['local3'] = LOG_LOCAL3; - $this->facilities['local4'] = LOG_LOCAL4; - $this->facilities['local5'] = LOG_LOCAL5; - $this->facilities['local6'] = LOG_LOCAL6; - $this->facilities['local7'] = LOG_LOCAL7; + $this->facilities['local0'] = \LOG_LOCAL0; + $this->facilities['local1'] = \LOG_LOCAL1; + $this->facilities['local2'] = \LOG_LOCAL2; + $this->facilities['local3'] = \LOG_LOCAL3; + $this->facilities['local4'] = \LOG_LOCAL4; + $this->facilities['local5'] = \LOG_LOCAL5; + $this->facilities['local6'] = \LOG_LOCAL6; + $this->facilities['local7'] = \LOG_LOCAL7; } else { $this->facilities['local0'] = 128; // LOG_LOCAL0 $this->facilities['local1'] = 136; // LOG_LOCAL1 @@ -93,7 +86,7 @@ public function __construct($facility = LOG_USER, $level = Logger::DEBUG, $bubbl } /** - * {@inheritdoc} + * @inheritDoc */ protected function getDefaultFormatter(): FormatterInterface { diff --git a/src/Monolog/Handler/AmqpHandler.php b/src/Monolog/Handler/AmqpHandler.php index 6e39a113d..b25e4f139 100644 --- a/src/Monolog/Handler/AmqpHandler.php +++ b/src/Monolog/Handler/AmqpHandler.php @@ -11,38 +11,32 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; use Monolog\Formatter\FormatterInterface; use Monolog\Formatter\JsonFormatter; use PhpAmqpLib\Message\AMQPMessage; use PhpAmqpLib\Channel\AMQPChannel; use AMQPExchange; +use Monolog\LogRecord; class AmqpHandler extends AbstractProcessingHandler { - /** - * @var AMQPExchange|AMQPChannel $exchange - */ - protected $exchange; + protected AMQPExchange|AMQPChannel $exchange; - /** - * @var string - */ - protected $exchangeName; + /** @var array */ + private array $extraAttributes = []; + + protected string $exchangeName; /** * @param AMQPExchange|AMQPChannel $exchange AMQPExchange (php AMQP ext) or PHP AMQP lib channel, ready for use - * @param string $exchangeName Optional exchange name, for AMQPChannel (PhpAmqpLib) only - * @param int $level - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not + * @param string|null $exchangeName Optional exchange name, for AMQPChannel (PhpAmqpLib) only */ - public function __construct($exchange, $exchangeName = null, $level = Logger::DEBUG, $bubble = true) + public function __construct(AMQPExchange|AMQPChannel $exchange, ?string $exchangeName = null, int|string|Level $level = Level::Debug, bool $bubble = true) { if ($exchange instanceof AMQPChannel) { - $this->exchangeName = $exchangeName; - } elseif (!$exchange instanceof AMQPExchange) { - throw new \InvalidArgumentException('PhpAmqpLib\Channel\AMQPChannel or AMQPExchange instance required'); - } elseif ($exchangeName) { + $this->exchangeName = (string) $exchangeName; + } elseif ($exchangeName !== null) { @trigger_error('The $exchangeName parameter can only be passed when using PhpAmqpLib, if using an AMQPExchange instance configure it beforehand', E_USER_DEPRECATED); } $this->exchange = $exchange; @@ -51,22 +45,49 @@ public function __construct($exchange, $exchangeName = null, $level = Logger::DE } /** - * {@inheritDoc} + * @return array + */ + public function getExtraAttributes(): array + { + return $this->extraAttributes; + } + + /** + * Configure extra attributes to pass to the AMQPExchange (if you are using the amqp extension) + * + * @param array $extraAttributes One of content_type, content_encoding, + * message_id, user_id, app_id, delivery_mode, + * priority, timestamp, expiration, type + * or reply_to, headers. + * @return $this */ - protected function write(array $record) + public function setExtraAttributes(array $extraAttributes): self { - $data = $record["formatted"]; + $this->extraAttributes = $extraAttributes; + return $this; + } + + /** + * @inheritDoc + */ + protected function write(LogRecord $record): void + { + $data = $record->formatted; $routingKey = $this->getRoutingKey($record); if ($this->exchange instanceof AMQPExchange) { + $attributes = [ + 'delivery_mode' => 2, + 'content_type' => 'application/json', + ]; + if (\count($this->extraAttributes) > 0) { + $attributes = array_merge($attributes, $this->extraAttributes); + } $this->exchange->publish( $data, $routingKey, 0, - [ - 'delivery_mode' => 2, - 'content_type' => 'application/json', - ] + $attributes ); } else { $this->exchange->basic_publish( @@ -78,9 +99,9 @@ protected function write(array $record) } /** - * {@inheritDoc} + * @inheritDoc */ - public function handleBatch(array $records) + public function handleBatch(array $records): void { if ($this->exchange instanceof AMQPExchange) { parent::handleBatch($records); @@ -108,25 +129,18 @@ public function handleBatch(array $records) /** * Gets the routing key for the AMQP exchange - * - * @param array $record - * @return string */ - protected function getRoutingKey(array $record) + protected function getRoutingKey(LogRecord $record): string { - $routingKey = sprintf('%s.%s', $record['level_name'], $record['channel']); + $routingKey = sprintf('%s.%s', $record->level->name, $record->channel); return strtolower($routingKey); } - /** - * @param string $data - * @return AMQPMessage - */ - private function createAmqpMessage($data) + private function createAmqpMessage(string $data): AMQPMessage { return new AMQPMessage( - (string) $data, + $data, [ 'delivery_mode' => 2, 'content_type' => 'application/json', @@ -135,7 +149,7 @@ private function createAmqpMessage($data) } /** - * {@inheritDoc} + * @inheritDoc */ protected function getDefaultFormatter(): FormatterInterface { diff --git a/src/Monolog/Handler/BrowserConsoleHandler.php b/src/Monolog/Handler/BrowserConsoleHandler.php old mode 100755 new mode 100644 index 7774adefa..654bf9a8c --- a/src/Monolog/Handler/BrowserConsoleHandler.php +++ b/src/Monolog/Handler/BrowserConsoleHandler.php @@ -11,8 +11,15 @@ namespace Monolog\Handler; -use Monolog\Formatter\LineFormatter; use Monolog\Formatter\FormatterInterface; +use Monolog\Formatter\LineFormatter; +use Monolog\Utils; +use Monolog\LogRecord; +use Monolog\Level; + +use function count; +use function headers_list; +use function stripos; /** * Handler sending logs to browser's javascript console with no browser extension required @@ -21,11 +28,17 @@ */ class BrowserConsoleHandler extends AbstractProcessingHandler { - protected static $initialized = false; - protected static $records = []; + protected static bool $initialized = false; + + /** @var LogRecord[] */ + protected static array $records = []; + + protected const FORMAT_HTML = 'html'; + protected const FORMAT_JS = 'js'; + protected const FORMAT_UNKNOWN = 'unknown'; /** - * {@inheritDoc} + * @inheritDoc * * Formatted output may contain some formatting markers to be transferred to `console.log` using the %c format. * @@ -39,9 +52,9 @@ protected function getDefaultFormatter(): FormatterInterface } /** - * {@inheritDoc} + * @inheritDoc */ - protected function write(array $record) + protected function write(LogRecord $record): void { // Accumulate records static::$records[] = $record; @@ -57,27 +70,39 @@ protected function write(array $record) * Convert records to javascript console commands and send it to the browser. * This method is automatically called on PHP shutdown if output is HTML or Javascript. */ - public static function send() + public static function send(): void { $format = static::getResponseFormat(); - if ($format === 'unknown') { + if ($format === self::FORMAT_UNKNOWN) { return; } - if (count(static::$records)) { - if ($format === 'html') { - static::writeOutput(''); - } elseif ($format === 'js') { - static::writeOutput(static::generateScript()); + if (count(static::$records) > 0) { + if ($format === self::FORMAT_HTML) { + static::writeOutput(''); + } else { // js format + static::writeOutput(self::generateScript()); } - static::reset(); + static::resetStatic(); } } + public function close(): void + { + self::resetStatic(); + } + + public function reset(): void + { + parent::reset(); + + self::resetStatic(); + } + /** * Forget all logged records */ - public static function reset() + public static function resetStatic(): void { static::$records = []; } @@ -85,7 +110,7 @@ public static function reset() /** * Wrapper for register_shutdown_function to allow overriding */ - protected function registerShutdownFunction() + protected function registerShutdownFunction(): void { if (PHP_SAPI !== 'cli') { register_shutdown_function(['Monolog\Handler\BrowserConsoleHandler', 'send']); @@ -94,10 +119,8 @@ protected function registerShutdownFunction() /** * Wrapper for echo to allow overriding - * - * @param string $str */ - protected static function writeOutput($str) + protected static function writeOutput(string $str): void { echo $str; } @@ -110,43 +133,55 @@ protected static function writeOutput($str) * If Content-Type is anything else -> unknown * * @return string One of 'js', 'html' or 'unknown' + * @phpstan-return self::FORMAT_* */ - protected static function getResponseFormat() + protected static function getResponseFormat(): string { // Check content type foreach (headers_list() as $header) { if (stripos($header, 'content-type:') === 0) { - // This handler only works with HTML and javascript outputs - // text/javascript is obsolete in favour of application/javascript, but still used - if (stripos($header, 'application/javascript') !== false || stripos($header, 'text/javascript') !== false) { - return 'js'; - } - if (stripos($header, 'text/html') === false) { - return 'unknown'; - } - break; + return static::getResponseFormatFromContentType($header); } } - return 'html'; + return self::FORMAT_HTML; } - private static function generateScript() + /** + * @return string One of 'js', 'html' or 'unknown' + * @phpstan-return self::FORMAT_* + */ + protected static function getResponseFormatFromContentType(string $contentType): string + { + // This handler only works with HTML and javascript outputs + // text/javascript is obsolete in favour of application/javascript, but still used + if (stripos($contentType, 'application/javascript') !== false || stripos($contentType, 'text/javascript') !== false) { + return self::FORMAT_JS; + } + + if (stripos($contentType, 'text/html') !== false) { + return self::FORMAT_HTML; + } + + return self::FORMAT_UNKNOWN; + } + + private static function generateScript(): string { $script = []; foreach (static::$records as $record) { - $context = static::dump('Context', $record['context']); - $extra = static::dump('Extra', $record['extra']); + $context = self::dump('Context', $record->context); + $extra = self::dump('Extra', $record->extra); - if (empty($context) && empty($extra)) { - $script[] = static::call_array('log', static::handleStyles($record['formatted'])); + if (\count($context) === 0 && \count($extra) === 0) { + $script[] = self::call_array(self::getConsoleMethodForLevel($record->level), self::handleStyles($record->formatted)); } else { $script = array_merge( $script, - [static::call_array('groupCollapsed', static::handleStyles($record['formatted']))], + [self::call_array('groupCollapsed', self::handleStyles($record->formatted))], $context, $extra, - [static::call('groupEnd')] + [self::call('groupEnd')] ); } } @@ -154,31 +189,45 @@ private static function generateScript() return "(function (c) {if (c && c.groupCollapsed) {\n" . implode("\n", $script) . "\n}})(console);"; } - private static function handleStyles($formatted) + private static function getConsoleMethodForLevel(Level $level): string + { + return match ($level) { + Level::Debug => 'debug', + Level::Info, Level::Notice => 'info', + Level::Warning => 'warn', + Level::Error, Level::Critical, Level::Alert, Level::Emergency => 'error', + }; + } + + /** + * @return string[] + */ + private static function handleStyles(string $formatted): array { - $args = [static::quote('font-weight: normal')]; + $args = []; $format = '%c' . $formatted; preg_match_all('/\[\[(.*?)\]\]\{([^}]*)\}/s', $format, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER); foreach (array_reverse($matches) as $match) { - $args[] = static::quote(static::handleCustomStyles($match[2][0], $match[1][0])); $args[] = '"font-weight: normal"'; + $args[] = self::quote(self::handleCustomStyles($match[2][0], $match[1][0])); $pos = $match[0][1]; - $format = substr($format, 0, $pos) . '%c' . $match[1][0] . '%c' . substr($format, $pos + strlen($match[0][0])); + $format = Utils::substr($format, 0, $pos) . '%c' . $match[1][0] . '%c' . Utils::substr($format, $pos + strlen($match[0][0])); } - array_unshift($args, static::quote($format)); + $args[] = self::quote('font-weight: normal'); + $args[] = self::quote($format); - return $args; + return array_reverse($args); } - private static function handleCustomStyles($style, $string) + private static function handleCustomStyles(string $style, string $string): string { static $colors = ['blue', 'green', 'red', 'magenta', 'orange', 'black', 'grey']; static $labels = []; - return preg_replace_callback('/macro\s*:(.*?)(?:;|$)/', function ($m) use ($string, &$colors, &$labels) { + $style = preg_replace_callback('/macro\s*:(.*?)(?:;|$)/', function (array $m) use ($string, &$colors, &$labels) { if (trim($m[1]) === 'autolabel') { // Format the string as a label with consistent auto assigned background color if (!isset($labels[$string])) { @@ -191,41 +240,61 @@ private static function handleCustomStyles($style, $string) return $m[1]; }, $style); + + if (null === $style) { + $pcreErrorCode = preg_last_error(); + + throw new \RuntimeException('Failed to run preg_replace_callback: ' . $pcreErrorCode . ' / ' . Utils::pcreLastErrorMessage($pcreErrorCode)); + } + + return $style; } - private static function dump($title, array $dict) + /** + * @param mixed[] $dict + * @return mixed[] + */ + private static function dump(string $title, array $dict): array { $script = []; $dict = array_filter($dict); - if (empty($dict)) { + if (\count($dict) === 0) { return $script; } - $script[] = static::call('log', static::quote('%c%s'), static::quote('font-weight: bold'), static::quote($title)); + $script[] = self::call('log', self::quote('%c%s'), self::quote('font-weight: bold'), self::quote($title)); foreach ($dict as $key => $value) { $value = json_encode($value); if (empty($value)) { - $value = static::quote(''); + $value = self::quote(''); } - $script[] = static::call('log', static::quote('%s: %o'), static::quote($key), $value); + $script[] = self::call('log', self::quote('%s: %o'), self::quote((string) $key), $value); } return $script; } - private static function quote($arg) + private static function quote(string $arg): string { return '"' . addcslashes($arg, "\"\n\\") . '"'; } - private static function call() + /** + * @param mixed $args + */ + private static function call(...$args): string { - $args = func_get_args(); $method = array_shift($args); + if (!is_string($method)) { + throw new \UnexpectedValueException('Expected the first arg to be a string, got: '.var_export($method, true)); + } - return static::call_array($method, $args); + return self::call_array($method, $args); } - private static function call_array($method, array $args) + /** + * @param mixed[] $args + */ + private static function call_array(string $method, array $args): string { return 'c.' . $method . '(' . implode(', ', $args) . ');'; } diff --git a/src/Monolog/Handler/BufferHandler.php b/src/Monolog/Handler/BufferHandler.php index 676c7c142..ff89faa8a 100644 --- a/src/Monolog/Handler/BufferHandler.php +++ b/src/Monolog/Handler/BufferHandler.php @@ -11,7 +11,10 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; +use Monolog\ResettableInterface; +use Monolog\Formatter\FormatterInterface; +use Monolog\LogRecord; /** * Buffers all records until closing the handler and then pass them as batch. @@ -21,38 +24,42 @@ * * @author Christophe Coevoet */ -class BufferHandler extends AbstractHandler implements ProcessableHandlerInterface +class BufferHandler extends AbstractHandler implements ProcessableHandlerInterface, FormattableHandlerInterface { use ProcessableHandlerTrait; - protected $handler; - protected $bufferSize = 0; - protected $bufferLimit; - protected $flushOnOverflow; - protected $buffer = []; - protected $initialized = false; + protected HandlerInterface $handler; + + protected int $bufferSize = 0; + + protected int $bufferLimit; + + protected bool $flushOnOverflow; + + /** @var LogRecord[] */ + protected array $buffer = []; + + protected bool $initialized = false; /** * @param HandlerInterface $handler Handler. * @param int $bufferLimit How many entries should be buffered at most, beyond that the oldest items are removed from the buffer. - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not * @param bool $flushOnOverflow If true, the buffer is flushed when the max size has been reached, by default oldest entries are discarded */ - public function __construct(HandlerInterface $handler, $bufferLimit = 0, $level = Logger::DEBUG, $bubble = true, $flushOnOverflow = false) + public function __construct(HandlerInterface $handler, int $bufferLimit = 0, int|string|Level $level = Level::Debug, bool $bubble = true, bool $flushOnOverflow = false) { parent::__construct($level, $bubble); $this->handler = $handler; - $this->bufferLimit = (int) $bufferLimit; + $this->bufferLimit = $bufferLimit; $this->flushOnOverflow = $flushOnOverflow; } /** - * {@inheritdoc} + * @inheritDoc */ - public function handle(array $record): bool + public function handle(LogRecord $record): bool { - if ($record['level'] < $this->level) { + if ($record->level->isLowerThan($this->level)) { return false; } @@ -71,7 +78,7 @@ public function handle(array $record): bool } } - if ($this->processors) { + if (\count($this->processors) > 0) { $record = $this->processRecord($record); } @@ -81,7 +88,7 @@ public function handle(array $record): bool return false === $this->bubble; } - public function flush() + public function flush(): void { if ($this->bufferSize === 0) { return; @@ -99,19 +106,60 @@ public function __destruct() } /** - * {@inheritdoc} + * @inheritDoc */ - public function close() + public function close(): void { $this->flush(); + + $this->handler->close(); } /** * Clears the buffer without flushing any messages down to the wrapped handler. */ - public function clear() + public function clear(): void { $this->bufferSize = 0; $this->buffer = []; } + + public function reset(): void + { + $this->flush(); + + parent::reset(); + + $this->resetProcessors(); + + if ($this->handler instanceof ResettableInterface) { + $this->handler->reset(); + } + } + + /** + * @inheritDoc + */ + public function setFormatter(FormatterInterface $formatter): HandlerInterface + { + if ($this->handler instanceof FormattableHandlerInterface) { + $this->handler->setFormatter($formatter); + + return $this; + } + + throw new \UnexpectedValueException('The nested handler of type '.get_class($this->handler).' does not support formatters.'); + } + + /** + * @inheritDoc + */ + public function getFormatter(): FormatterInterface + { + if ($this->handler instanceof FormattableHandlerInterface) { + return $this->handler->getFormatter(); + } + + throw new \UnexpectedValueException('The nested handler of type '.get_class($this->handler).' does not support formatters.'); + } } diff --git a/src/Monolog/Handler/ChromePHPHandler.php b/src/Monolog/Handler/ChromePHPHandler.php index 9c1ca5235..3742d47d9 100644 --- a/src/Monolog/Handler/ChromePHPHandler.php +++ b/src/Monolog/Handler/ChromePHPHandler.php @@ -13,7 +13,10 @@ use Monolog\Formatter\ChromePHPFormatter; use Monolog\Formatter\FormatterInterface; -use Monolog\Logger; +use Monolog\Level; +use Monolog\Utils; +use Monolog\LogRecord; +use Monolog\DateTimeImmutable; /** * Handler sending logs to the ChromePHP extension (http://www.chromephp.com/) @@ -29,42 +32,37 @@ class ChromePHPHandler extends AbstractProcessingHandler /** * Version of the extension */ - const VERSION = '4.0'; + protected const VERSION = '4.0'; /** * Header name */ - const HEADER_NAME = 'X-ChromeLogger-Data'; + protected const HEADER_NAME = 'X-ChromeLogger-Data'; /** * Regular expression to detect supported browsers (matches any Chrome, or Firefox 43+) */ - const USER_AGENT_REGEX = '{\b(?:Chrome/\d+(?:\.\d+)*|HeadlessChrome|Firefox/(?:4[3-9]|[5-9]\d|\d{3,})(?:\.\d)*)\b}'; + protected const USER_AGENT_REGEX = '{\b(?:Chrome/\d+(?:\.\d+)*|HeadlessChrome|Firefox/(?:4[3-9]|[5-9]\d|\d{3,})(?:\.\d)*)\b}'; - protected static $initialized = false; + protected static bool $initialized = false; /** * Tracks whether we sent too much data * - * Chrome limits the headers to 256KB, so when we sent 240KB we stop sending - * - * @var bool + * Chrome limits the headers to 4KB, so when we sent 3KB we stop sending */ - protected static $overflowed = false; + protected static bool $overflowed = false; - protected static $json = [ + /** @var mixed[] */ + protected static array $json = [ 'version' => self::VERSION, 'columns' => ['label', 'log', 'backtrace', 'type'], 'rows' => [], ]; - protected static $sendHeaders = true; + protected static bool $sendHeaders = true; - /** - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not - */ - public function __construct($level = Logger::DEBUG, $bubble = true) + public function __construct(int|string|Level $level = Level::Debug, bool $bubble = true) { parent::__construct($level, $bubble); if (!function_exists('json_encode')) { @@ -73,9 +71,9 @@ public function __construct($level = Logger::DEBUG, $bubble = true) } /** - * {@inheritdoc} + * @inheritDoc */ - public function handleBatch(array $records) + public function handleBatch(array $records): void { if (!$this->isWebRequest()) { return; @@ -84,13 +82,15 @@ public function handleBatch(array $records) $messages = []; foreach ($records as $record) { - if ($record['level'] < $this->level) { + if ($record->level < $this->level) { continue; } - $messages[] = $this->processRecord($record); + + $message = $this->processRecord($record); + $messages[] = $message; } - if (!empty($messages)) { + if (\count($messages) > 0) { $messages = $this->getFormatter()->formatBatch($messages); self::$json['rows'] = array_merge(self::$json['rows'], $messages); $this->send(); @@ -98,7 +98,7 @@ public function handleBatch(array $records) } /** - * {@inheritDoc} + * @inheritDoc */ protected function getDefaultFormatter(): FormatterInterface { @@ -110,15 +110,14 @@ protected function getDefaultFormatter(): FormatterInterface * * @see sendHeader() * @see send() - * @param array $record */ - protected function write(array $record) + protected function write(LogRecord $record): void { if (!$this->isWebRequest()) { return; } - self::$json['rows'][] = $record['formatted']; + self::$json['rows'][] = $record->formatted; $this->send(); } @@ -128,7 +127,7 @@ protected function write(array $record) * * @see sendHeader() */ - protected function send() + protected function send(): void { if (self::$overflowed || !self::$sendHeaders) { return; @@ -145,37 +144,31 @@ protected function send() self::$json['request_uri'] = $_SERVER['REQUEST_URI'] ?? ''; } - $json = @json_encode(self::$json); - $data = base64_encode(utf8_encode($json)); - if (strlen($data) > 240 * 1024) { + $json = Utils::jsonEncode(self::$json, Utils::DEFAULT_JSON_FLAGS & ~JSON_UNESCAPED_UNICODE, true); + $data = base64_encode($json); + if (strlen($data) > 3 * 1024) { self::$overflowed = true; - $record = [ - 'message' => 'Incomplete logs, chrome header size limit reached', - 'context' => [], - 'level' => Logger::WARNING, - 'level_name' => Logger::getLevelName(Logger::WARNING), - 'channel' => 'monolog', - 'datetime' => new \DateTimeImmutable(), - 'extra' => [], - ]; + $record = new LogRecord( + message: 'Incomplete logs, chrome header size limit reached', + level: Level::Warning, + channel: 'monolog', + datetime: new DateTimeImmutable(true), + ); self::$json['rows'][count(self::$json['rows']) - 1] = $this->getFormatter()->format($record); - $json = @json_encode(self::$json); - $data = base64_encode(utf8_encode($json)); + $json = Utils::jsonEncode(self::$json, Utils::DEFAULT_JSON_FLAGS & ~JSON_UNESCAPED_UNICODE, true); + $data = base64_encode($json); } if (trim($data) !== '') { - $this->sendHeader(self::HEADER_NAME, $data); + $this->sendHeader(static::HEADER_NAME, $data); } } /** * Send header string to the client - * - * @param string $header - * @param string $content */ - protected function sendHeader($header, $content) + protected function sendHeader(string $header, string $content): void { if (!headers_sent() && self::$sendHeaders) { header(sprintf('%s: %s', $header, $content)); @@ -187,10 +180,10 @@ protected function sendHeader($header, $content) */ protected function headersAccepted(): bool { - if (empty($_SERVER['HTTP_USER_AGENT'])) { + if (!isset($_SERVER['HTTP_USER_AGENT'])) { return false; } - return preg_match(self::USER_AGENT_REGEX, $_SERVER['HTTP_USER_AGENT']) === 1; + return preg_match(static::USER_AGENT_REGEX, $_SERVER['HTTP_USER_AGENT']) === 1; } } diff --git a/src/Monolog/Handler/CouchDBHandler.php b/src/Monolog/Handler/CouchDBHandler.php index e0603f343..8d9c10e76 100644 --- a/src/Monolog/Handler/CouchDBHandler.php +++ b/src/Monolog/Handler/CouchDBHandler.php @@ -13,18 +13,42 @@ use Monolog\Formatter\FormatterInterface; use Monolog\Formatter\JsonFormatter; -use Monolog\Logger; +use Monolog\Level; +use Monolog\LogRecord; /** * CouchDB handler * * @author Markus Bachmann + * @phpstan-type Options array{ + * host: string, + * port: int, + * dbname: string, + * username: string|null, + * password: string|null + * } + * @phpstan-type InputOptions array{ + * host?: string, + * port?: int, + * dbname?: string, + * username?: string|null, + * password?: string|null + * } */ class CouchDBHandler extends AbstractProcessingHandler { - private $options; + /** + * @var mixed[] + * @phpstan-var Options + */ + private array $options; - public function __construct(array $options = [], $level = Logger::DEBUG, $bubble = true) + /** + * @param mixed[] $options + * + * @phpstan-param InputOptions $options + */ + public function __construct(array $options = [], int|string|Level $level = Level::Debug, bool $bubble = true) { $this->options = array_merge([ 'host' => 'localhost', @@ -38,12 +62,12 @@ public function __construct(array $options = [], $level = Logger::DEBUG, $bubble } /** - * {@inheritDoc} + * @inheritDoc */ - protected function write(array $record) + protected function write(LogRecord $record): void { $basicAuth = null; - if ($this->options['username']) { + if (null !== $this->options['username'] && null !== $this->options['password']) { $basicAuth = sprintf('%s:%s@', $this->options['username'], $this->options['password']); } @@ -51,7 +75,7 @@ protected function write(array $record) $context = stream_context_create([ 'http' => [ 'method' => 'POST', - 'content' => $record['formatted'], + 'content' => $record->formatted, 'ignore_errors' => true, 'max_redirects' => 0, 'header' => 'Content-type: application/json', @@ -64,7 +88,7 @@ protected function write(array $record) } /** - * {@inheritDoc} + * @inheritDoc */ protected function getDefaultFormatter(): FormatterInterface { diff --git a/src/Monolog/Handler/CubeHandler.php b/src/Monolog/Handler/CubeHandler.php index 82b169210..8388f5ade 100644 --- a/src/Monolog/Handler/CubeHandler.php +++ b/src/Monolog/Handler/CubeHandler.php @@ -11,22 +11,26 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; +use Monolog\Utils; +use Monolog\LogRecord; /** * Logs to Cube. * - * @link http://square.github.com/cube/ + * @link https://github.com/square/cube/wiki * @author Wan Chen + * @deprecated Since 2.8.0 and 3.2.0, Cube appears abandoned and thus we will drop this handler in Monolog 4 */ class CubeHandler extends AbstractProcessingHandler { - private $udpConnection; - private $httpConnection; - private $scheme; - private $host; - private $port; - private $acceptedSchemes = ['http', 'udp']; + private ?\Socket $udpConnection = null; + private ?\CurlHandle $httpConnection = null; + private string $scheme; + private string $host; + private int $port; + /** @var string[] */ + private array $acceptedSchemes = ['http', 'udp']; /** * Create a Cube handler @@ -35,15 +39,15 @@ class CubeHandler extends AbstractProcessingHandler * A valid url must consist of three parts : protocol://host:port * Only valid protocols used by Cube are http and udp */ - public function __construct($url, $level = Logger::DEBUG, $bubble = true) + public function __construct(string $url, int|string|Level $level = Level::Debug, bool $bubble = true) { $urlInfo = parse_url($url); - if (!isset($urlInfo['scheme'], $urlInfo['host'], $urlInfo['port'])) { + if ($urlInfo === false || !isset($urlInfo['scheme'], $urlInfo['host'], $urlInfo['port'])) { throw new \UnexpectedValueException('URL "'.$url.'" is not valid'); } - if (!in_array($urlInfo['scheme'], $this->acceptedSchemes)) { + if (!in_array($urlInfo['scheme'], $this->acceptedSchemes, true)) { throw new \UnexpectedValueException( 'Invalid protocol (' . $urlInfo['scheme'] . ').' . ' Valid options are ' . implode(', ', $this->acceptedSchemes) @@ -63,84 +67,95 @@ public function __construct($url, $level = Logger::DEBUG, $bubble = true) * @throws \LogicException when unable to connect to the socket * @throws MissingExtensionException when there is no socket extension */ - protected function connectUdp() + protected function connectUdp(): void { if (!extension_loaded('sockets')) { throw new MissingExtensionException('The sockets extension is required to use udp URLs with the CubeHandler'); } - $this->udpConnection = socket_create(AF_INET, SOCK_DGRAM, 0); - if (!$this->udpConnection) { + $udpConnection = socket_create(AF_INET, SOCK_DGRAM, 0); + if (false === $udpConnection) { throw new \LogicException('Unable to create a socket'); } + $this->udpConnection = $udpConnection; if (!socket_connect($this->udpConnection, $this->host, $this->port)) { throw new \LogicException('Unable to connect to the socket at ' . $this->host . ':' . $this->port); } } /** - * Establish a connection to a http server - * @throws \LogicException when no curl extension + * Establish a connection to an http server + * + * @throws \LogicException when unable to connect to the socket + * @throws MissingExtensionException when no curl extension */ - protected function connectHttp() + protected function connectHttp(): void { if (!extension_loaded('curl')) { - throw new \LogicException('The curl extension is needed to use http URLs with the CubeHandler'); + throw new MissingExtensionException('The curl extension is required to use http URLs with the CubeHandler'); } - $this->httpConnection = curl_init('http://'.$this->host.':'.$this->port.'/1.0/event/put'); - - if (!$this->httpConnection) { + $httpConnection = curl_init('http://'.$this->host.':'.$this->port.'/1.0/event/put'); + if (false === $httpConnection) { throw new \LogicException('Unable to connect to ' . $this->host . ':' . $this->port); } + $this->httpConnection = $httpConnection; curl_setopt($this->httpConnection, CURLOPT_CUSTOMREQUEST, "POST"); curl_setopt($this->httpConnection, CURLOPT_RETURNTRANSFER, true); } /** - * {@inheritdoc} + * @inheritDoc */ - protected function write(array $record) + protected function write(LogRecord $record): void { - $date = $record['datetime']; + $date = $record->datetime; $data = ['time' => $date->format('Y-m-d\TH:i:s.uO')]; - unset($record['datetime']); + $context = $record->context; - if (isset($record['context']['type'])) { - $data['type'] = $record['context']['type']; - unset($record['context']['type']); + if (isset($context['type'])) { + $data['type'] = $context['type']; + unset($context['type']); } else { - $data['type'] = $record['channel']; + $data['type'] = $record->channel; } - $data['data'] = $record['context']; - $data['data']['level'] = $record['level']; + $data['data'] = $context; + $data['data']['level'] = $record->level; if ($this->scheme === 'http') { - $this->writeHttp(json_encode($data)); + $this->writeHttp(Utils::jsonEncode($data)); } else { - $this->writeUdp(json_encode($data)); + $this->writeUdp(Utils::jsonEncode($data)); } } - private function writeUdp($data) + private function writeUdp(string $data): void { - if (!$this->udpConnection) { + if (null === $this->udpConnection) { $this->connectUdp(); } + if (null === $this->udpConnection) { + throw new \LogicException('No UDP socket could be opened'); + } + socket_send($this->udpConnection, $data, strlen($data), 0); } - private function writeHttp($data) + private function writeHttp(string $data): void { - if (!$this->httpConnection) { + if (null === $this->httpConnection) { $this->connectHttp(); } + if (null === $this->httpConnection) { + throw new \LogicException('No connection could be established'); + } + curl_setopt($this->httpConnection, CURLOPT_POSTFIELDS, '['.$data.']'); curl_setopt($this->httpConnection, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', diff --git a/src/Monolog/Handler/Curl/Util.php b/src/Monolog/Handler/Curl/Util.php index b0bec3d9b..4decf0e62 100644 --- a/src/Monolog/Handler/Curl/Util.php +++ b/src/Monolog/Handler/Curl/Util.php @@ -11,9 +11,17 @@ namespace Monolog\Handler\Curl; -class Util +use CurlHandle; + +/** + * This class is marked as internal and it is not under the BC promise of the package. + * + * @internal + */ +final class Util { - private static $retriableErrorCodes = [ + /** @var array */ + private static array $retriableErrorCodes = [ CURLE_COULDNT_RESOLVE_HOST, CURLE_COULDNT_CONNECT, CURLE_HTTP_NOT_FOUND, @@ -26,23 +34,24 @@ class Util /** * Executes a CURL request with optional retries and exception on failure * - * @param resource $ch curl handler - * @throws \RuntimeException + * @param CurlHandle $ch curl handler + * @return bool|string @see curl_exec */ - public static function execute($ch, $retries = 5, $closeAfterDone = true) + public static function execute(CurlHandle $ch, int $retries = 5, bool $closeAfterDone = true) { while ($retries--) { - if (curl_exec($ch) === false) { + $curlResponse = curl_exec($ch); + if ($curlResponse === false) { $curlErrno = curl_errno($ch); - if (false === in_array($curlErrno, self::$retriableErrorCodes, true) || !$retries) { + if (false === in_array($curlErrno, self::$retriableErrorCodes, true) || $retries === 0) { $curlError = curl_error($ch); if ($closeAfterDone) { curl_close($ch); } - throw new \RuntimeException(sprintf('Curl error (code %s): %s', $curlErrno, $curlError)); + throw new \RuntimeException(sprintf('Curl error (code %d): %s', $curlErrno, $curlError)); } continue; @@ -51,7 +60,10 @@ public static function execute($ch, $retries = 5, $closeAfterDone = true) if ($closeAfterDone) { curl_close($ch); } - break; + + return $curlResponse; } + + return false; } } diff --git a/src/Monolog/Handler/DeduplicationHandler.php b/src/Monolog/Handler/DeduplicationHandler.php index 6d7753cba..355491219 100644 --- a/src/Monolog/Handler/DeduplicationHandler.php +++ b/src/Monolog/Handler/DeduplicationHandler.php @@ -11,7 +11,10 @@ namespace Monolog\Handler; +use Monolog\Level; use Monolog\Logger; +use Psr\Log\LogLevel; +use Monolog\LogRecord; /** * Simple handler wrapper that deduplicates log records across multiple requests @@ -35,43 +38,33 @@ */ class DeduplicationHandler extends BufferHandler { - /** - * @var string - */ - protected $deduplicationStore; + protected string $deduplicationStore; - /** - * @var int - */ - protected $deduplicationLevel; + protected Level $deduplicationLevel; - /** - * @var int - */ - protected $time; + protected int $time; - /** - * @var bool - */ - private $gc = false; + private bool $gc = false; /** - * @param HandlerInterface $handler Handler. - * @param string $deduplicationStore The file/path where the deduplication log should be kept - * @param int $deduplicationLevel The minimum logging level for log records to be looked at for deduplication purposes - * @param int $time The period (in seconds) during which duplicate entries should be suppressed after a given log is sent through - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not + * @param HandlerInterface $handler Handler. + * @param string $deduplicationStore The file/path where the deduplication log should be kept + * @param int|string|Level|LogLevel::* $deduplicationLevel The minimum logging level for log records to be looked at for deduplication purposes + * @param int $time The period (in seconds) during which duplicate entries should be suppressed after a given log is sent through + * @param bool $bubble Whether the messages that are handled can bubble up the stack or not + * + * @phpstan-param value-of|value-of|Level|LogLevel::* $deduplicationLevel */ - public function __construct(HandlerInterface $handler, $deduplicationStore = null, $deduplicationLevel = Logger::ERROR, $time = 60, $bubble = true) + public function __construct(HandlerInterface $handler, ?string $deduplicationStore = null, int|string|Level $deduplicationLevel = Level::Error, int $time = 60, bool $bubble = true) { - parent::__construct($handler, 0, Logger::DEBUG, $bubble, false); + parent::__construct($handler, 0, Level::Debug, $bubble, false); $this->deduplicationStore = $deduplicationStore === null ? sys_get_temp_dir() . '/monolog-dedup-' . substr(md5(__FILE__), 0, 20) .'.log' : $deduplicationStore; $this->deduplicationLevel = Logger::toMonologLevel($deduplicationLevel); $this->time = $time; } - public function flush() + public function flush(): void { if ($this->bufferSize === 0) { return; @@ -80,8 +73,8 @@ public function flush() $passthru = null; foreach ($this->buffer as $record) { - if ($record['level'] >= $this->deduplicationLevel) { - $passthru = $passthru || !$this->isDuplicate($record); + if ($record->level->value >= $this->deduplicationLevel->value) { + $passthru = $passthru === true || !$this->isDuplicate($record); if ($passthru) { $this->appendRecord($record); } @@ -100,7 +93,7 @@ public function flush() } } - private function isDuplicate(array $record) + private function isDuplicate(LogRecord $record): bool { if (!file_exists($this->deduplicationStore)) { return false; @@ -112,13 +105,13 @@ private function isDuplicate(array $record) } $yesterday = time() - 86400; - $timestampValidity = $record['datetime']->getTimestamp() - $this->time; - $expectedMessage = preg_replace('{[\r\n].*}', '', $record['message']); + $timestampValidity = $record->datetime->getTimestamp() - $this->time; + $expectedMessage = preg_replace('{[\r\n].*}', '', $record->message); for ($i = count($store) - 1; $i >= 0; $i--) { list($timestamp, $level, $message) = explode(':', $store[$i], 3); - if ($level === $record['level_name'] && $message === $expectedMessage && $timestamp > $timestampValidity) { + if ($level === $record->level->getName() && $message === $expectedMessage && $timestamp > $timestampValidity) { return true; } @@ -130,13 +123,18 @@ private function isDuplicate(array $record) return false; } - private function collectLogs() + private function collectLogs(): void { if (!file_exists($this->deduplicationStore)) { - return false; + return; } $handle = fopen($this->deduplicationStore, 'rw+'); + + if (false === $handle) { + throw new \RuntimeException('Failed to open file for reading and writing: ' . $this->deduplicationStore); + } + flock($handle, LOCK_EX); $validLogs = []; @@ -144,7 +142,7 @@ private function collectLogs() while (!feof($handle)) { $log = fgets($handle); - if ($log && substr($log, 0, 10) >= $timestampValidity) { + if (is_string($log) && '' !== $log && substr($log, 0, 10) >= $timestampValidity) { $validLogs[] = $log; } } @@ -161,8 +159,8 @@ private function collectLogs() $this->gc = false; } - private function appendRecord(array $record) + private function appendRecord(LogRecord $record): void { - file_put_contents($this->deduplicationStore, $record['datetime']->getTimestamp() . ':' . $record['level_name'] . ':' . preg_replace('{[\r\n].*}', '', $record['message']) . "\n", FILE_APPEND); + file_put_contents($this->deduplicationStore, $record->datetime->getTimestamp() . ':' . $record->level->getName() . ':' . preg_replace('{[\r\n].*}', '', $record->message) . "\n", FILE_APPEND); } } diff --git a/src/Monolog/Handler/DoctrineCouchDBHandler.php b/src/Monolog/Handler/DoctrineCouchDBHandler.php index 13a08eecf..eab9f1089 100644 --- a/src/Monolog/Handler/DoctrineCouchDBHandler.php +++ b/src/Monolog/Handler/DoctrineCouchDBHandler.php @@ -11,10 +11,11 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; use Monolog\Formatter\NormalizerFormatter; use Monolog\Formatter\FormatterInterface; use Doctrine\CouchDB\CouchDBClient; +use Monolog\LogRecord; /** * CouchDB handler for Doctrine CouchDB ODM @@ -23,20 +24,20 @@ */ class DoctrineCouchDBHandler extends AbstractProcessingHandler { - private $client; + private CouchDBClient $client; - public function __construct(CouchDBClient $client, $level = Logger::DEBUG, $bubble = true) + public function __construct(CouchDBClient $client, int|string|Level $level = Level::Debug, bool $bubble = true) { $this->client = $client; parent::__construct($level, $bubble); } /** - * {@inheritDoc} + * @inheritDoc */ - protected function write(array $record) + protected function write(LogRecord $record): void { - $this->client->postDocument($record['formatted']); + $this->client->postDocument($record->formatted); } protected function getDefaultFormatter(): FormatterInterface diff --git a/src/Monolog/Handler/DynamoDbHandler.php b/src/Monolog/Handler/DynamoDbHandler.php index a6991fa93..f1c5a9590 100644 --- a/src/Monolog/Handler/DynamoDbHandler.php +++ b/src/Monolog/Handler/DynamoDbHandler.php @@ -16,7 +16,8 @@ use Monolog\Formatter\FormatterInterface; use Aws\DynamoDb\Marshaler; use Monolog\Formatter\ScalarFormatter; -use Monolog\Logger; +use Monolog\Level; +use Monolog\LogRecord; /** * Amazon DynamoDB handler (http://aws.amazon.com/dynamodb/) @@ -26,42 +27,17 @@ */ class DynamoDbHandler extends AbstractProcessingHandler { - const DATE_FORMAT = 'Y-m-d\TH:i:s.uO'; + public const DATE_FORMAT = 'Y-m-d\TH:i:s.uO'; - /** - * @var DynamoDbClient - */ - protected $client; + protected DynamoDbClient $client; - /** - * @var string - */ - protected $table; + protected string $table; - /** - * @var int - */ - protected $version; + protected Marshaler $marshaler; - /** - * @var Marshaler - */ - protected $marshaler; - - /** - * @param DynamoDbClient $client - * @param string $table - * @param int $level - * @param bool $bubble - */ - public function __construct(DynamoDbClient $client, $table, $level = Logger::DEBUG, $bubble = true) + public function __construct(DynamoDbClient $client, string $table, int|string|Level $level = Level::Debug, bool $bubble = true) { - if (defined('Aws\Sdk::VERSION') && version_compare(Sdk::VERSION, '3.0', '>=')) { - $this->version = 3; - $this->marshaler = new Marshaler; - } else { - $this->version = 2; - } + $this->marshaler = new Marshaler; $this->client = $client; $this->table = $table; @@ -70,16 +46,12 @@ public function __construct(DynamoDbClient $client, $table, $level = Logger::DEB } /** - * {@inheritdoc} + * @inheritDoc */ - protected function write(array $record) + protected function write(LogRecord $record): void { - $filtered = $this->filterEmptyFields($record['formatted']); - if ($this->version === 3) { - $formatted = $this->marshaler->marshalItem($filtered); - } else { - $formatted = $this->client->formatAttributes($filtered); - } + $filtered = $this->filterEmptyFields($record->formatted); + $formatted = $this->marshaler->marshalItem($filtered); $this->client->putItem([ 'TableName' => $this->table, @@ -88,18 +60,18 @@ protected function write(array $record) } /** - * @param array $record - * @return array + * @param mixed[] $record + * @return mixed[] */ - protected function filterEmptyFields(array $record) + protected function filterEmptyFields(array $record): array { return array_filter($record, function ($value) { - return !empty($value) || false === $value || 0 === $value; + return [] !== $value; }); } /** - * {@inheritdoc} + * @inheritDoc */ protected function getDefaultFormatter(): FormatterInterface { diff --git a/src/Monolog/Handler/ElasticSearchHandler.php b/src/Monolog/Handler/ElasticaHandler.php similarity index 62% rename from src/Monolog/Handler/ElasticSearchHandler.php rename to src/Monolog/Handler/ElasticaHandler.php index b7dc56760..d9b85b4d0 100644 --- a/src/Monolog/Handler/ElasticSearchHandler.php +++ b/src/Monolog/Handler/ElasticaHandler.php @@ -11,11 +11,13 @@ namespace Monolog\Handler; +use Elastica\Document; use Monolog\Formatter\FormatterInterface; use Monolog\Formatter\ElasticaFormatter; -use Monolog\Logger; +use Monolog\Level; use Elastica\Client; use Elastica\Exception\ExceptionInterface; +use Monolog\LogRecord; /** * Elastic Search handler @@ -25,33 +27,41 @@ * $client = new \Elastica\Client(); * $options = array( * 'index' => 'elastic_index_name', - * 'type' => 'elastic_doc_type', + * 'type' => 'elastic_doc_type', Types have been removed in Elastica 7 * ); - * $handler = new ElasticSearchHandler($client, $options); + * $handler = new ElasticaHandler($client, $options); * $log = new Logger('application'); * $log->pushHandler($handler); * * @author Jelle Vink + * @phpstan-type Options array{ + * index: string, + * type: string, + * ignore_error: bool + * } + * @phpstan-type InputOptions array{ + * index?: string, + * type?: string, + * ignore_error?: bool + * } */ -class ElasticSearchHandler extends AbstractProcessingHandler +class ElasticaHandler extends AbstractProcessingHandler { - /** - * @var Client - */ - protected $client; + protected Client $client; /** - * @var array Handler config options + * @var mixed[] Handler config options + * @phpstan-var Options */ - protected $options = []; + protected array $options; /** - * @param Client $client Elastica Client object - * @param array $options Handler configuration - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not + * @param Client $client Elastica Client object + * @param mixed[] $options Handler configuration + * + * @phpstan-param InputOptions $options */ - public function __construct(Client $client, array $options = [], $level = Logger::DEBUG, $bubble = true) + public function __construct(Client $client, array $options = [], int|string|Level $level = Level::Debug, bool $bubble = true) { parent::__construct($level, $bubble); $this->client = $client; @@ -66,15 +76,15 @@ public function __construct(Client $client, array $options = [], $level = Logger } /** - * {@inheritDoc} + * @inheritDoc */ - protected function write(array $record) + protected function write(LogRecord $record): void { - $this->bulkSend([$record['formatted']]); + $this->bulkSend([$record->formatted]); } /** - * {@inheritdoc} + * @inheritDoc */ public function setFormatter(FormatterInterface $formatter): HandlerInterface { @@ -82,20 +92,21 @@ public function setFormatter(FormatterInterface $formatter): HandlerInterface return parent::setFormatter($formatter); } - throw new \InvalidArgumentException('ElasticSearchHandler is only compatible with ElasticaFormatter'); + throw new \InvalidArgumentException('ElasticaHandler is only compatible with ElasticaFormatter'); } /** - * Getter options - * @return array + * @return mixed[] + * + * @phpstan-return Options */ - public function getOptions() + public function getOptions(): array { return $this->options; } /** - * {@inheritDoc} + * @inheritDoc */ protected function getDefaultFormatter(): FormatterInterface { @@ -103,9 +114,9 @@ protected function getDefaultFormatter(): FormatterInterface } /** - * {@inheritdoc} + * @inheritDoc */ - public function handleBatch(array $records) + public function handleBatch(array $records): void { $documents = $this->getFormatter()->formatBatch($records); $this->bulkSend($documents); @@ -113,10 +124,12 @@ public function handleBatch(array $records) /** * Use Elasticsearch bulk API to send list of documents - * @param array $documents + * + * @param Document[] $documents + * * @throws \RuntimeException */ - protected function bulkSend(array $documents) + protected function bulkSend(array $documents): void { try { $this->client->addDocuments($documents); diff --git a/src/Monolog/Handler/ElasticsearchHandler.php b/src/Monolog/Handler/ElasticsearchHandler.php new file mode 100644 index 000000000..c288824aa --- /dev/null +++ b/src/Monolog/Handler/ElasticsearchHandler.php @@ -0,0 +1,227 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Handler; + +use Elastic\Elasticsearch\Response\Elasticsearch; +use Throwable; +use RuntimeException; +use Monolog\Level; +use Monolog\Formatter\FormatterInterface; +use Monolog\Formatter\ElasticsearchFormatter; +use InvalidArgumentException; +use Elasticsearch\Common\Exceptions\RuntimeException as ElasticsearchRuntimeException; +use Elasticsearch\Client; +use Monolog\LogRecord; +use Elastic\Elasticsearch\Exception\InvalidArgumentException as ElasticInvalidArgumentException; +use Elastic\Elasticsearch\Client as Client8; + +/** + * Elasticsearch handler + * + * @link https://www.elastic.co/guide/en/elasticsearch/client/php-api/current/index.html + * + * Simple usage example: + * + * $client = \Elasticsearch\ClientBuilder::create() + * ->setHosts($hosts) + * ->build(); + * + * $options = array( + * 'index' => 'elastic_index_name', + * 'type' => 'elastic_doc_type', + * ); + * $handler = new ElasticsearchHandler($client, $options); + * $log = new Logger('application'); + * $log->pushHandler($handler); + * + * @author Avtandil Kikabidze + * @phpstan-type Options array{ + * index: string, + * type: string, + * ignore_error: bool + * } + * @phpstan-type InputOptions array{ + * index?: string, + * type?: string, + * ignore_error?: bool + * } + */ +class ElasticsearchHandler extends AbstractProcessingHandler +{ + protected Client|Client8 $client; + + /** + * @var mixed[] Handler config options + * @phpstan-var Options + */ + protected array $options; + + /** + * @var bool + */ + private $needsType; + + /** + * @param Client|Client8 $client Elasticsearch Client object + * @param mixed[] $options Handler configuration + * + * @phpstan-param InputOptions $options + */ + public function __construct(Client|Client8 $client, array $options = [], int|string|Level $level = Level::Debug, bool $bubble = true) + { + parent::__construct($level, $bubble); + $this->client = $client; + $this->options = array_merge( + [ + 'index' => 'monolog', // Elastic index name + 'type' => '_doc', // Elastic document type + 'ignore_error' => false, // Suppress Elasticsearch exceptions + ], + $options + ); + + if ($client instanceof Client8 || $client::VERSION[0] === '7') { + $this->needsType = false; + // force the type to _doc for ES8/ES7 + $this->options['type'] = '_doc'; + } else { + $this->needsType = true; + } + } + + /** + * @inheritDoc + */ + protected function write(LogRecord $record): void + { + $this->bulkSend([$record->formatted]); + } + + /** + * @inheritDoc + */ + public function setFormatter(FormatterInterface $formatter): HandlerInterface + { + if ($formatter instanceof ElasticsearchFormatter) { + return parent::setFormatter($formatter); + } + + throw new InvalidArgumentException('ElasticsearchHandler is only compatible with ElasticsearchFormatter'); + } + + /** + * Getter options + * + * @return mixed[] + * + * @phpstan-return Options + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * @inheritDoc + */ + protected function getDefaultFormatter(): FormatterInterface + { + return new ElasticsearchFormatter($this->options['index'], $this->options['type']); + } + + /** + * @inheritDoc + */ + public function handleBatch(array $records): void + { + $documents = $this->getFormatter()->formatBatch($records); + $this->bulkSend($documents); + } + + /** + * Use Elasticsearch bulk API to send list of documents + * + * @param array> $records Records + _index/_type keys + * @throws \RuntimeException + */ + protected function bulkSend(array $records): void + { + try { + $params = [ + 'body' => [], + ]; + + foreach ($records as $record) { + $params['body'][] = [ + 'index' => $this->needsType ? [ + '_index' => $record['_index'], + '_type' => $record['_type'], + ] : [ + '_index' => $record['_index'], + ], + ]; + unset($record['_index'], $record['_type']); + + $params['body'][] = $record; + } + + /** @var Elasticsearch */ + $responses = $this->client->bulk($params); + + if ($responses['errors'] === true) { + throw $this->createExceptionFromResponses($responses); + } + } catch (Throwable $e) { + if (! $this->options['ignore_error']) { + throw new RuntimeException('Error sending messages to Elasticsearch', 0, $e); + } + } + } + + /** + * Creates elasticsearch exception from responses array + * + * Only the first error is converted into an exception. + * + * @param mixed[]|Elasticsearch $responses returned by $this->client->bulk() + */ + protected function createExceptionFromResponses($responses): Throwable + { + foreach ($responses['items'] ?? [] as $item) { + if (isset($item['index']['error'])) { + return $this->createExceptionFromError($item['index']['error']); + } + } + + if (class_exists(ElasticInvalidArgumentException::class)) { + return new ElasticInvalidArgumentException('Elasticsearch failed to index one or more records.'); + } + + return new ElasticsearchRuntimeException('Elasticsearch failed to index one or more records.'); + } + + /** + * Creates elasticsearch exception from error array + * + * @param mixed[] $error + */ + protected function createExceptionFromError(array $error): Throwable + { + $previous = isset($error['caused_by']) ? $this->createExceptionFromError($error['caused_by']) : null; + + if (class_exists(ElasticInvalidArgumentException::class)) { + return new ElasticInvalidArgumentException($error['type'] . ': ' . $error['reason'], 0, $previous); + } + + return new ElasticsearchRuntimeException($error['type'] . ': ' . $error['reason'], 0, $previous); + } +} diff --git a/src/Monolog/Handler/ErrorLogHandler.php b/src/Monolog/Handler/ErrorLogHandler.php index 3108aed78..477d7e45a 100644 --- a/src/Monolog/Handler/ErrorLogHandler.php +++ b/src/Monolog/Handler/ErrorLogHandler.php @@ -13,7 +13,9 @@ use Monolog\Formatter\LineFormatter; use Monolog\Formatter\FormatterInterface; -use Monolog\Logger; +use Monolog\Level; +use Monolog\Utils; +use Monolog\LogRecord; /** * Stores to PHP error_log() handler. @@ -22,19 +24,17 @@ */ class ErrorLogHandler extends AbstractProcessingHandler { - const OPERATING_SYSTEM = 0; - const SAPI = 4; + public const OPERATING_SYSTEM = 0; + public const SAPI = 4; - protected $messageType; - protected $expandNewlines; + protected int $messageType; + protected bool $expandNewlines; /** * @param int $messageType Says where the error should go. - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not * @param bool $expandNewlines If set to true, newlines in the message will be expanded to be take multiple log entries */ - public function __construct($messageType = self::OPERATING_SYSTEM, $level = Logger::DEBUG, $bubble = true, $expandNewlines = false) + public function __construct(int $messageType = self::OPERATING_SYSTEM, int|string|Level $level = Level::Debug, bool $bubble = true, bool $expandNewlines = false) { parent::__construct($level, $bubble); @@ -49,9 +49,9 @@ public function __construct($messageType = self::OPERATING_SYSTEM, $level = Logg } /** - * @return array With all available types + * @return int[] With all available types */ - public static function getAvailableTypes() + public static function getAvailableTypes(): array { return [ self::OPERATING_SYSTEM, @@ -60,7 +60,7 @@ public static function getAvailableTypes() } /** - * {@inheritDoc} + * @inheritDoc */ protected function getDefaultFormatter(): FormatterInterface { @@ -68,17 +68,22 @@ protected function getDefaultFormatter(): FormatterInterface } /** - * {@inheritdoc} + * @inheritDoc */ - protected function write(array $record) + protected function write(LogRecord $record): void { if (!$this->expandNewlines) { - error_log((string) $record['formatted'], $this->messageType); + error_log((string) $record->formatted, $this->messageType); return; } - $lines = preg_split('{[\r\n]+}', (string) $record['formatted']); + $lines = preg_split('{[\r\n]+}', (string) $record->formatted); + if ($lines === false) { + $pcreErrorCode = preg_last_error(); + + throw new \RuntimeException('Failed to preg_split formatted string: ' . $pcreErrorCode . ' / '. Utils::pcreLastErrorMessage($pcreErrorCode)); + } foreach ($lines as $line) { error_log($line, $this->messageType); } diff --git a/src/Monolog/Handler/FallbackGroupHandler.php b/src/Monolog/Handler/FallbackGroupHandler.php new file mode 100644 index 000000000..1776eb517 --- /dev/null +++ b/src/Monolog/Handler/FallbackGroupHandler.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Handler; + +use Throwable; +use Monolog\LogRecord; + +/** + * Forwards records to at most one handler + * + * If a handler fails, the exception is suppressed and the record is forwarded to the next handler. + * + * As soon as one handler handles a record successfully, the handling stops there. + */ +class FallbackGroupHandler extends GroupHandler +{ + /** + * @inheritDoc + */ + public function handle(LogRecord $record): bool + { + if (\count($this->processors) > 0) { + $record = $this->processRecord($record); + } + foreach ($this->handlers as $handler) { + try { + $handler->handle($record); + break; + } catch (Throwable $e) { + // What throwable? + } + } + + return false === $this->bubble; + } + + /** + * @inheritDoc + */ + public function handleBatch(array $records): void + { + if (\count($this->processors) > 0) { + $processed = []; + foreach ($records as $record) { + $processed[] = $this->processRecord($record); + } + $records = $processed; + } + + foreach ($this->handlers as $handler) { + try { + $handler->handleBatch($records); + break; + } catch (Throwable $e) { + // What throwable? + } + } + } +} diff --git a/src/Monolog/Handler/FilterHandler.php b/src/Monolog/Handler/FilterHandler.php index b42ce9664..00381ab4d 100644 --- a/src/Monolog/Handler/FilterHandler.php +++ b/src/Monolog/Handler/FilterHandler.php @@ -11,7 +11,13 @@ namespace Monolog\Handler; +use Closure; +use Monolog\Level; use Monolog\Logger; +use Monolog\ResettableInterface; +use Monolog\Formatter\FormatterInterface; +use Psr\Log\LogLevel; +use Monolog\LogRecord; /** * Simple handler wrapper that filters records based on a list of levels @@ -21,112 +27,110 @@ * @author Hennadiy Verkh * @author Jordi Boggiano */ -class FilterHandler extends Handler implements ProcessableHandlerInterface +class FilterHandler extends Handler implements ProcessableHandlerInterface, ResettableInterface, FormattableHandlerInterface { use ProcessableHandlerTrait; /** - * Handler or factory callable($record, $this) + * Handler or factory Closure($record, $this) * - * @var callable|\Monolog\Handler\HandlerInterface + * @phpstan-var (Closure(LogRecord|null, HandlerInterface): HandlerInterface)|HandlerInterface */ - protected $handler; + protected Closure|HandlerInterface $handler; /** * Minimum level for logs that are passed to handler * - * @var int[] + * @var bool[] Map of Level value => true + * @phpstan-var array, true> */ - protected $acceptedLevels; + protected array $acceptedLevels; /** * Whether the messages that are handled can bubble up the stack or not - * - * @var bool */ - protected $bubble; + protected bool $bubble; /** - * @param callable|HandlerInterface $handler Handler or factory callable($record, $this). - * @param int|array $minLevelOrList A list of levels to accept or a minimum level if maxLevel is provided - * @param int $maxLevel Maximum level to accept, only used if $minLevelOrList is not an array - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not + * @phpstan-param (Closure(LogRecord|null, HandlerInterface): HandlerInterface)|HandlerInterface $handler + * + * @param Closure|HandlerInterface $handler Handler or factory Closure($record|null, $filterHandler). + * @param int|string|Level|array $minLevelOrList A list of levels to accept or a minimum level if maxLevel is provided + * @param int|string|Level|LogLevel::* $maxLevel Maximum level to accept, only used if $minLevelOrList is not an array + * @param bool $bubble Whether the messages that are handled can bubble up the stack or not + * + * @phpstan-param value-of|value-of|Level|LogLevel::*|array|value-of|Level|LogLevel::*> $minLevelOrList + * @phpstan-param value-of|value-of|Level|LogLevel::* $maxLevel */ - public function __construct($handler, $minLevelOrList = Logger::DEBUG, $maxLevel = Logger::EMERGENCY, $bubble = true) + public function __construct(Closure|HandlerInterface $handler, int|string|Level|array $minLevelOrList = Level::Debug, int|string|Level $maxLevel = Level::Emergency, bool $bubble = true) { $this->handler = $handler; $this->bubble = $bubble; $this->setAcceptedLevels($minLevelOrList, $maxLevel); - - if (!$this->handler instanceof HandlerInterface && !is_callable($this->handler)) { - throw new \RuntimeException("The given handler (".json_encode($this->handler).") is not a callable nor a Monolog\Handler\HandlerInterface object"); - } } /** - * @return array + * @phpstan-return list List of levels */ public function getAcceptedLevels(): array { - return array_flip($this->acceptedLevels); + return array_map(fn (int $level) => Level::from($level), array_keys($this->acceptedLevels)); } /** - * @param int|string|array $minLevelOrList A list of levels to accept or a minimum level or level name if maxLevel is provided - * @param int|string $maxLevel Maximum level or level name to accept, only used if $minLevelOrList is not an array + * @param int|string|Level|LogLevel::*|array $minLevelOrList A list of levels to accept or a minimum level or level name if maxLevel is provided + * @param int|string|Level|LogLevel::* $maxLevel Maximum level or level name to accept, only used if $minLevelOrList is not an array + * + * @phpstan-param value-of|value-of|Level|LogLevel::*|array|value-of|Level|LogLevel::*> $minLevelOrList + * @phpstan-param value-of|value-of|Level|LogLevel::* $maxLevel */ - public function setAcceptedLevels($minLevelOrList = Logger::DEBUG, $maxLevel = Logger::EMERGENCY) + public function setAcceptedLevels(int|string|Level|array $minLevelOrList = Level::Debug, int|string|Level $maxLevel = Level::Emergency): self { if (is_array($minLevelOrList)) { - $acceptedLevels = array_map('Monolog\Logger::toMonologLevel', $minLevelOrList); + $acceptedLevels = array_map(Logger::toMonologLevel(...), $minLevelOrList); } else { $minLevelOrList = Logger::toMonologLevel($minLevelOrList); $maxLevel = Logger::toMonologLevel($maxLevel); - $acceptedLevels = array_values(array_filter(Logger::getLevels(), function ($level) use ($minLevelOrList, $maxLevel) { - return $level >= $minLevelOrList && $level <= $maxLevel; - })); + $acceptedLevels = array_values(array_filter(Level::cases(), fn (Level $level) => $level->value >= $minLevelOrList->value && $level->value <= $maxLevel->value)); } - $this->acceptedLevels = array_flip($acceptedLevels); + $this->acceptedLevels = []; + foreach ($acceptedLevels as $level) { + $this->acceptedLevels[$level->value] = true; + } + + return $this; } /** - * {@inheritdoc} + * @inheritDoc */ - public function isHandling(array $record): bool + public function isHandling(LogRecord $record): bool { - return isset($this->acceptedLevels[$record['level']]); + return isset($this->acceptedLevels[$record->level->value]); } /** - * {@inheritdoc} + * @inheritDoc */ - public function handle(array $record): bool + public function handle(LogRecord $record): bool { if (!$this->isHandling($record)) { return false; } - // The same logic as in FingersCrossedHandler - if (!$this->handler instanceof HandlerInterface) { - $this->handler = call_user_func($this->handler, $record, $this); - if (!$this->handler instanceof HandlerInterface) { - throw new \RuntimeException("The factory callable should return a HandlerInterface"); - } - } - - if ($this->processors) { + if (\count($this->processors) > 0) { $record = $this->processRecord($record); } - $this->handler->handle($record); + $this->getHandler($record)->handle($record); return false === $this->bubble; } /** - * {@inheritdoc} + * @inheritDoc */ - public function handleBatch(array $records) + public function handleBatch(array $records): void { $filtered = []; foreach ($records as $record) { @@ -135,6 +139,63 @@ public function handleBatch(array $records) } } - $this->handler->handleBatch($filtered); + if (count($filtered) > 0) { + $this->getHandler($filtered[count($filtered) - 1])->handleBatch($filtered); + } + } + + /** + * Return the nested handler + * + * If the handler was provided as a factory, this will trigger the handler's instantiation. + */ + public function getHandler(LogRecord $record = null): HandlerInterface + { + if (!$this->handler instanceof HandlerInterface) { + $handler = ($this->handler)($record, $this); + if (!$handler instanceof HandlerInterface) { + throw new \RuntimeException("The factory Closure should return a HandlerInterface"); + } + $this->handler = $handler; + } + + return $this->handler; + } + + /** + * @inheritDoc + */ + public function setFormatter(FormatterInterface $formatter): HandlerInterface + { + $handler = $this->getHandler(); + if ($handler instanceof FormattableHandlerInterface) { + $handler->setFormatter($formatter); + + return $this; + } + + throw new \UnexpectedValueException('The nested handler of type '.get_class($handler).' does not support formatters.'); + } + + /** + * @inheritDoc + */ + public function getFormatter(): FormatterInterface + { + $handler = $this->getHandler(); + if ($handler instanceof FormattableHandlerInterface) { + return $handler->getFormatter(); + } + + throw new \UnexpectedValueException('The nested handler of type '.get_class($handler).' does not support formatters.'); + } + + public function reset(): void + { + $this->resetProcessors(); + + if ($this->getHandler() instanceof ResettableInterface) { + $this->getHandler()->reset(); + } } } diff --git a/src/Monolog/Handler/FingersCrossed/ActivationStrategyInterface.php b/src/Monolog/Handler/FingersCrossed/ActivationStrategyInterface.php index b73854ad7..e8a1b0b0d 100644 --- a/src/Monolog/Handler/FingersCrossed/ActivationStrategyInterface.php +++ b/src/Monolog/Handler/FingersCrossed/ActivationStrategyInterface.php @@ -11,6 +11,8 @@ namespace Monolog\Handler\FingersCrossed; +use Monolog\LogRecord; + /** * Interface for activation strategies for the FingersCrossedHandler. * @@ -20,9 +22,6 @@ interface ActivationStrategyInterface { /** * Returns whether the given record activates the handler. - * - * @param array $record - * @return bool */ - public function isHandlerActivated(array $record); + public function isHandlerActivated(LogRecord $record): bool; } diff --git a/src/Monolog/Handler/FingersCrossed/ChannelLevelActivationStrategy.php b/src/Monolog/Handler/FingersCrossed/ChannelLevelActivationStrategy.php index 63a14cb6e..383e19af9 100644 --- a/src/Monolog/Handler/FingersCrossed/ChannelLevelActivationStrategy.php +++ b/src/Monolog/Handler/FingersCrossed/ChannelLevelActivationStrategy.php @@ -11,7 +11,10 @@ namespace Monolog\Handler\FingersCrossed; +use Monolog\Level; use Monolog\Logger; +use Psr\Log\LogLevel; +use Monolog\LogRecord; /** * Channel and Error level based monolog activation strategy. Allows to trigger activation @@ -22,10 +25,10 @@ * * * $activationStrategy = new ChannelLevelActivationStrategy( - * Logger::CRITICAL, + * Level::Critical, * array( - * 'request' => Logger::ALERT, - * 'sensitive' => Logger::ERROR, + * 'request' => Level::Alert, + * 'sensitive' => Level::Error, * ) * ); * $handler = new FingersCrossedHandler(new StreamHandler('php://stderr'), $activationStrategy); @@ -35,25 +38,32 @@ */ class ChannelLevelActivationStrategy implements ActivationStrategyInterface { - private $defaultActionLevel; - private $channelToActionLevel; + private Level $defaultActionLevel; /** - * @param int $defaultActionLevel The default action level to be used if the record's category doesn't match any - * @param array $channelToActionLevel An array that maps channel names to action levels. + * @var array */ - public function __construct($defaultActionLevel, $channelToActionLevel = []) + private array $channelToActionLevel; + + /** + * @param int|string|Level|LogLevel::* $defaultActionLevel The default action level to be used if the record's category doesn't match any + * @param array $channelToActionLevel An array that maps channel names to action levels. + * + * @phpstan-param value-of|value-of|Level|LogLevel::* $defaultActionLevel + * @phpstan-param array|value-of|Level|LogLevel::*> $channelToActionLevel + */ + public function __construct(int|string|Level $defaultActionLevel, array $channelToActionLevel = []) { $this->defaultActionLevel = Logger::toMonologLevel($defaultActionLevel); - $this->channelToActionLevel = array_map('Monolog\Logger::toMonologLevel', $channelToActionLevel); + $this->channelToActionLevel = array_map(Logger::toMonologLevel(...), $channelToActionLevel); } - public function isHandlerActivated(array $record) + public function isHandlerActivated(LogRecord $record): bool { - if (isset($this->channelToActionLevel[$record['channel']])) { - return $record['level'] >= $this->channelToActionLevel[$record['channel']]; + if (isset($this->channelToActionLevel[$record->channel])) { + return $record->level->value >= $this->channelToActionLevel[$record->channel]->value; } - return $record['level'] >= $this->defaultActionLevel; + return $record->level->value >= $this->defaultActionLevel->value; } } diff --git a/src/Monolog/Handler/FingersCrossed/ErrorLevelActivationStrategy.php b/src/Monolog/Handler/FingersCrossed/ErrorLevelActivationStrategy.php index d0ebd8405..c3ca2967a 100644 --- a/src/Monolog/Handler/FingersCrossed/ErrorLevelActivationStrategy.php +++ b/src/Monolog/Handler/FingersCrossed/ErrorLevelActivationStrategy.php @@ -11,7 +11,10 @@ namespace Monolog\Handler\FingersCrossed; +use Monolog\Level; +use Monolog\LogRecord; use Monolog\Logger; +use Psr\Log\LogLevel; /** * Error level based activation strategy. @@ -20,15 +23,20 @@ */ class ErrorLevelActivationStrategy implements ActivationStrategyInterface { - private $actionLevel; + private Level $actionLevel; - public function __construct($actionLevel) + /** + * @param int|string|Level $actionLevel Level or name or value + * + * @phpstan-param value-of|value-of|Level|LogLevel::* $actionLevel + */ + public function __construct(int|string|Level $actionLevel) { $this->actionLevel = Logger::toMonologLevel($actionLevel); } - public function isHandlerActivated(array $record) + public function isHandlerActivated(LogRecord $record): bool { - return $record['level'] >= $this->actionLevel; + return $record->level->value >= $this->actionLevel->value; } } diff --git a/src/Monolog/Handler/FingersCrossedHandler.php b/src/Monolog/Handler/FingersCrossedHandler.php index 8943d4f01..ce2a3a8e1 100644 --- a/src/Monolog/Handler/FingersCrossedHandler.php +++ b/src/Monolog/Handler/FingersCrossedHandler.php @@ -11,9 +11,15 @@ namespace Monolog\Handler; +use Closure; use Monolog\Handler\FingersCrossed\ErrorLevelActivationStrategy; use Monolog\Handler\FingersCrossed\ActivationStrategyInterface; +use Monolog\Level; use Monolog\Logger; +use Monolog\ResettableInterface; +use Monolog\Formatter\FormatterInterface; +use Psr\Log\LogLevel; +use Monolog\LogRecord; /** * Buffers all records until a certain level is reached @@ -22,35 +28,58 @@ * Only requests which actually trigger an error (or whatever your actionLevel is) will be * in the logs, but they will contain all records, not only those above the level threshold. * + * You can then have a passthruLevel as well which means that at the end of the request, + * even if it did not get activated, it will still send through log records of e.g. at least a + * warning level. + * * You can find the various activation strategies in the * Monolog\Handler\FingersCrossed\ namespace. * * @author Jordi Boggiano */ -class FingersCrossedHandler extends Handler implements ProcessableHandlerInterface +class FingersCrossedHandler extends Handler implements ProcessableHandlerInterface, ResettableInterface, FormattableHandlerInterface { use ProcessableHandlerTrait; - protected $handler; - protected $activationStrategy; - protected $buffering = true; - protected $bufferSize; - protected $buffer = []; - protected $stopBuffering; - protected $passthruLevel; + /** + * Handler or factory Closure($record, $this) + * + * @phpstan-var (Closure(LogRecord|null, HandlerInterface): HandlerInterface)|HandlerInterface + */ + protected Closure|HandlerInterface $handler; + + protected ActivationStrategyInterface $activationStrategy; + + protected bool $buffering = true; + + protected int $bufferSize; + + /** @var LogRecord[] */ + protected array $buffer = []; + + protected bool $stopBuffering; + + protected Level|null $passthruLevel = null; + + protected bool $bubble; /** - * @param callable|HandlerInterface $handler Handler or factory callable($record, $fingersCrossedHandler). - * @param int|ActivationStrategyInterface $activationStrategy Strategy which determines when this handler takes action - * @param int $bufferSize How many entries should be buffered at most, beyond that the oldest items are removed from the buffer. - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not - * @param bool $stopBuffering Whether the handler should stop buffering after being triggered (default true) - * @param int $passthruLevel Minimum level to always flush to handler on close, even if strategy not triggered + * @phpstan-param (Closure(LogRecord|null, HandlerInterface): HandlerInterface)|HandlerInterface $handler + * + * @param Closure|HandlerInterface $handler Handler or factory Closure($record|null, $fingersCrossedHandler). + * @param int|string|Level|LogLevel::* $activationStrategy Strategy which determines when this handler takes action, or a level name/value at which the handler is activated + * @param int $bufferSize How many entries should be buffered at most, beyond that the oldest items are removed from the buffer. + * @param bool $bubble Whether the messages that are handled can bubble up the stack or not + * @param bool $stopBuffering Whether the handler should stop buffering after being triggered (default true) + * @param int|string|Level|LogLevel::*|null $passthruLevel Minimum level to always flush to handler on close, even if strategy not triggered + * + * @phpstan-param value-of|value-of|Level|LogLevel::*|ActivationStrategyInterface $activationStrategy + * @phpstan-param value-of|value-of|Level|LogLevel::* $passthruLevel */ - public function __construct($handler, $activationStrategy = null, $bufferSize = 0, $bubble = true, $stopBuffering = true, $passthruLevel = null) + public function __construct(Closure|HandlerInterface $handler, int|string|Level|ActivationStrategyInterface $activationStrategy = null, int $bufferSize = 0, bool $bubble = true, bool $stopBuffering = true, int|string|Level|null $passthruLevel = null) { if (null === $activationStrategy) { - $activationStrategy = new ErrorLevelActivationStrategy(Logger::WARNING); + $activationStrategy = new ErrorLevelActivationStrategy(Level::Warning); } // convert simple int activationStrategy to an object @@ -67,16 +96,12 @@ public function __construct($handler, $activationStrategy = null, $bufferSize = if ($passthruLevel !== null) { $this->passthruLevel = Logger::toMonologLevel($passthruLevel); } - - if (!$this->handler instanceof HandlerInterface && !is_callable($this->handler)) { - throw new \RuntimeException("The given handler (".json_encode($this->handler).") is not a callable nor a Monolog\Handler\HandlerInterface object"); - } } /** - * {@inheritdoc} + * @inheritDoc */ - public function isHandling(array $record): bool + public function isHandling(LogRecord $record): bool { return true; } @@ -84,29 +109,22 @@ public function isHandling(array $record): bool /** * Manually activate this logger regardless of the activation strategy */ - public function activate() + public function activate(): void { if ($this->stopBuffering) { $this->buffering = false; } - if (!$this->handler instanceof HandlerInterface) { - $record = end($this->buffer) ?: null; - $this->handler = call_user_func($this->handler, $record, $this); - if (!$this->handler instanceof HandlerInterface) { - throw new \RuntimeException("The factory callable should return a HandlerInterface"); - } - } - $this->handler->handleBatch($this->buffer); + $this->getHandler(end($this->buffer) ?: null)->handleBatch($this->buffer); $this->buffer = []; } /** - * {@inheritdoc} + * @inheritDoc */ - public function handle(array $record): bool + public function handle(LogRecord $record): bool { - if ($this->processors) { + if (\count($this->processors) > 0) { $record = $this->processRecord($record); } @@ -119,45 +137,106 @@ public function handle(array $record): bool $this->activate(); } } else { - $this->handler->handle($record); + $this->getHandler($record)->handle($record); } return false === $this->bubble; } /** - * {@inheritdoc} + * @inheritDoc + */ + public function close(): void + { + $this->flushBuffer(); + + $this->getHandler()->close(); + } + + public function reset(): void + { + $this->flushBuffer(); + + $this->resetProcessors(); + + if ($this->getHandler() instanceof ResettableInterface) { + $this->getHandler()->reset(); + } + } + + /** + * Clears the buffer without flushing any messages down to the wrapped handler. + * + * It also resets the handler to its initial buffering state. + */ + public function clear(): void + { + $this->buffer = []; + $this->reset(); + } + + /** + * Resets the state of the handler. Stops forwarding records to the wrapped handler. */ - public function close() + private function flushBuffer(): void { if (null !== $this->passthruLevel) { $level = $this->passthruLevel; $this->buffer = array_filter($this->buffer, function ($record) use ($level) { - return $record['level'] >= $level; + return $record->level >= $level; }); if (count($this->buffer) > 0) { - $this->handler->handleBatch($this->buffer); - $this->buffer = []; + $this->getHandler(end($this->buffer))->handleBatch($this->buffer); } } + + $this->buffer = []; + $this->buffering = true; } /** - * Resets the state of the handler. Stops forwarding records to the wrapped handler. + * Return the nested handler + * + * If the handler was provided as a factory, this will trigger the handler's instantiation. */ - public function reset() + public function getHandler(LogRecord $record = null): HandlerInterface { - $this->buffering = true; + if (!$this->handler instanceof HandlerInterface) { + $handler = ($this->handler)($record, $this); + if (!$handler instanceof HandlerInterface) { + throw new \RuntimeException("The factory Closure should return a HandlerInterface"); + } + $this->handler = $handler; + } + + return $this->handler; } /** - * Clears the buffer without flushing any messages down to the wrapped handler. - * - * It also resets the handler to its initial buffering state. + * @inheritDoc */ - public function clear() + public function setFormatter(FormatterInterface $formatter): HandlerInterface { - $this->buffer = []; - $this->reset(); + $handler = $this->getHandler(); + if ($handler instanceof FormattableHandlerInterface) { + $handler->setFormatter($formatter); + + return $this; + } + + throw new \UnexpectedValueException('The nested handler of type '.get_class($handler).' does not support formatters.'); + } + + /** + * @inheritDoc + */ + public function getFormatter(): FormatterInterface + { + $handler = $this->getHandler(); + if ($handler instanceof FormattableHandlerInterface) { + return $handler->getFormatter(); + } + + throw new \UnexpectedValueException('The nested handler of type '.get_class($handler).' does not support formatters.'); } } diff --git a/src/Monolog/Handler/FirePHPHandler.php b/src/Monolog/Handler/FirePHPHandler.php index 8f5c0c698..6b9e5103a 100644 --- a/src/Monolog/Handler/FirePHPHandler.php +++ b/src/Monolog/Handler/FirePHPHandler.php @@ -13,6 +13,7 @@ use Monolog\Formatter\WildfireFormatter; use Monolog\Formatter\FormatterInterface; +use Monolog\LogRecord; /** * Simple FirePHP Handler (http://www.firephp.org/), which uses the Wildfire protocol. @@ -26,46 +27,48 @@ class FirePHPHandler extends AbstractProcessingHandler /** * WildFire JSON header message format */ - const PROTOCOL_URI = 'http://meta.wildfirehq.org/Protocol/JsonStream/0.2'; + protected const PROTOCOL_URI = 'http://meta.wildfirehq.org/Protocol/JsonStream/0.2'; /** * FirePHP structure for parsing messages & their presentation */ - const STRUCTURE_URI = 'http://meta.firephp.org/Wildfire/Structure/FirePHP/FirebugConsole/0.1'; + protected const STRUCTURE_URI = 'http://meta.firephp.org/Wildfire/Structure/FirePHP/FirebugConsole/0.1'; /** * Must reference a "known" plugin, otherwise headers won't display in FirePHP */ - const PLUGIN_URI = 'http://meta.firephp.org/Wildfire/Plugin/FirePHP/Library-FirePHPCore/0.3'; + protected const PLUGIN_URI = 'http://meta.firephp.org/Wildfire/Plugin/FirePHP/Library-FirePHPCore/0.3'; /** * Header prefix for Wildfire to recognize & parse headers */ - const HEADER_PREFIX = 'X-Wf'; + protected const HEADER_PREFIX = 'X-Wf'; /** * Whether or not Wildfire vendor-specific headers have been generated & sent yet */ - protected static $initialized = false; + protected static bool $initialized = false; /** * Shared static message index between potentially multiple handlers - * @var int */ - protected static $messageIndex = 1; + protected static int $messageIndex = 1; - protected static $sendHeaders = true; + protected static bool $sendHeaders = true; /** * Base header creation function used by init headers & record headers * - * @param array $meta Wildfire Plugin, Protocol & Structure Indexes - * @param string $message Log message - * @return array Complete header string ready for the client as key and message as value + * @param array $meta Wildfire Plugin, Protocol & Structure Indexes + * @param string $message Log message + * + * @return array Complete header string ready for the client as key and message as value + * + * @phpstan-return non-empty-array */ protected function createHeader(array $meta, string $message): array { - $header = sprintf('%s-%s', self::HEADER_PREFIX, join('-', $meta)); + $header = sprintf('%s-%s', static::HEADER_PREFIX, join('-', $meta)); return [$header => $message]; } @@ -73,20 +76,24 @@ protected function createHeader(array $meta, string $message): array /** * Creates message header from record * + * @return array + * + * @phpstan-return non-empty-array + * * @see createHeader() */ - protected function createRecordHeader(array $record): array + protected function createRecordHeader(LogRecord $record): array { // Wildfire is extensible to support multiple protocols & plugins in a single request, // but we're not taking advantage of that (yet), so we're using "1" for simplicity's sake. return $this->createHeader( [1, 1, 1, self::$messageIndex++], - $record['formatted'] + $record->formatted ); } /** - * {@inheritDoc} + * @inheritDoc */ protected function getDefaultFormatter(): FormatterInterface { @@ -98,25 +105,23 @@ protected function getDefaultFormatter(): FormatterInterface * * @see createHeader() * @see sendHeader() - * @return array + * + * @return array */ - protected function getInitHeaders() + protected function getInitHeaders(): array { // Initial payload consists of required headers for Wildfire return array_merge( - $this->createHeader(['Protocol', 1], self::PROTOCOL_URI), - $this->createHeader([1, 'Structure', 1], self::STRUCTURE_URI), - $this->createHeader([1, 'Plugin', 1], self::PLUGIN_URI) + $this->createHeader(['Protocol', 1], static::PROTOCOL_URI), + $this->createHeader([1, 'Structure', 1], static::STRUCTURE_URI), + $this->createHeader([1, 'Plugin', 1], static::PLUGIN_URI) ); } /** * Send header string to the client - * - * @param string $header - * @param string $content */ - protected function sendHeader($header, $content) + protected function sendHeader(string $header, string $content): void { if (!headers_sent() && self::$sendHeaders) { header(sprintf('%s: %s', $header, $content)); @@ -128,9 +133,8 @@ protected function sendHeader($header, $content) * * @see sendHeader() * @see sendInitHeaders() - * @param array $record */ - protected function write(array $record) + protected function write(LogRecord $record): void { if (!self::$sendHeaders || !$this->isWebRequest()) { return; @@ -158,12 +162,10 @@ protected function write(array $record) /** * Verifies if the headers are accepted by the current user agent - * - * @return bool */ - protected function headersAccepted() + protected function headersAccepted(): bool { - if (!empty($_SERVER['HTTP_USER_AGENT']) && preg_match('{\bFirePHP/\d+\.\d+\b}', $_SERVER['HTTP_USER_AGENT'])) { + if (isset($_SERVER['HTTP_USER_AGENT']) && 1 === preg_match('{\bFirePHP/\d+\.\d+\b}', $_SERVER['HTTP_USER_AGENT'])) { return true; } diff --git a/src/Monolog/Handler/FleepHookHandler.php b/src/Monolog/Handler/FleepHookHandler.php index 748100b1b..9f44ba719 100644 --- a/src/Monolog/Handler/FleepHookHandler.php +++ b/src/Monolog/Handler/FleepHookHandler.php @@ -13,7 +13,8 @@ use Monolog\Formatter\FormatterInterface; use Monolog\Formatter\LineFormatter; -use Monolog\Logger; +use Monolog\Level; +use Monolog\LogRecord; /** * Sends logs to Fleep.io using Webhook integrations @@ -25,14 +26,14 @@ */ class FleepHookHandler extends SocketHandler { - const FLEEP_HOST = 'fleep.io'; + protected const FLEEP_HOST = 'fleep.io'; - const FLEEP_HOOK_URI = '/hook/'; + protected const FLEEP_HOOK_URI = '/hook/'; /** * @var string Webhook token (specifies the conversation where logs are sent) */ - protected $token; + protected string $token; /** * Construct a new Fleep.io Handler. @@ -40,21 +41,36 @@ class FleepHookHandler extends SocketHandler * For instructions on how to create a new web hook in your conversations * see https://fleep.io/integrations/webhooks/ * - * @param string $token Webhook token - * @param bool|int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not + * @param string $token Webhook token * @throws MissingExtensionException */ - public function __construct($token, $level = Logger::DEBUG, $bubble = true) - { + public function __construct( + string $token, + $level = Level::Debug, + bool $bubble = true, + bool $persistent = false, + float $timeout = 0.0, + float $writingTimeout = 10.0, + ?float $connectionTimeout = null, + ?int $chunkSize = null + ) { if (!extension_loaded('openssl')) { throw new MissingExtensionException('The OpenSSL PHP extension is required to use the FleepHookHandler'); } $this->token = $token; - $connectionString = 'ssl://' . self::FLEEP_HOST . ':443'; - parent::__construct($connectionString, $level, $bubble); + $connectionString = 'ssl://' . static::FLEEP_HOST . ':443'; + parent::__construct( + $connectionString, + $level, + $bubble, + $persistent, + $timeout, + $writingTimeout, + $connectionTimeout, + $chunkSize + ); } /** @@ -71,22 +87,17 @@ protected function getDefaultFormatter(): FormatterInterface /** * Handles a log record - * - * @param array $record */ - public function write(array $record) + public function write(LogRecord $record): void { parent::write($record); $this->closeSocket(); } /** - * {@inheritdoc} - * - * @param array $record - * @return string + * @inheritDoc */ - protected function generateDataStream($record) + protected function generateDataStream(LogRecord $record): string { $content = $this->buildContent($record); @@ -95,14 +106,11 @@ protected function generateDataStream($record) /** * Builds the header of the API Call - * - * @param string $content - * @return string */ - private function buildHeader($content) + private function buildHeader(string $content): string { - $header = "POST " . self::FLEEP_HOOK_URI . $this->token . " HTTP/1.1\r\n"; - $header .= "Host: " . self::FLEEP_HOST . "\r\n"; + $header = "POST " . static::FLEEP_HOOK_URI . $this->token . " HTTP/1.1\r\n"; + $header .= "Host: " . static::FLEEP_HOST . "\r\n"; $header .= "Content-Type: application/x-www-form-urlencoded\r\n"; $header .= "Content-Length: " . strlen($content) . "\r\n"; $header .= "\r\n"; @@ -112,14 +120,11 @@ private function buildHeader($content) /** * Builds the body of API call - * - * @param array $record - * @return string */ - private function buildContent($record) + private function buildContent(LogRecord $record): string { $dataArray = [ - 'message' => $record['formatted'], + 'message' => $record->formatted, ]; return http_build_query($dataArray); diff --git a/src/Monolog/Handler/FlowdockHandler.php b/src/Monolog/Handler/FlowdockHandler.php index 67d129189..135815485 100644 --- a/src/Monolog/Handler/FlowdockHandler.php +++ b/src/Monolog/Handler/FlowdockHandler.php @@ -11,9 +11,11 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; +use Monolog\Utils; use Monolog\Formatter\FlowdockFormatter; use Monolog\Formatter\FormatterInterface; +use Monolog\LogRecord; /** * Sends notifications through the Flowdock push API @@ -28,30 +30,40 @@ */ class FlowdockHandler extends SocketHandler { - /** - * @var string - */ - protected $apiToken; + protected string $apiToken; /** - * @param string $apiToken - * @param bool|int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not - * * @throws MissingExtensionException if OpenSSL is missing */ - public function __construct($apiToken, $level = Logger::DEBUG, $bubble = true) - { + public function __construct( + string $apiToken, + $level = Level::Debug, + bool $bubble = true, + bool $persistent = false, + float $timeout = 0.0, + float $writingTimeout = 10.0, + ?float $connectionTimeout = null, + ?int $chunkSize = null + ) { if (!extension_loaded('openssl')) { throw new MissingExtensionException('The OpenSSL PHP extension is required to use the FlowdockHandler'); } - parent::__construct('ssl://api.flowdock.com:443', $level, $bubble); + parent::__construct( + 'ssl://api.flowdock.com:443', + $level, + $bubble, + $persistent, + $timeout, + $writingTimeout, + $connectionTimeout, + $chunkSize + ); $this->apiToken = $apiToken; } /** - * {@inheritdoc} + * @inheritDoc */ public function setFormatter(FormatterInterface $formatter): HandlerInterface { @@ -64,8 +76,6 @@ public function setFormatter(FormatterInterface $formatter): HandlerInterface /** * Gets the default formatter. - * - * @suppress PhanTypeMissingReturn */ protected function getDefaultFormatter(): FormatterInterface { @@ -73,11 +83,9 @@ protected function getDefaultFormatter(): FormatterInterface } /** - * {@inheritdoc} - * - * @param array $record + * @inheritDoc */ - protected function write(array $record) + protected function write(LogRecord $record): void { parent::write($record); @@ -85,12 +93,9 @@ protected function write(array $record) } /** - * {@inheritdoc} - * - * @param array $record - * @return string + * @inheritDoc */ - protected function generateDataStream($record) + protected function generateDataStream(LogRecord $record): string { $content = $this->buildContent($record); @@ -99,22 +104,16 @@ protected function generateDataStream($record) /** * Builds the body of API call - * - * @param array $record - * @return string */ - private function buildContent($record) + private function buildContent(LogRecord $record): string { - return json_encode($record['formatted']['flowdock']); + return Utils::jsonEncode($record->formatted); } /** * Builds the header of the API Call - * - * @param string $content - * @return string */ - private function buildHeader($content) + private function buildHeader(string $content): string { $header = "POST /v1/messages/team_inbox/" . $this->apiToken . " HTTP/1.1\r\n"; $header .= "Host: api.flowdock.com\r\n"; diff --git a/src/Monolog/Handler/FormattableHandlerInterface.php b/src/Monolog/Handler/FormattableHandlerInterface.php index fc1693cd0..72da59e1c 100644 --- a/src/Monolog/Handler/FormattableHandlerInterface.php +++ b/src/Monolog/Handler/FormattableHandlerInterface.php @@ -23,15 +23,12 @@ interface FormattableHandlerInterface /** * Sets the formatter. * - * @param FormatterInterface $formatter - * @return HandlerInterface self + * @return HandlerInterface self */ public function setFormatter(FormatterInterface $formatter): HandlerInterface; /** * Gets the formatter. - * - * @return FormatterInterface */ public function getFormatter(): FormatterInterface; } diff --git a/src/Monolog/Handler/FormattableHandlerTrait.php b/src/Monolog/Handler/FormattableHandlerTrait.php index 2b7e56f94..c044e0786 100644 --- a/src/Monolog/Handler/FormattableHandlerTrait.php +++ b/src/Monolog/Handler/FormattableHandlerTrait.php @@ -21,13 +21,10 @@ */ trait FormattableHandlerTrait { - /** - * @var FormatterInterface - */ - protected $formatter; + protected FormatterInterface|null $formatter = null; /** - * {@inheritdoc} + * @inheritDoc */ public function setFormatter(FormatterInterface $formatter): HandlerInterface { @@ -37,11 +34,11 @@ public function setFormatter(FormatterInterface $formatter): HandlerInterface } /** - * {@inheritdoc} + * @inheritDoc */ public function getFormatter(): FormatterInterface { - if (!$this->formatter) { + if (null === $this->formatter) { $this->formatter = $this->getDefaultFormatter(); } @@ -51,7 +48,7 @@ public function getFormatter(): FormatterInterface /** * Gets the default formatter. * - * @return FormatterInterface + * Overwrite this if the LineFormatter is not a good default for your handler. */ protected function getDefaultFormatter(): FormatterInterface { diff --git a/src/Monolog/Handler/GelfHandler.php b/src/Monolog/Handler/GelfHandler.php index 53def8227..ba5bb975d 100644 --- a/src/Monolog/Handler/GelfHandler.php +++ b/src/Monolog/Handler/GelfHandler.php @@ -12,9 +12,10 @@ namespace Monolog\Handler; use Gelf\PublisherInterface; -use Monolog\Logger; +use Monolog\Level; use Monolog\Formatter\GelfMessageFormatter; use Monolog\Formatter\FormatterInterface; +use Monolog\LogRecord; /** * Handler to send messages to a Graylog2 (http://www.graylog2.org) server @@ -25,16 +26,14 @@ class GelfHandler extends AbstractProcessingHandler { /** - * @var PublisherInterface|null the publisher object that sends the message to the server + * @var PublisherInterface the publisher object that sends the message to the server */ - protected $publisher; + protected PublisherInterface $publisher; /** - * @param PublisherInterface $publisher a publisher object - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not + * @param PublisherInterface $publisher a gelf publisher object */ - public function __construct(PublisherInterface $publisher, $level = Logger::DEBUG, $bubble = true) + public function __construct(PublisherInterface $publisher, int|string|Level $level = Level::Debug, bool $bubble = true) { parent::__construct($level, $bubble); @@ -42,15 +41,15 @@ public function __construct(PublisherInterface $publisher, $level = Logger::DEBU } /** - * {@inheritdoc} + * @inheritDoc */ - protected function write(array $record) + protected function write(LogRecord $record): void { - $this->publisher->publish($record['formatted']); + $this->publisher->publish($record->formatted); } /** - * {@inheritDoc} + * @inheritDoc */ protected function getDefaultFormatter(): FormatterInterface { diff --git a/src/Monolog/Handler/GroupHandler.php b/src/Monolog/Handler/GroupHandler.php index 30fd68ebe..7ab8bd973 100644 --- a/src/Monolog/Handler/GroupHandler.php +++ b/src/Monolog/Handler/GroupHandler.php @@ -12,23 +12,27 @@ namespace Monolog\Handler; use Monolog\Formatter\FormatterInterface; +use Monolog\ResettableInterface; +use Monolog\LogRecord; /** * Forwards records to multiple handlers * * @author Lenar Lõhmus */ -class GroupHandler extends Handler implements ProcessableHandlerInterface +class GroupHandler extends Handler implements ProcessableHandlerInterface, ResettableInterface { use ProcessableHandlerTrait; - protected $handlers; + /** @var HandlerInterface[] */ + protected array $handlers; + protected bool $bubble; /** - * @param array $handlers Array of Handlers. - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not + * @param HandlerInterface[] $handlers Array of Handlers. + * @param bool $bubble Whether the messages that are handled can bubble up the stack or not */ - public function __construct(array $handlers, $bubble = true) + public function __construct(array $handlers, bool $bubble = true) { foreach ($handlers as $handler) { if (!$handler instanceof HandlerInterface) { @@ -41,9 +45,9 @@ public function __construct(array $handlers, $bubble = true) } /** - * {@inheritdoc} + * @inheritDoc */ - public function isHandling(array $record): bool + public function isHandling(LogRecord $record): bool { foreach ($this->handlers as $handler) { if ($handler->isHandling($record)) { @@ -55,11 +59,11 @@ public function isHandling(array $record): bool } /** - * {@inheritdoc} + * @inheritDoc */ - public function handle(array $record): bool + public function handle(LogRecord $record): bool { - if ($this->processors) { + if (\count($this->processors) > 0) { $record = $this->processRecord($record); } @@ -71,16 +75,14 @@ public function handle(array $record): bool } /** - * {@inheritdoc} + * @inheritDoc */ - public function handleBatch(array $records) + public function handleBatch(array $records): void { - if ($this->processors) { + if (\count($this->processors) > 0) { $processed = []; foreach ($records as $record) { - foreach ($this->processors as $processor) { - $processed[] = call_user_func($processor, $record); - } + $processed[] = $this->processRecord($record); } $records = $processed; } @@ -90,13 +92,35 @@ public function handleBatch(array $records) } } + public function reset(): void + { + $this->resetProcessors(); + + foreach ($this->handlers as $handler) { + if ($handler instanceof ResettableInterface) { + $handler->reset(); + } + } + } + + public function close(): void + { + parent::close(); + + foreach ($this->handlers as $handler) { + $handler->close(); + } + } + /** - * {@inheritdoc} + * @inheritDoc */ - public function setFormatter(FormatterInterface $formatter) + public function setFormatter(FormatterInterface $formatter): HandlerInterface { foreach ($this->handlers as $handler) { - $handler->setFormatter($formatter); + if ($handler instanceof FormattableHandlerInterface) { + $handler->setFormatter($formatter); + } } return $this; diff --git a/src/Monolog/Handler/Handler.php b/src/Monolog/Handler/Handler.php index 347e7b704..e89f969b8 100644 --- a/src/Monolog/Handler/Handler.php +++ b/src/Monolog/Handler/Handler.php @@ -19,9 +19,9 @@ abstract class Handler implements HandlerInterface { /** - * {@inheritdoc} + * @inheritDoc */ - public function handleBatch(array $records) + public function handleBatch(array $records): void { foreach ($records as $record) { $this->handle($record); @@ -29,9 +29,9 @@ public function handleBatch(array $records) } /** - * {@inheritdoc} + * @inheritDoc */ - public function close() + public function close(): void { } @@ -48,6 +48,15 @@ public function __sleep() { $this->close(); - return array_keys(get_object_vars($this)); + $reflClass = new \ReflectionClass($this); + + $keys = []; + foreach ($reflClass->getProperties() as $reflProp) { + if (!$reflProp->isStatic()) { + $keys[] = $reflProp->getName(); + } + } + + return $keys; } } diff --git a/src/Monolog/Handler/HandlerInterface.php b/src/Monolog/Handler/HandlerInterface.php index 8ad7e38f8..83905c323 100644 --- a/src/Monolog/Handler/HandlerInterface.php +++ b/src/Monolog/Handler/HandlerInterface.php @@ -11,6 +11,8 @@ namespace Monolog\Handler; +use Monolog\LogRecord; + /** * Interface that all Monolog Handlers must implement * @@ -27,11 +29,9 @@ interface HandlerInterface * is no guarantee that handle() will not be called, and isHandling() might not be called * for a given record. * - * @param array $record Partial log record containing only a level key - * - * @return bool + * @param LogRecord $record Partial log record having only a level initialized */ - public function isHandling(array $record): bool; + public function isHandling(LogRecord $record): bool; /** * Handles a record. @@ -43,26 +43,34 @@ public function isHandling(array $record): bool; * Unless the bubbling is interrupted (by returning true), the Logger class will keep on * calling further handlers in the stack with a given log record. * - * @param array $record The record to handle - * @return bool true means that this handler handled the record, and that bubbling is not permitted. - * false means the record was either not processed or that this handler allows bubbling. + * @param LogRecord $record The record to handle + * @return bool true means that this handler handled the record, and that bubbling is not permitted. + * false means the record was either not processed or that this handler allows bubbling. */ - public function handle(array $record): bool; + public function handle(LogRecord $record): bool; /** * Handles a set of records at once. * - * @param array $records The records to handle (an array of record arrays) + * @param array $records The records to handle */ - public function handleBatch(array $records); + public function handleBatch(array $records): void; /** * Closes the handler. * - * This will be called automatically when the object is destroyed if you extend Monolog\Handler\Handler + * Ends a log cycle and frees all resources used by the handler. + * + * Closing a Handler means flushing all buffers and freeing any open resources/handles. * * Implementations have to be idempotent (i.e. it should be possible to call close several times without breakage) * and ideally handlers should be able to reopen themselves on handle() after they have been closed. + * + * This is useful at the end of a request and will be called automatically when the object + * is destroyed if you extend Monolog\Handler\Handler. + * + * If you are thinking of calling this method yourself, most likely you should be + * calling ResettableInterface::reset instead. Have a look. */ - public function close(); + public function close(): void; } diff --git a/src/Monolog/Handler/HandlerWrapper.php b/src/Monolog/Handler/HandlerWrapper.php index 28fe57d6c..541ec2541 100644 --- a/src/Monolog/Handler/HandlerWrapper.php +++ b/src/Monolog/Handler/HandlerWrapper.php @@ -11,7 +11,9 @@ namespace Monolog\Handler; +use Monolog\ResettableInterface; use Monolog\Formatter\FormatterInterface; +use Monolog\LogRecord; /** * This simple wrapper class can be used to extend handlers functionality. @@ -20,7 +22,7 @@ * * Inherit from this class and override handle() like this: * - * public function handle(array $record) + * public function handle(LogRecord $record) * { * if ($record meets certain conditions) { * return false; @@ -30,56 +32,49 @@ * * @author Alexey Karapetov */ -class HandlerWrapper implements HandlerInterface, ProcessableHandlerInterface, FormattableHandlerInterface +class HandlerWrapper implements HandlerInterface, ProcessableHandlerInterface, FormattableHandlerInterface, ResettableInterface { - /** - * @var HandlerInterface - */ - protected $handler; + protected HandlerInterface $handler; - /** - * HandlerWrapper constructor. - * @param HandlerInterface $handler - */ public function __construct(HandlerInterface $handler) { $this->handler = $handler; } /** - * {@inheritdoc} + * @inheritDoc */ - public function isHandling(array $record): bool + public function isHandling(LogRecord $record): bool { return $this->handler->isHandling($record); } /** - * {@inheritdoc} + * @inheritDoc */ - public function handle(array $record): bool + public function handle(LogRecord $record): bool { return $this->handler->handle($record); } /** - * {@inheritdoc} + * @inheritDoc */ - public function handleBatch(array $records) + public function handleBatch(array $records): void { - return $this->handler->handleBatch($records); + $this->handler->handleBatch($records); } /** - * {@inheritdoc} + * @inheritDoc */ - public function close() + public function close(): void { - return $this->handler->close(); + $this->handler->close(); } /** - * {@inheritdoc} + * @inheritDoc */ public function pushProcessor(callable $callback): HandlerInterface { @@ -93,7 +88,7 @@ public function pushProcessor(callable $callback): HandlerInterface } /** - * {@inheritdoc} + * @inheritDoc */ public function popProcessor(): callable { @@ -105,26 +100,35 @@ public function popProcessor(): callable } /** - * {@inheritdoc} + * @inheritDoc */ public function setFormatter(FormatterInterface $formatter): HandlerInterface { if ($this->handler instanceof FormattableHandlerInterface) { $this->handler->setFormatter($formatter); + + return $this; } throw new \LogicException('The wrapped handler does not implement ' . FormattableHandlerInterface::class); } /** - * {@inheritdoc} + * @inheritDoc */ public function getFormatter(): FormatterInterface { if ($this->handler instanceof FormattableHandlerInterface) { - return $this->handler->getFormatter($formatter); + return $this->handler->getFormatter(); } throw new \LogicException('The wrapped handler does not implement ' . FormattableHandlerInterface::class); } + + public function reset(): void + { + if ($this->handler instanceof ResettableInterface) { + $this->handler->reset(); + } + } } diff --git a/src/Monolog/Handler/HipChatHandler.php b/src/Monolog/Handler/HipChatHandler.php deleted file mode 100644 index 87286e3f3..000000000 --- a/src/Monolog/Handler/HipChatHandler.php +++ /dev/null @@ -1,334 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Monolog\Handler; - -use Monolog\Logger; - -/** - * Sends notifications through the hipchat api to a hipchat room - * - * This handler only supports the API v2 - * - * Notes: - * API token - HipChat API token - * Room - HipChat Room Id or name, where messages are sent - * Name - Name used to send the message (from) - * notify - Should the message trigger a notification in the clients - * - * @author Rafael Dohms - * @see https://www.hipchat.com/docs/api - */ -class HipChatHandler extends SocketHandler -{ - /** - * The maximum allowed length for the name used in the "from" field. - */ - const MAXIMUM_NAME_LENGTH = 15; - - /** - * The maximum allowed length for the message. - */ - const MAXIMUM_MESSAGE_LENGTH = 9500; - - /** - * @var string - */ - private $token; - - /** - * @var string - */ - private $room; - - /** - * @var string - */ - private $name; - - /** - * @var bool - */ - private $notify; - - /** - * @var string - */ - private $format; - - /** - * @var string - */ - private $host; - - /** - * @param string $token HipChat API Token - * @param string $room The room that should be alerted of the message (Id or Name) - * @param string $name Name used in the "from" field. - * @param bool $notify Trigger a notification in clients or not - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not - * @param bool $useSSL Whether to connect via SSL. - * @param string $format The format of the messages (default to text, can be set to html if you have html in the messages) - * @param string $host The HipChat server hostname. - */ - public function __construct($token, $room, $name = 'Monolog', $notify = false, $level = Logger::CRITICAL, $bubble = true, $useSSL = true, $format = 'text', $host = 'api.hipchat.com') - { - $connectionString = $useSSL ? 'ssl://'.$host.':443' : $host.':80'; - parent::__construct($connectionString, $level, $bubble); - - $this->token = $token; - $this->name = $name; - $this->notify = $notify; - $this->room = $room; - $this->format = $format; - $this->host = $host; - } - - /** - * {@inheritdoc} - * - * @param array $record - * @return string - */ - protected function generateDataStream($record) - { - $content = $this->buildContent($record); - - return $this->buildHeader($content) . $content; - } - - /** - * Builds the body of API call - * - * @param array $record - * @return string - */ - private function buildContent($record) - { - $dataArray = [ - 'notify' => $this->notify ? 'true' : 'false', - 'message' => $record['formatted'], - 'message_format' => $this->format, - 'color' => $this->getAlertColor($record['level']), - ]; - - if (!$this->validateStringLength($dataArray['message'], static::MAXIMUM_MESSAGE_LENGTH)) { - if (function_exists('mb_substr')) { - $dataArray['message'] = mb_substr($dataArray['message'], 0, static::MAXIMUM_MESSAGE_LENGTH).' [truncated]'; - } else { - $dataArray['message'] = substr($dataArray['message'], 0, static::MAXIMUM_MESSAGE_LENGTH).' [truncated]'; - } - } - - // append the sender name if it is set - // always append it if we use the v1 api (it is required in v1) - if ($this->name !== null) { - $dataArray['from'] = (string) $this->name; - } - - return http_build_query($dataArray); - } - - /** - * Builds the header of the API Call - * - * @param string $content - * @return string - */ - private function buildHeader($content) - { - // needed for rooms with special (spaces, etc) characters in the name - $room = rawurlencode($this->room); - $header = "POST /v2/room/{$room}/notification?auth_token={$this->token} HTTP/1.1\r\n"; - - $header .= "Host: {$this->host}\r\n"; - $header .= "Content-Type: application/x-www-form-urlencoded\r\n"; - $header .= "Content-Length: " . strlen($content) . "\r\n"; - $header .= "\r\n"; - - return $header; - } - - /** - * Assigns a color to each level of log records. - * - * @param int $level - * @return string - */ - protected function getAlertColor($level) - { - switch (true) { - case $level >= Logger::ERROR: - return 'red'; - case $level >= Logger::WARNING: - return 'yellow'; - case $level >= Logger::INFO: - return 'green'; - case $level == Logger::DEBUG: - return 'gray'; - default: - return 'yellow'; - } - } - - /** - * {@inheritdoc} - * - * @param array $record - */ - protected function write(array $record) - { - parent::write($record); - $this->finalizeWrite(); - } - - /** - * Finalizes the request by reading some bytes and then closing the socket - * - * If we do not read some but close the socket too early, hipchat sometimes - * drops the request entirely. - */ - protected function finalizeWrite() - { - $res = $this->getResource(); - if (is_resource($res)) { - @fread($res, 2048); - } - $this->closeSocket(); - } - - /** - * {@inheritdoc} - */ - public function handleBatch(array $records) - { - if (count($records) == 0) { - return true; - } - - $batchRecords = $this->combineRecords($records); - - $handled = false; - foreach ($batchRecords as $batchRecord) { - if ($this->isHandling($batchRecord)) { - $this->write($batchRecord); - $handled = true; - } - } - - if (!$handled) { - return false; - } - - return false === $this->bubble; - } - - /** - * Combines multiple records into one. Error level of the combined record - * will be the highest level from the given records. Datetime will be taken - * from the first record. - * - * @param $records - * @return array - */ - private function combineRecords($records) - { - $batchRecord = null; - $batchRecords = []; - $messages = []; - $formattedMessages = []; - $level = 0; - $levelName = null; - $datetime = null; - - foreach ($records as $record) { - $record = $this->processRecord($record); - - if ($record['level'] > $level) { - $level = $record['level']; - $levelName = $record['level_name']; - } - - if (null === $datetime) { - $datetime = $record['datetime']; - } - - $messages[] = $record['message']; - $messageStr = implode(PHP_EOL, $messages); - $formattedMessages[] = $this->getFormatter()->format($record); - $formattedMessageStr = implode('', $formattedMessages); - - $batchRecord = [ - 'message' => $messageStr, - 'formatted' => $formattedMessageStr, - 'context' => [], - 'extra' => [], - ]; - - if (!$this->validateStringLength($batchRecord['formatted'], static::MAXIMUM_MESSAGE_LENGTH)) { - // Pop the last message and implode the remaining messages - $lastMessage = array_pop($messages); - $lastFormattedMessage = array_pop($formattedMessages); - $batchRecord['message'] = implode(PHP_EOL, $messages); - $batchRecord['formatted'] = implode('', $formattedMessages); - - $batchRecords[] = $batchRecord; - $messages = [$lastMessage]; - $formattedMessages = [$lastFormattedMessage]; - - $batchRecord = null; - } - } - - if (null !== $batchRecord) { - $batchRecords[] = $batchRecord; - } - - // Set the max level and datetime for all records - foreach ($batchRecords as &$batchRecord) { - $batchRecord = array_merge( - $batchRecord, - [ - 'level' => $level, - 'level_name' => $levelName, - 'datetime' => $datetime, - ] - ); - } - - return $batchRecords; - } - - /** - * Validates the length of a string. - * - * If the `mb_strlen()` function is available, it will use that, as HipChat - * allows UTF-8 characters. Otherwise, it will fall back to `strlen()`. - * - * Note that this might cause false failures in the specific case of using - * a valid name with less than 16 characters, but 16 or more bytes, on a - * system where `mb_strlen()` is unavailable. - * - * @param string $str - * @param int $length - * - * @return bool - */ - private function validateStringLength($str, $length) - { - if (function_exists('mb_strlen')) { - return (mb_strlen($str) <= $length); - } - - return (strlen($str) <= $length); - } -} diff --git a/src/Monolog/Handler/IFTTTHandler.php b/src/Monolog/Handler/IFTTTHandler.php index f5d440dfa..ee7f81f6a 100644 --- a/src/Monolog/Handler/IFTTTHandler.php +++ b/src/Monolog/Handler/IFTTTHandler.php @@ -11,7 +11,9 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; +use Monolog\Utils; +use Monolog\LogRecord; /** * IFTTTHandler uses cURL to trigger IFTTT Maker actions @@ -26,17 +28,19 @@ */ class IFTTTHandler extends AbstractProcessingHandler { - private $eventName; - private $secretKey; + private string $eventName; + private string $secretKey; /** * @param string $eventName The name of the IFTTT Maker event that should be triggered * @param string $secretKey A valid IFTTT secret key - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not */ - public function __construct($eventName, $secretKey, $level = Logger::ERROR, $bubble = true) + public function __construct(string $eventName, string $secretKey, int|string|Level $level = Level::Error, bool $bubble = true) { + if (!extension_loaded('curl')) { + throw new MissingExtensionException('The curl extension is needed to use the IFTTTHandler'); + } + $this->eventName = $eventName; $this->secretKey = $secretKey; @@ -44,16 +48,16 @@ public function __construct($eventName, $secretKey, $level = Logger::ERROR, $bub } /** - * {@inheritdoc} + * @inheritDoc */ - public function write(array $record) + public function write(LogRecord $record): void { $postData = [ - "value1" => $record["channel"], + "value1" => $record->channel, "value2" => $record["level_name"], - "value3" => $record["message"], + "value3" => $record->message, ]; - $postString = json_encode($postData); + $postString = Utils::jsonEncode($postData); $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, "https://maker.ifttt.com/trigger/" . $this->eventName . "/with/key/" . $this->secretKey); diff --git a/src/Monolog/Handler/InsightOpsHandler.php b/src/Monolog/Handler/InsightOpsHandler.php index bd6dfe609..abb2f88f7 100644 --- a/src/Monolog/Handler/InsightOpsHandler.php +++ b/src/Monolog/Handler/InsightOpsHandler.php @@ -11,9 +11,10 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; +use Monolog\LogRecord; - /** +/** * Inspired on LogEntriesHandler. * * @author Robert Kaufmann III @@ -21,42 +22,53 @@ */ class InsightOpsHandler extends SocketHandler { - /** - * @var string - */ - protected $logToken; + protected string $logToken; /** * @param string $token Log token supplied by InsightOps * @param string $region Region where InsightOps account is hosted. Could be 'us' or 'eu'. * @param bool $useSSL Whether or not SSL encryption should be used - * @param int $level The minimum logging level to trigger this handler - * @param bool $bubble Whether or not messages that are handled should bubble up the stack. * * @throws MissingExtensionException If SSL encryption is set to true and OpenSSL is missing */ - public function __construct($token, $region = 'us', $useSSL = true, $level = Logger::DEBUG, $bubble = true) - { + public function __construct( + string $token, + string $region = 'us', + bool $useSSL = true, + $level = Level::Debug, + bool $bubble = true, + bool $persistent = false, + float $timeout = 0.0, + float $writingTimeout = 10.0, + ?float $connectionTimeout = null, + ?int $chunkSize = null + ) { if ($useSSL && !extension_loaded('openssl')) { - throw new MissingExtensionException('The OpenSSL PHP plugin is required to use SSL encrypted connection for LogEntriesHandler'); + throw new MissingExtensionException('The OpenSSL PHP plugin is required to use SSL encrypted connection for InsightOpsHandler'); } $endpoint = $useSSL ? 'ssl://' . $region . '.data.logs.insight.rapid7.com:443' : $region . '.data.logs.insight.rapid7.com:80'; - parent::__construct($endpoint, $level, $bubble); + parent::__construct( + $endpoint, + $level, + $bubble, + $persistent, + $timeout, + $writingTimeout, + $connectionTimeout, + $chunkSize + ); $this->logToken = $token; } /** - * {@inheritdoc} - * - * @param array $record - * @return string + * @inheritDoc */ - protected function generateDataStream($record) + protected function generateDataStream(LogRecord $record): string { - return $this->logToken . ' ' . $record['formatted']; + return $this->logToken . ' ' . $record->formatted; } } diff --git a/src/Monolog/Handler/LogEntriesHandler.php b/src/Monolog/Handler/LogEntriesHandler.php index c74b9d2ee..00259834e 100644 --- a/src/Monolog/Handler/LogEntriesHandler.php +++ b/src/Monolog/Handler/LogEntriesHandler.php @@ -11,45 +11,58 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; +use Monolog\LogRecord; /** * @author Robert Kaufmann III */ class LogEntriesHandler extends SocketHandler { - /** - * @var string - */ - protected $logToken; + protected string $logToken; /** * @param string $token Log token supplied by LogEntries * @param bool $useSSL Whether or not SSL encryption should be used. - * @param int $level The minimum logging level to trigger this handler - * @param bool $bubble Whether or not messages that are handled should bubble up the stack. + * @param string $host Custom hostname to send the data to if needed * * @throws MissingExtensionException If SSL encryption is set to true and OpenSSL is missing */ - public function __construct($token, $useSSL = true, $level = Logger::DEBUG, $bubble = true) - { + public function __construct( + string $token, + bool $useSSL = true, + $level = Level::Debug, + bool $bubble = true, + string $host = 'data.logentries.com', + bool $persistent = false, + float $timeout = 0.0, + float $writingTimeout = 10.0, + ?float $connectionTimeout = null, + ?int $chunkSize = null + ) { if ($useSSL && !extension_loaded('openssl')) { throw new MissingExtensionException('The OpenSSL PHP plugin is required to use SSL encrypted connection for LogEntriesHandler'); } - $endpoint = $useSSL ? 'ssl://data.logentries.com:443' : 'data.logentries.com:80'; - parent::__construct($endpoint, $level, $bubble); + $endpoint = $useSSL ? 'ssl://' . $host . ':443' : $host . ':80'; + parent::__construct( + $endpoint, + $level, + $bubble, + $persistent, + $timeout, + $writingTimeout, + $connectionTimeout, + $chunkSize + ); $this->logToken = $token; } /** - * {@inheritdoc} - * - * @param array $record - * @return string + * @inheritDoc */ - protected function generateDataStream($record) + protected function generateDataStream(LogRecord $record): string { - return $this->logToken . ' ' . $record['formatted']; + return $this->logToken . ' ' . $record->formatted; } } diff --git a/src/Monolog/Handler/LogglyHandler.php b/src/Monolog/Handler/LogglyHandler.php index 544e7c7f7..2d8e66f18 100644 --- a/src/Monolog/Handler/LogglyHandler.php +++ b/src/Monolog/Handler/LogglyHandler.php @@ -11,9 +11,12 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; use Monolog\Formatter\FormatterInterface; use Monolog\Formatter\LogglyFormatter; +use function array_key_exists; +use CurlHandle; +use Monolog\LogRecord; /** * Sends errors to Loggly. @@ -24,18 +27,31 @@ */ class LogglyHandler extends AbstractProcessingHandler { - const HOST = 'logs-01.loggly.com'; - const ENDPOINT_SINGLE = 'inputs'; - const ENDPOINT_BATCH = 'bulk'; - - protected $token; - - protected $tag = []; - - public function __construct($token, $level = Logger::DEBUG, $bubble = true) + protected const HOST = 'logs-01.loggly.com'; + protected const ENDPOINT_SINGLE = 'inputs'; + protected const ENDPOINT_BATCH = 'bulk'; + + /** + * Caches the curl handlers for every given endpoint. + * + * @var CurlHandle[] + */ + protected array $curlHandlers = []; + + protected string $token; + + /** @var string[] */ + protected array $tag = []; + + /** + * @param string $token API token supplied by Loggly + * + * @throws MissingExtensionException If the curl extension is missing + */ + public function __construct(string $token, int|string|Level $level = Level::Debug, bool $bubble = true) { if (!extension_loaded('curl')) { - throw new \LogicException('The curl extension is needed to use the LogglyHandler'); + throw new MissingExtensionException('The curl extension is needed to use the LogglyHandler'); } $this->token = $token; @@ -43,57 +59,93 @@ public function __construct($token, $level = Logger::DEBUG, $bubble = true) parent::__construct($level, $bubble); } - public function setTag($tag) + /** + * Loads and returns the shared curl handler for the given endpoint. + */ + protected function getCurlHandler(string $endpoint): CurlHandle + { + if (!array_key_exists($endpoint, $this->curlHandlers)) { + $this->curlHandlers[$endpoint] = $this->loadCurlHandle($endpoint); + } + + return $this->curlHandlers[$endpoint]; + } + + /** + * Starts a fresh curl session for the given endpoint and returns its handler. + */ + private function loadCurlHandle(string $endpoint): CurlHandle + { + $url = sprintf("https://%s/%s/%s/", static::HOST, $endpoint, $this->token); + + $ch = curl_init(); + + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + return $ch; + } + + /** + * @param string[]|string $tag + */ + public function setTag(string|array $tag): self { - $tag = !empty($tag) ? $tag : []; - $this->tag = is_array($tag) ? $tag : [$tag]; + if ('' === $tag || [] === $tag) { + $this->tag = []; + } else { + $this->tag = is_array($tag) ? $tag : [$tag]; + } + + return $this; } - public function addTag($tag) + /** + * @param string[]|string $tag + */ + public function addTag(string|array $tag): self { - if (!empty($tag)) { + if ('' !== $tag) { $tag = is_array($tag) ? $tag : [$tag]; $this->tag = array_unique(array_merge($this->tag, $tag)); } + + return $this; } - protected function write(array $record) + protected function write(LogRecord $record): void { - $this->send($record["formatted"], self::ENDPOINT_SINGLE); + $this->send($record->formatted, static::ENDPOINT_SINGLE); } - public function handleBatch(array $records) + public function handleBatch(array $records): void { $level = $this->level; $records = array_filter($records, function ($record) use ($level) { - return ($record['level'] >= $level); + return ($record->level >= $level); }); - if ($records) { - $this->send($this->getFormatter()->formatBatch($records), self::ENDPOINT_BATCH); + if (\count($records) > 0) { + $this->send($this->getFormatter()->formatBatch($records), static::ENDPOINT_BATCH); } } - protected function send($data, $endpoint) + protected function send(string $data, string $endpoint): void { - $url = sprintf("https://%s/%s/%s/", self::HOST, $endpoint, $this->token); + $ch = $this->getCurlHandler($endpoint); $headers = ['Content-Type: application/json']; - if (!empty($this->tag)) { + if (\count($this->tag) > 0) { $headers[] = 'X-LOGGLY-TAG: '.implode(',', $this->tag); } - $ch = curl_init(); - - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $data); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - Curl\Util::execute($ch); + Curl\Util::execute($ch, 5, false); } protected function getDefaultFormatter(): FormatterInterface diff --git a/src/Monolog/Handler/LogmaticHandler.php b/src/Monolog/Handler/LogmaticHandler.php index 97472204a..876b1a953 100644 --- a/src/Monolog/Handler/LogmaticHandler.php +++ b/src/Monolog/Handler/LogmaticHandler.php @@ -11,42 +11,43 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; use Monolog\Formatter\FormatterInterface; use Monolog\Formatter\LogmaticFormatter; +use Monolog\LogRecord; /** * @author Julien Breux */ class LogmaticHandler extends SocketHandler { - /** - * @var string - */ - private $logToken; + private string $logToken; - /** - * @var string - */ - private $hostname; + private string $hostname; - /** - * @var string - */ - private $appname; + private string $appName; /** - * @param string $token Log token supplied by Logmatic. - * @param string $hostname Host name supplied by Logmatic. - * @param string $appname Application name supplied by Logmatic. - * @param bool $useSSL Whether or not SSL encryption should be used. - * @param int|string $level The minimum logging level to trigger this handler. - * @param bool $bubble Whether or not messages that are handled should bubble up the stack. + * @param string $token Log token supplied by Logmatic. + * @param string $hostname Host name supplied by Logmatic. + * @param string $appName Application name supplied by Logmatic. + * @param bool $useSSL Whether or not SSL encryption should be used. * * @throws MissingExtensionException If SSL encryption is set to true and OpenSSL is missing */ - public function __construct(string $token, string $hostname = '', string $appname = '', bool $useSSL = true, $level = Logger::DEBUG, bool $bubble = true) - { + public function __construct( + string $token, + string $hostname = '', + string $appName = '', + bool $useSSL = true, + $level = Level::Debug, + bool $bubble = true, + bool $persistent = false, + float $timeout = 0.0, + float $writingTimeout = 10.0, + ?float $connectionTimeout = null, + ?int $chunkSize = null + ) { if ($useSSL && !extension_loaded('openssl')) { throw new MissingExtensionException('The OpenSSL PHP extension is required to use SSL encrypted connection for LogmaticHandler'); } @@ -54,33 +55,42 @@ public function __construct(string $token, string $hostname = '', string $appnam $endpoint = $useSSL ? 'ssl://api.logmatic.io:10515' : 'api.logmatic.io:10514'; $endpoint .= '/v1/'; - parent::__construct($endpoint, $level, $bubble); + parent::__construct( + $endpoint, + $level, + $bubble, + $persistent, + $timeout, + $writingTimeout, + $connectionTimeout, + $chunkSize + ); $this->logToken = $token; $this->hostname = $hostname; - $this->appname = $appname; + $this->appName = $appName; } /** - * {@inheritdoc} + * @inheritDoc */ - protected function generateDataStream($record): string + protected function generateDataStream(LogRecord $record): string { - return $this->logToken . ' ' . $record['formatted']; + return $this->logToken . ' ' . $record->formatted; } /** - * {@inheritdoc} + * @inheritDoc */ protected function getDefaultFormatter(): FormatterInterface { $formatter = new LogmaticFormatter(); - if (!empty($this->hostname)) { + if ($this->hostname !== '') { $formatter->setHostname($this->hostname); } - if (!empty($this->appname)) { - $formatter->setAppname($this->appname); + if ($this->appName !== '') { + $formatter->setAppName($this->appName); } return $formatter; diff --git a/src/Monolog/Handler/MailHandler.php b/src/Monolog/Handler/MailHandler.php index 634fbc193..b6c822772 100644 --- a/src/Monolog/Handler/MailHandler.php +++ b/src/Monolog/Handler/MailHandler.php @@ -13,6 +13,7 @@ use Monolog\Formatter\FormatterInterface; use Monolog\Formatter\HtmlFormatter; +use Monolog\LogRecord; /** * Base class for all mail handlers @@ -22,20 +23,22 @@ abstract class MailHandler extends AbstractProcessingHandler { /** - * {@inheritdoc} + * @inheritDoc */ - public function handleBatch(array $records) + public function handleBatch(array $records): void { $messages = []; foreach ($records as $record) { - if ($record['level'] < $this->level) { + if ($record->level->isLowerThan($this->level)) { continue; } - $messages[] = $this->processRecord($record); + + $message = $this->processRecord($record); + $messages[] = $message; } - if (!empty($messages)) { + if (\count($messages) > 0) { $this->send((string) $this->getFormatter()->formatBatch($messages), $messages); } } @@ -45,22 +48,27 @@ public function handleBatch(array $records) * * @param string $content formatted email body to be sent * @param array $records the array of log records that formed this content + * + * @phpstan-param non-empty-array $records */ - abstract protected function send(string $content, array $records); + abstract protected function send(string $content, array $records): void; /** - * {@inheritdoc} + * @inheritDoc */ - protected function write(array $record) + protected function write(LogRecord $record): void { - $this->send((string) $record['formatted'], [$record]); + $this->send((string) $record->formatted, [$record]); } - protected function getHighestRecord(array $records) + /** + * @phpstan-param non-empty-array $records + */ + protected function getHighestRecord(array $records): LogRecord { $highestRecord = null; foreach ($records as $record) { - if ($highestRecord === null || $highestRecord['level'] < $record['level']) { + if ($highestRecord === null || $record->level->isHigherThan($highestRecord->level)) { $highestRecord = $record; } } @@ -68,15 +76,13 @@ protected function getHighestRecord(array $records) return $highestRecord; } - protected function isHtmlBody($body) + protected function isHtmlBody(string $body): bool { - return substr($body, 0, 1) === '<'; + return ($body[0] ?? null) === '<'; } /** * Gets the default formatter. - * - * @return FormatterInterface */ protected function getDefaultFormatter(): FormatterInterface { diff --git a/src/Monolog/Handler/MandrillHandler.php b/src/Monolog/Handler/MandrillHandler.php index 6cf90e7f7..0f923bc52 100644 --- a/src/Monolog/Handler/MandrillHandler.php +++ b/src/Monolog/Handler/MandrillHandler.php @@ -11,7 +11,9 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; +use Swift; +use Swift_Message; /** * MandrillHandler uses cURL to send the emails to the Mandrill API @@ -20,23 +22,23 @@ */ class MandrillHandler extends MailHandler { - protected $message; - protected $apiKey; + protected Swift_Message $message; + protected string $apiKey; /** - * @param string $apiKey A valid Mandrill API key - * @param callable|\Swift_Message $message An example message for real messages, only the body will be replaced - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not + * @phpstan-param (Swift_Message|callable(): Swift_Message) $message + * + * @param string $apiKey A valid Mandrill API key + * @param callable|Swift_Message $message An example message for real messages, only the body will be replaced */ - public function __construct($apiKey, $message, $level = Logger::ERROR, $bubble = true) + public function __construct(string $apiKey, callable|Swift_Message $message, int|string|Level $level = Level::Error, bool $bubble = true) { parent::__construct($level, $bubble); - if (!$message instanceof \Swift_Message && is_callable($message)) { - $message = call_user_func($message); + if (!$message instanceof Swift_Message) { + $message = $message(); } - if (!$message instanceof \Swift_Message) { + if (!$message instanceof Swift_Message) { throw new \InvalidArgumentException('You must provide either a Swift_Message instance or a callable returning it'); } $this->message = $message; @@ -44,18 +46,24 @@ public function __construct($apiKey, $message, $level = Logger::ERROR, $bubble = } /** - * {@inheritdoc} + * @inheritDoc */ - protected function send(string $content, array $records) + protected function send(string $content, array $records): void { - $mime = null; + $mime = 'text/plain'; if ($this->isHtmlBody($content)) { $mime = 'text/html'; } $message = clone $this->message; $message->setBody($content, $mime); - $message->setDate(time()); + /** @phpstan-ignore-next-line */ + if (version_compare(Swift::VERSION, '6.0.0', '>=')) { + $message->setDate(new \DateTimeImmutable()); + } else { + /** @phpstan-ignore-next-line */ + $message->setDate(time()); + } $ch = curl_init(); diff --git a/src/Monolog/Handler/MissingExtensionException.php b/src/Monolog/Handler/MissingExtensionException.php index 1554b3453..3965aeea5 100644 --- a/src/Monolog/Handler/MissingExtensionException.php +++ b/src/Monolog/Handler/MissingExtensionException.php @@ -12,9 +12,9 @@ namespace Monolog\Handler; /** - * Exception can be thrown if an extension for an handler is missing + * Exception can be thrown if an extension for a handler is missing * - * @author Christian Bergau + * @author Christian Bergau */ class MissingExtensionException extends \Exception { diff --git a/src/Monolog/Handler/MongoDBHandler.php b/src/Monolog/Handler/MongoDBHandler.php index 5415ee525..33ab68c6d 100644 --- a/src/Monolog/Handler/MongoDBHandler.php +++ b/src/Monolog/Handler/MongoDBHandler.php @@ -14,9 +14,10 @@ use MongoDB\Driver\BulkWrite; use MongoDB\Driver\Manager; use MongoDB\Client; -use Monolog\Logger; +use Monolog\Level; use Monolog\Formatter\FormatterInterface; use Monolog\Formatter\MongoDBFormatter; +use Monolog\LogRecord; /** * Logs to a MongoDB database. @@ -33,9 +34,11 @@ */ class MongoDBHandler extends AbstractProcessingHandler { - private $collection; - private $manager; - private $namespace; + private \MongoDB\Collection $collection; + + private Client|Manager $manager; + + private string|null $namespace = null; /** * Constructor. @@ -43,15 +46,9 @@ class MongoDBHandler extends AbstractProcessingHandler * @param Client|Manager $mongodb MongoDB library or driver client * @param string $database Database name * @param string $collection Collection name - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not */ - public function __construct($mongodb, $database, $collection, $level = Logger::DEBUG, $bubble = true) + public function __construct(Client|Manager $mongodb, string $database, string $collection, int|string|Level $level = Level::Debug, bool $bubble = true) { - if (!($mongodb instanceof Client || $mongodb instanceof Manager)) { - throw new \InvalidArgumentException('MongoDB\Client or MongoDB\Driver\Manager instance required'); - } - if ($mongodb instanceof Client) { $this->collection = $mongodb->selectCollection($database, $collection); } else { @@ -62,21 +59,21 @@ public function __construct($mongodb, $database, $collection, $level = Logger::D parent::__construct($level, $bubble); } - protected function write(array $record) + protected function write(LogRecord $record): void { if (isset($this->collection)) { - $this->collection->insertOne($record['formatted']); + $this->collection->insertOne($record->formatted); } if (isset($this->manager, $this->namespace)) { $bulk = new BulkWrite; - $bulk->insert($record["formatted"]); + $bulk->insert($record->formatted); $this->manager->executeBulkWrite($this->namespace, $bulk); } } /** - * {@inheritDoc} + * @inheritDoc */ protected function getDefaultFormatter(): FormatterInterface { diff --git a/src/Monolog/Handler/NativeMailerHandler.php b/src/Monolog/Handler/NativeMailerHandler.php index b597761e7..d4c9d8010 100644 --- a/src/Monolog/Handler/NativeMailerHandler.php +++ b/src/Monolog/Handler/NativeMailerHandler.php @@ -11,7 +11,7 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; use Monolog\Formatter\LineFormatter; /** @@ -24,55 +24,49 @@ class NativeMailerHandler extends MailHandler { /** * The email addresses to which the message will be sent - * @var array + * @var string[] */ - protected $to; + protected array $to; /** * The subject of the email - * @var string */ - protected $subject; + protected string $subject; /** * Optional headers for the message - * @var array + * @var string[] */ - protected $headers = []; + protected array $headers = []; /** * Optional parameters for the message - * @var array + * @var string[] */ - protected $parameters = []; + protected array $parameters = []; /** * The wordwrap length for the message - * @var int */ - protected $maxColumnWidth; + protected int $maxColumnWidth; /** * The Content-type for the message - * @var string */ - protected $contentType; + protected string|null $contentType = null; /** * The encoding for the message - * @var string */ - protected $encoding = 'utf-8'; + protected string $encoding = 'utf-8'; /** - * @param string|array $to The receiver of the mail - * @param string $subject The subject of the mail - * @param string $from The sender of the mail - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not - * @param int $maxColumnWidth The maximum column width that the message lines will have + * @param string|string[] $to The receiver of the mail + * @param string $subject The subject of the mail + * @param string $from The sender of the mail + * @param int $maxColumnWidth The maximum column width that the message lines will have */ - public function __construct($to, $subject, $from, $level = Logger::ERROR, $bubble = true, $maxColumnWidth = 70) + public function __construct(string|array $to, string $subject, string $from, int|string|Level $level = Level::Error, bool $bubble = true, int $maxColumnWidth = 70) { parent::__construct($level, $bubble); $this->to = (array) $to; @@ -84,10 +78,9 @@ public function __construct($to, $subject, $from, $level = Logger::ERROR, $bubbl /** * Add headers to the message * - * @param string|array $headers Custom added headers - * @return self + * @param string|string[] $headers Custom added headers */ - public function addHeader($headers) + public function addHeader($headers): self { foreach ((array) $headers as $header) { if (strpos($header, "\n") !== false || strpos($header, "\r") !== false) { @@ -102,10 +95,9 @@ public function addHeader($headers) /** * Add parameters to the message * - * @param string|array $parameters Custom added parameters - * @return self + * @param string|string[] $parameters Custom added parameters */ - public function addParameter($parameters) + public function addParameter($parameters): self { $this->parameters = array_merge($this->parameters, (array) $parameters); @@ -113,11 +105,11 @@ public function addParameter($parameters) } /** - * {@inheritdoc} + * @inheritDoc */ - protected function send(string $content, array $records) + protected function send(string $content, array $records): void { - $contentType = $this->getContentType() ?: ($this->isHtmlBody($content) ? 'text/html' : 'text/plain'); + $contentType = $this->getContentType() ?? ($this->isHtmlBody($content) ? 'text/html' : 'text/plain'); if ($contentType !== 'text/html') { $content = wordwrap($content, $this->maxColumnWidth); @@ -129,11 +121,8 @@ protected function send(string $content, array $records) $headers .= 'MIME-Version: 1.0' . "\r\n"; } - $subject = $this->subject; - if ($records) { - $subjectFormatter = new LineFormatter($this->subject); - $subject = $subjectFormatter->format($this->getHighestRecord($records)); - } + $subjectFormatter = new LineFormatter($this->subject); + $subject = $subjectFormatter->format($this->getHighestRecord($records)); $parameters = implode(' ', $this->parameters); foreach ($this->to as $to) { @@ -141,28 +130,20 @@ protected function send(string $content, array $records) } } - /** - * @return string $contentType - */ - public function getContentType() + public function getContentType(): ?string { return $this->contentType; } - /** - * @return string $encoding - */ - public function getEncoding() + public function getEncoding(): string { return $this->encoding; } /** - * @param string $contentType The content type of the email - Defaults to text/plain. Use text/html for HTML - * messages. - * @return self + * @param string $contentType The content type of the email - Defaults to text/plain. Use text/html for HTML messages. */ - public function setContentType($contentType) + public function setContentType(string $contentType): self { if (strpos($contentType, "\n") !== false || strpos($contentType, "\r") !== false) { throw new \InvalidArgumentException('The content type can not contain newline characters to prevent email header injection'); @@ -173,11 +154,7 @@ public function setContentType($contentType) return $this; } - /** - * @param string $encoding - * @return self - */ - public function setEncoding($encoding) + public function setEncoding(string $encoding): self { if (strpos($encoding, "\n") !== false || strpos($encoding, "\r") !== false) { throw new \InvalidArgumentException('The encoding can not contain newline characters to prevent email header injection'); diff --git a/src/Monolog/Handler/NewRelicHandler.php b/src/Monolog/Handler/NewRelicHandler.php index d757067bf..b8cb3785b 100644 --- a/src/Monolog/Handler/NewRelicHandler.php +++ b/src/Monolog/Handler/NewRelicHandler.php @@ -11,15 +11,17 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; +use Monolog\Utils; use Monolog\Formatter\NormalizerFormatter; use Monolog\Formatter\FormatterInterface; +use Monolog\LogRecord; /** * Class to record a log on a NewRelic application. * Enabling New Relic High Security mode may prevent capture of useful information. * - * This handler requires a NormalizerFormatter to function and expects an array in $record['formatted'] + * This handler requires a NormalizerFormatter to function and expects an array in $record->formatted * * @see https://docs.newrelic.com/docs/agents/php-agent * @see https://docs.newrelic.com/docs/accounts-partnerships/accounts/security/high-security @@ -27,75 +29,58 @@ class NewRelicHandler extends AbstractProcessingHandler { /** - * Name of the New Relic application that will receive logs from this handler. - * - * @var string - */ - protected $appName; - - /** - * Name of the current transaction - * - * @var string - */ - protected $transactionName; - - /** - * Some context and extra data is passed into the handler as arrays of values. Do we send them as is - * (useful if we are using the API), or explode them for display on the NewRelic RPM website? - * - * @var bool - */ - protected $explodeArrays; - - /** - * {@inheritDoc} - * - * @param string $appName - * @param bool $explodeArrays - * @param string $transactionName + * @inheritDoc */ public function __construct( - $level = Logger::ERROR, - $bubble = true, - $appName = null, - $explodeArrays = false, - $transactionName = null + int|string|Level $level = Level::Error, + bool $bubble = true, + + /** + * Name of the New Relic application that will receive logs from this handler. + */ + protected string|null $appName = null, + + /** + * Some context and extra data is passed into the handler as arrays of values. Do we send them as is + * (useful if we are using the API), or explode them for display on the NewRelic RPM website? + */ + protected bool $explodeArrays = false, + + /** + * Name of the current transaction + */ + protected string|null $transactionName = null ) { parent::__construct($level, $bubble); - - $this->appName = $appName; - $this->explodeArrays = $explodeArrays; - $this->transactionName = $transactionName; } /** - * {@inheritDoc} + * @inheritDoc */ - protected function write(array $record) + protected function write(LogRecord $record): void { if (!$this->isNewRelicEnabled()) { throw new MissingExtensionException('The newrelic PHP extension is required to use the NewRelicHandler'); } - if ($appName = $this->getAppName($record['context'])) { + if (null !== ($appName = $this->getAppName($record->context))) { $this->setNewRelicAppName($appName); } - if ($transactionName = $this->getTransactionName($record['context'])) { + if (null !== ($transactionName = $this->getTransactionName($record->context))) { $this->setNewRelicTransactionName($transactionName); - unset($record['formatted']['context']['transaction_name']); + unset($record->formatted['context']['transaction_name']); } - if (isset($record['context']['exception']) && ($record['context']['exception'] instanceof \Exception || (PHP_VERSION_ID >= 70000 && $record['context']['exception'] instanceof \Throwable))) { - newrelic_notice_error($record['message'], $record['context']['exception']); - unset($record['formatted']['context']['exception']); + if (isset($record->context['exception']) && $record->context['exception'] instanceof \Throwable) { + newrelic_notice_error($record->message, $record->context['exception']); + unset($record->formatted['context']['exception']); } else { - newrelic_notice_error($record['message']); + newrelic_notice_error($record->message); } - if (isset($record['formatted']['context']) && is_array($record['formatted']['context'])) { - foreach ($record['formatted']['context'] as $key => $parameter) { + if (isset($record->formatted['context']) && is_array($record->formatted['context'])) { + foreach ($record->formatted['context'] as $key => $parameter) { if (is_array($parameter) && $this->explodeArrays) { foreach ($parameter as $paramKey => $paramValue) { $this->setNewRelicParameter('context_' . $key . '_' . $paramKey, $paramValue); @@ -106,8 +91,8 @@ protected function write(array $record) } } - if (isset($record['formatted']['extra']) && is_array($record['formatted']['extra'])) { - foreach ($record['formatted']['extra'] as $key => $parameter) { + if (isset($record->formatted['extra']) && is_array($record->formatted['extra'])) { + foreach ($record->formatted['extra'] as $key => $parameter) { if (is_array($parameter) && $this->explodeArrays) { foreach ($parameter as $paramKey => $paramValue) { $this->setNewRelicParameter('extra_' . $key . '_' . $paramKey, $paramValue); @@ -121,10 +106,8 @@ protected function write(array $record) /** * Checks whether the NewRelic extension is enabled in the system. - * - * @return bool */ - protected function isNewRelicEnabled() + protected function isNewRelicEnabled(): bool { return extension_loaded('newrelic'); } @@ -133,10 +116,9 @@ protected function isNewRelicEnabled() * Returns the appname where this log should be sent. Each log can override the default appname, set in this * handler's constructor, by providing the appname in it's context. * - * @param array $context - * @return null|string + * @param mixed[] $context */ - protected function getAppName(array $context) + protected function getAppName(array $context): ?string { if (isset($context['appname'])) { return $context['appname']; @@ -149,11 +131,9 @@ protected function getAppName(array $context) * Returns the name of the current transaction. Each log can override the default transaction name, set in this * handler's constructor, by providing the transaction_name in it's context * - * @param array $context - * - * @return null|string + * @param mixed[] $context */ - protected function getTransactionName(array $context) + protected function getTransactionName(array $context): ?string { if (isset($context['transaction_name'])) { return $context['transaction_name']; @@ -164,39 +144,34 @@ protected function getTransactionName(array $context) /** * Sets the NewRelic application that should receive this log. - * - * @param string $appName */ - protected function setNewRelicAppName($appName) + protected function setNewRelicAppName(string $appName): void { newrelic_set_appname($appName); } /** * Overwrites the name of the current transaction - * - * @param string $transactionName */ - protected function setNewRelicTransactionName($transactionName) + protected function setNewRelicTransactionName(string $transactionName): void { newrelic_name_transaction($transactionName); } /** - * @param string $key - * @param mixed $value + * @param mixed $value */ - protected function setNewRelicParameter($key, $value) + protected function setNewRelicParameter(string $key, $value): void { if (null === $value || is_scalar($value)) { newrelic_add_custom_parameter($key, $value); } else { - newrelic_add_custom_parameter($key, @json_encode($value)); + newrelic_add_custom_parameter($key, Utils::jsonEncode($value, null, true)); } } /** - * {@inheritDoc} + * @inheritDoc */ protected function getDefaultFormatter(): FormatterInterface { diff --git a/src/Monolog/Handler/NoopHandler.php b/src/Monolog/Handler/NoopHandler.php index 8ee2b4c69..d9fea180c 100644 --- a/src/Monolog/Handler/NoopHandler.php +++ b/src/Monolog/Handler/NoopHandler.php @@ -11,6 +11,8 @@ namespace Monolog\Handler; +use Monolog\LogRecord; + /** * No-op * @@ -23,17 +25,17 @@ class NoopHandler extends Handler { /** - * {@inheritdoc} + * @inheritDoc */ - public function isHandling(array $record): bool + public function isHandling(LogRecord $record): bool { return true; } /** - * {@inheritdoc} + * @inheritDoc */ - public function handle(array $record): bool + public function handle(LogRecord $record): bool { return false; } diff --git a/src/Monolog/Handler/NullHandler.php b/src/Monolog/Handler/NullHandler.php index 93678e8f1..1aa84e4f8 100644 --- a/src/Monolog/Handler/NullHandler.php +++ b/src/Monolog/Handler/NullHandler.php @@ -11,7 +11,10 @@ namespace Monolog\Handler; +use Monolog\Level; +use Psr\Log\LogLevel; use Monolog\Logger; +use Monolog\LogRecord; /** * Blackhole @@ -23,33 +26,31 @@ */ class NullHandler extends Handler { - private $level; + private Level $level; /** - * @param int $level The minimum logging level at which this handler will be triggered + * @param string|int|Level $level The minimum logging level at which this handler will be triggered + * + * @phpstan-param value-of|value-of|Level|LogLevel::* $level */ - public function __construct(int $level = Logger::DEBUG) + public function __construct(string|int|Level $level = Level::Debug) { - $this->level = $level; + $this->level = Logger::toMonologLevel($level); } /** - * {@inheritdoc} + * @inheritDoc */ - public function isHandling(array $record): bool + public function isHandling(LogRecord $record): bool { - return $record['level'] >= $this->level; + return $record->level->value >= $this->level->value; } /** - * {@inheritdoc} + * @inheritDoc */ - public function handle(array $record): bool + public function handle(LogRecord $record): bool { - if ($record['level'] < $this->level) { - return false; - } - - return true; + return $record->level->value >= $this->level->value; } } diff --git a/src/Monolog/Handler/OverflowHandler.php b/src/Monolog/Handler/OverflowHandler.php new file mode 100644 index 000000000..a72b7a11d --- /dev/null +++ b/src/Monolog/Handler/OverflowHandler.php @@ -0,0 +1,139 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Handler; + +use Monolog\Level; +use Monolog\Formatter\FormatterInterface; +use Monolog\LogRecord; + +/** + * Handler to only pass log messages when a certain threshold of number of messages is reached. + * + * This can be useful in cases of processing a batch of data, but you're for example only interested + * in case it fails catastrophically instead of a warning for 1 or 2 events. Worse things can happen, right? + * + * Usage example: + * + * ``` + * $log = new Logger('application'); + * $handler = new SomeHandler(...) + * + * // Pass all warnings to the handler when more than 10 & all error messages when more then 5 + * $overflow = new OverflowHandler($handler, [Level::Warning->value => 10, Level::Error->value => 5]); + * + * $log->pushHandler($overflow); + *``` + * + * @author Kris Buist + */ +class OverflowHandler extends AbstractHandler implements FormattableHandlerInterface +{ + private HandlerInterface $handler; + + /** @var array */ + private array $thresholdMap = []; + + /** + * Buffer of all messages passed to the handler before the threshold was reached + * + * @var mixed[][] + */ + private array $buffer = []; + + /** + * @param array $thresholdMap Dictionary of log level value => threshold + */ + public function __construct( + HandlerInterface $handler, + array $thresholdMap = [], + $level = Level::Debug, + bool $bubble = true + ) { + $this->handler = $handler; + foreach ($thresholdMap as $thresholdLevel => $threshold) { + $this->thresholdMap[$thresholdLevel] = $threshold; + } + parent::__construct($level, $bubble); + } + + /** + * Handles a record. + * + * All records may be passed to this method, and the handler should discard + * those that it does not want to handle. + * + * The return value of this function controls the bubbling process of the handler stack. + * Unless the bubbling is interrupted (by returning true), the Logger class will keep on + * calling further handlers in the stack with a given log record. + * + * @inheritDoc + */ + public function handle(LogRecord $record): bool + { + if ($record->level->isLowerThan($this->level)) { + return false; + } + + $level = $record->level->value; + + if (!isset($this->thresholdMap[$level])) { + $this->thresholdMap[$level] = 0; + } + + if ($this->thresholdMap[$level] > 0) { + // The overflow threshold is not yet reached, so we're buffering the record and lowering the threshold by 1 + $this->thresholdMap[$level]--; + $this->buffer[$level][] = $record; + + return false === $this->bubble; + } + + if ($this->thresholdMap[$level] == 0) { + // This current message is breaking the threshold. Flush the buffer and continue handling the current record + foreach ($this->buffer[$level] ?? [] as $buffered) { + $this->handler->handle($buffered); + } + $this->thresholdMap[$level]--; + unset($this->buffer[$level]); + } + + $this->handler->handle($record); + + return false === $this->bubble; + } + + /** + * @inheritDoc + */ + public function setFormatter(FormatterInterface $formatter): HandlerInterface + { + if ($this->handler instanceof FormattableHandlerInterface) { + $this->handler->setFormatter($formatter); + + return $this; + } + + throw new \UnexpectedValueException('The nested handler of type '.get_class($this->handler).' does not support formatters.'); + } + + /** + * @inheritDoc + */ + public function getFormatter(): FormatterInterface + { + if ($this->handler instanceof FormattableHandlerInterface) { + return $this->handler->getFormatter(); + } + + throw new \UnexpectedValueException('The nested handler of type '.get_class($this->handler).' does not support formatters.'); + } +} diff --git a/src/Monolog/Handler/PHPConsoleHandler.php b/src/Monolog/Handler/PHPConsoleHandler.php index c2b7b3e87..8aa78e4c4 100644 --- a/src/Monolog/Handler/PHPConsoleHandler.php +++ b/src/Monolog/Handler/PHPConsoleHandler.php @@ -13,10 +13,13 @@ use Monolog\Formatter\LineFormatter; use Monolog\Formatter\FormatterInterface; -use Monolog\Logger; +use Monolog\Level; +use Monolog\Utils; use PhpConsole\Connector; use PhpConsole\Handler as VendorPhpConsoleHandler; use PhpConsole\Helper; +use Monolog\LogRecord; +use PhpConsole\Storage; /** * Monolog handler for Google Chrome extension "PHP Console" @@ -24,7 +27,7 @@ * Display PHP error/debug log messages in Google Chrome console and notification popups, executes PHP code remotely * * Usage: - * 1. Install Google Chrome extension https://chrome.google.com/webstore/detail/php-console/nfhmhhlpfleoednkpnnnkolmclajemef + * 1. Install Google Chrome extension [now dead and removed from the chrome store] * 2. See overview https://github.com/barbushin/php-console#overview * 3. Install PHP Console library https://github.com/barbushin/php-console#installation * 4. Example (result will looks like http://i.hizliresim.com/vg3Pz4.png) @@ -36,10 +39,59 @@ * PC::debug($_SERVER); // PHP Console debugger for any type of vars * * @author Sergey Barbushin https://www.linkedin.com/in/barbushin + * @phpstan-type Options array{ + * enabled: bool, + * classesPartialsTraceIgnore: string[], + * debugTagsKeysInContext: array, + * useOwnErrorsHandler: bool, + * useOwnExceptionsHandler: bool, + * sourcesBasePath: string|null, + * registerHelper: bool, + * serverEncoding: string|null, + * headersLimit: int|null, + * password: string|null, + * enableSslOnlyMode: bool, + * ipMasks: string[], + * enableEvalListener: bool, + * dumperDetectCallbacks: bool, + * dumperLevelLimit: int, + * dumperItemsCountLimit: int, + * dumperItemSizeLimit: int, + * dumperDumpSizeLimit: int, + * detectDumpTraceAndSource: bool, + * dataStorage: Storage|null + * } + * @phpstan-type InputOptions array{ + * enabled?: bool, + * classesPartialsTraceIgnore?: string[], + * debugTagsKeysInContext?: array, + * useOwnErrorsHandler?: bool, + * useOwnExceptionsHandler?: bool, + * sourcesBasePath?: string|null, + * registerHelper?: bool, + * serverEncoding?: string|null, + * headersLimit?: int|null, + * password?: string|null, + * enableSslOnlyMode?: bool, + * ipMasks?: string[], + * enableEvalListener?: bool, + * dumperDetectCallbacks?: bool, + * dumperLevelLimit?: int, + * dumperItemsCountLimit?: int, + * dumperItemSizeLimit?: int, + * dumperDumpSizeLimit?: int, + * detectDumpTraceAndSource?: bool, + * dataStorage?: Storage|null + * } + * + * @deprecated Since 2.8.0 and 3.2.0, PHPConsole is abandoned and thus we will drop this handler in Monolog 4 */ class PHPConsoleHandler extends AbstractProcessingHandler { - private $options = [ + /** + * @phpstan-var Options + */ + private array $options = [ 'enabled' => true, // bool Is PHP Console server enabled 'classesPartialsTraceIgnore' => ['Monolog\\'], // array Hide calls of classes started with... 'debugTagsKeysInContext' => [0, 'tag'], // bool Is PHP Console server enabled @@ -59,20 +111,18 @@ class PHPConsoleHandler extends AbstractProcessingHandler 'dumperItemSizeLimit' => 5000, // int Maximum length of any string or dumped array item 'dumperDumpSizeLimit' => 500000, // int Maximum approximate size of dumped vars result formatted in JSON 'detectDumpTraceAndSource' => false, // bool Autodetect and append trace data to debug - 'dataStorage' => null, // PhpConsole\Storage|null Fixes problem with custom $_SESSION handler(see http://goo.gl/Ne8juJ) + 'dataStorage' => null, // \PhpConsole\Storage|null Fixes problem with custom $_SESSION handler(see http://goo.gl/Ne8juJ) ]; - /** @var Connector */ - private $connector; + private Connector $connector; /** - * @param array $options See \Monolog\Handler\PHPConsoleHandler::$options for more details - * @param Connector|null $connector Instance of \PhpConsole\Connector class (optional) - * @param int $level - * @param bool $bubble + * @param array $options See \Monolog\Handler\PHPConsoleHandler::$options for more details + * @param Connector|null $connector Instance of \PhpConsole\Connector class (optional) * @throws \RuntimeException + * @phpstan-param InputOptions $options */ - public function __construct(array $options = [], Connector $connector = null, $level = Logger::DEBUG, $bubble = true) + public function __construct(array $options = [], ?Connector $connector = null, int|string|Level $level = Level::Debug, bool $bubble = true) { if (!class_exists('PhpConsole\Connector')) { throw new \RuntimeException('PHP Console library not found. See https://github.com/barbushin/php-console#installation'); @@ -82,20 +132,27 @@ public function __construct(array $options = [], Connector $connector = null, $l $this->connector = $this->initConnector($connector); } - private function initOptions(array $options) + /** + * @param array $options + * @return array + * + * @phpstan-param InputOptions $options + * @phpstan-return Options + */ + private function initOptions(array $options): array { $wrongOptions = array_diff(array_keys($options), array_keys($this->options)); - if ($wrongOptions) { + if (\count($wrongOptions) > 0) { throw new \RuntimeException('Unknown options: ' . implode(', ', $wrongOptions)); } return array_replace($this->options, $options); } - private function initConnector(Connector $connector = null) + private function initConnector(?Connector $connector = null): Connector { - if (!$connector) { - if ($this->options['dataStorage']) { + if (null === $connector) { + if ($this->options['dataStorage'] instanceof Storage) { Connector::setPostponeStorage($this->options['dataStorage']); } $connector = Connector::getInstance(); @@ -112,22 +169,22 @@ private function initConnector(Connector $connector = null) $handler->setHandleExceptions($this->options['useOwnExceptionsHandler']); $handler->start(); } - if ($this->options['sourcesBasePath']) { + if (null !== $this->options['sourcesBasePath']) { $connector->setSourcesBasePath($this->options['sourcesBasePath']); } - if ($this->options['serverEncoding']) { + if (null !== $this->options['serverEncoding']) { $connector->setServerEncoding($this->options['serverEncoding']); } - if ($this->options['password']) { + if (null !== $this->options['password']) { $connector->setPassword($this->options['password']); } if ($this->options['enableSslOnlyMode']) { $connector->enableSslOnlyMode(); } - if ($this->options['ipMasks']) { + if (\count($this->options['ipMasks']) > 0) { $connector->setAllowedIpMasks($this->options['ipMasks']); } - if ($this->options['headersLimit']) { + if (null !== $this->options['headersLimit'] && $this->options['headersLimit'] > 0) { $connector->setHeadersLimit($this->options['headersLimit']); } if ($this->options['detectDumpTraceAndSource']) { @@ -147,17 +204,20 @@ private function initConnector(Connector $connector = null) return $connector; } - public function getConnector() + public function getConnector(): Connector { return $this->connector; } - public function getOptions() + /** + * @return array + */ + public function getOptions(): array { return $this->options; } - public function handle(array $record): bool + public function handle(LogRecord $record): bool { if ($this->options['enabled'] && $this->connector->isActiveClient()) { return parent::handle($record); @@ -168,72 +228,73 @@ public function handle(array $record): bool /** * Writes the record down to the log of the implementing handler - * - * @param array $record - * @return void */ - protected function write(array $record) + protected function write(LogRecord $record): void { - if ($record['level'] < Logger::NOTICE) { + if ($record->level->isLowerThan(Level::Notice)) { $this->handleDebugRecord($record); - } elseif (isset($record['context']['exception']) && $record['context']['exception'] instanceof \Throwable) { + } elseif (isset($record->context['exception']) && $record->context['exception'] instanceof \Throwable) { $this->handleExceptionRecord($record); } else { $this->handleErrorRecord($record); } } - private function handleDebugRecord(array $record) + private function handleDebugRecord(LogRecord $record): void { - $tags = $this->getRecordTags($record); - $message = $record['message']; - if ($record['context']) { - $message .= ' ' . json_encode($this->connector->getDumper()->dump(array_filter($record['context']))); + [$tags, $filteredContext] = $this->getRecordTags($record); + $message = $record->message; + if (\count($filteredContext) > 0) { + $message .= ' ' . Utils::jsonEncode($this->connector->getDumper()->dump(array_filter($filteredContext)), null, true); } $this->connector->getDebugDispatcher()->dispatchDebug($message, $tags, $this->options['classesPartialsTraceIgnore']); } - private function handleExceptionRecord(array $record) + private function handleExceptionRecord(LogRecord $record): void { - $this->connector->getErrorsDispatcher()->dispatchException($record['context']['exception']); + $this->connector->getErrorsDispatcher()->dispatchException($record->context['exception']); } - private function handleErrorRecord(array $record) + private function handleErrorRecord(LogRecord $record): void { - $context = $record['context']; + $context = $record->context; $this->connector->getErrorsDispatcher()->dispatchError( $context['code'] ?? null, - $context['message'] ?? $record['message'], + $context['message'] ?? $record->message, $context['file'] ?? null, $context['line'] ?? null, $this->options['classesPartialsTraceIgnore'] ); } - private function getRecordTags(array &$record) + /** + * @return array{string, mixed[]} + */ + private function getRecordTags(LogRecord $record): array { $tags = null; - if (!empty($record['context'])) { - $context = & $record['context']; + $filteredContext = []; + if ($record->context !== []) { + $filteredContext = $record->context; foreach ($this->options['debugTagsKeysInContext'] as $key) { - if (!empty($context[$key])) { - $tags = $context[$key]; + if (isset($filteredContext[$key])) { + $tags = $filteredContext[$key]; if ($key === 0) { - array_shift($context); + array_shift($filteredContext); } else { - unset($context[$key]); + unset($filteredContext[$key]); } break; } } } - return $tags ?: strtolower($record['level_name']); + return [$tags ?? $record->level->toPsrLogLevel(), $filteredContext]; } /** - * {@inheritDoc} + * @inheritDoc */ protected function getDefaultFormatter(): FormatterInterface { diff --git a/src/Monolog/Handler/ProcessHandler.php b/src/Monolog/Handler/ProcessHandler.php index 8cf6087b1..9edc9ac54 100644 --- a/src/Monolog/Handler/ProcessHandler.php +++ b/src/Monolog/Handler/ProcessHandler.php @@ -11,7 +11,8 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; +use Monolog\LogRecord; /** * Stores to STDIN of any process, specified by a command. @@ -33,25 +34,19 @@ class ProcessHandler extends AbstractProcessingHandler */ private $process; - /** - * @var string - */ - private $command; + private string $command; - /** - * @var string - */ - private $cwd; + private ?string $cwd; /** - * @var array + * @var resource[] */ - private $pipes = []; + private array $pipes = []; /** - * @var array + * @var array */ - const DESCRIPTOR_SPEC = [ + protected const DESCRIPTOR_SPEC = [ 0 => ['pipe', 'r'], // STDIN is a pipe that the child will read from 1 => ['pipe', 'w'], // STDOUT is a pipe that the child will write to 2 => ['pipe', 'w'], // STDERR is a pipe to catch the any errors @@ -60,12 +55,10 @@ class ProcessHandler extends AbstractProcessingHandler /** * @param string $command Command for the process to start. Absolute paths are recommended, * especially if you do not use the $cwd parameter. - * @param string|int $level The minimum logging level at which this handler will be triggered. - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not. - * @param string $cwd "Current working directory" (CWD) for the process to be executed in. + * @param string|null $cwd "Current working directory" (CWD) for the process to be executed in. * @throws \InvalidArgumentException */ - public function __construct(string $command, $level = Logger::DEBUG, bool $bubble = true, string $cwd = null) + public function __construct(string $command, int|string|Level $level = Level::Debug, bool $bubble = true, ?string $cwd = null) { if ($command === '') { throw new \InvalidArgumentException('The command argument must be a non-empty string.'); @@ -83,18 +76,16 @@ public function __construct(string $command, $level = Logger::DEBUG, bool $bubbl /** * Writes the record down to the log of the implementing handler * - * @param array $record * @throws \UnexpectedValueException - * @return void */ - protected function write(array $record) + protected function write(LogRecord $record): void { $this->ensureProcessIsStarted(); - $this->writeProcessInput($record['formatted']); + $this->writeProcessInput($record->formatted); $errors = $this->readProcessErrors(); - if (empty($errors) === false) { + if ($errors !== '') { throw new \UnexpectedValueException(sprintf('Errors while writing to process: %s', $errors)); } } @@ -102,10 +93,8 @@ protected function write(array $record) /** * Makes sure that the process is actually started, and if not, starts it, * assigns the stream pipes, and handles startup errors, if any. - * - * @return void */ - private function ensureProcessIsStarted() + private function ensureProcessIsStarted(): void { if (is_resource($this->process) === false) { $this->startProcess(); @@ -116,12 +105,10 @@ private function ensureProcessIsStarted() /** * Starts the actual process and sets all streams to non-blocking. - * - * @return void */ - private function startProcess() + private function startProcess(): void { - $this->process = proc_open($this->command, self::DESCRIPTOR_SPEC, $this->pipes, $this->cwd); + $this->process = proc_open($this->command, static::DESCRIPTOR_SPEC, $this->pipes, $this->cwd); foreach ($this->pipes as $pipe) { stream_set_blocking($pipe, false); @@ -132,9 +119,8 @@ private function startProcess() * Selects the STDERR stream, handles upcoming startup errors, and throws an exception, if any. * * @throws \UnexpectedValueException - * @return void */ - private function handleStartupErrors() + private function handleStartupErrors(): void { $selected = $this->selectErrorStream(); if (false === $selected) { @@ -143,7 +129,7 @@ private function handleStartupErrors() $errors = $this->readProcessErrors(); - if (is_resource($this->process) === false || empty($errors) === false) { + if (is_resource($this->process) === false || $errors !== '') { throw new \UnexpectedValueException( sprintf('The process "%s" could not be opened: ' . $errors, $this->command) ); @@ -169,27 +155,25 @@ protected function selectErrorStream() * @codeCoverageIgnore * @return string Empty string if there are no errors. */ - protected function readProcessErrors() + protected function readProcessErrors(): string { - return stream_get_contents($this->pipes[2]); + return (string) stream_get_contents($this->pipes[2]); } /** * Writes to the input stream of the opened process. * * @codeCoverageIgnore - * @param $string - * @return void */ - protected function writeProcessInput($string) + protected function writeProcessInput(string $string): void { - fwrite($this->pipes[0], (string) $string); + fwrite($this->pipes[0], $string); } /** - * {@inheritdoc} + * @inheritDoc */ - public function close() + public function close(): void { if (is_resource($this->process)) { foreach ($this->pipes as $pipe) { diff --git a/src/Monolog/Handler/ProcessableHandlerInterface.php b/src/Monolog/Handler/ProcessableHandlerInterface.php index 9556f9857..9fb290faa 100644 --- a/src/Monolog/Handler/ProcessableHandlerInterface.php +++ b/src/Monolog/Handler/ProcessableHandlerInterface.php @@ -11,6 +11,9 @@ namespace Monolog\Handler; +use Monolog\Processor\ProcessorInterface; +use Monolog\LogRecord; + /** * Interface to describe loggers that have processors * @@ -21,16 +24,20 @@ interface ProcessableHandlerInterface /** * Adds a processor in the stack. * - * @param callable $callback - * @return HandlerInterface self + * @phpstan-param ProcessorInterface|(callable(LogRecord): LogRecord) $callback + * + * @param ProcessorInterface|callable $callback + * @return HandlerInterface self */ public function pushProcessor(callable $callback): HandlerInterface; /** * Removes the processor on top of the stack and returns it. * - * @throws \LogicException In case the processor stack is empty - * @return callable + * @phpstan-return ProcessorInterface|(callable(LogRecord): LogRecord) $callback + * + * @throws \LogicException In case the processor stack is empty + * @return callable|ProcessorInterface */ public function popProcessor(): callable; } diff --git a/src/Monolog/Handler/ProcessableHandlerTrait.php b/src/Monolog/Handler/ProcessableHandlerTrait.php index bba940e83..74eeddddc 100644 --- a/src/Monolog/Handler/ProcessableHandlerTrait.php +++ b/src/Monolog/Handler/ProcessableHandlerTrait.php @@ -11,6 +11,10 @@ namespace Monolog\Handler; +use Monolog\ResettableInterface; +use Monolog\Processor\ProcessorInterface; +use Monolog\LogRecord; + /** * Helper trait for implementing ProcessableInterface * @@ -20,11 +24,12 @@ trait ProcessableHandlerTrait { /** * @var callable[] + * @phpstan-var array<(callable(LogRecord): LogRecord)|ProcessorInterface> */ - protected $processors = []; + protected array $processors = []; /** - * {@inheritdoc} + * @inheritDoc */ public function pushProcessor(callable $callback): HandlerInterface { @@ -34,24 +39,18 @@ public function pushProcessor(callable $callback): HandlerInterface } /** - * {@inheritdoc} + * @inheritDoc */ public function popProcessor(): callable { - if (!$this->processors) { + if (\count($this->processors) === 0) { throw new \LogicException('You tried to pop from an empty processor stack.'); } return array_shift($this->processors); } - /** - * Processes a record. - * - * @param array $record - * @return array - */ - protected function processRecord(array $record) + protected function processRecord(LogRecord $record): LogRecord { foreach ($this->processors as $processor) { $record = $processor($record); @@ -59,4 +58,13 @@ protected function processRecord(array $record) return $record; } + + protected function resetProcessors(): void + { + foreach ($this->processors as $processor) { + if ($processor instanceof ResettableInterface) { + $processor->reset(); + } + } + } } diff --git a/src/Monolog/Handler/PsrHandler.php b/src/Monolog/Handler/PsrHandler.php index e39233b46..6599a83b4 100644 --- a/src/Monolog/Handler/PsrHandler.php +++ b/src/Monolog/Handler/PsrHandler.php @@ -11,29 +11,33 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; use Psr\Log\LoggerInterface; +use Monolog\Formatter\FormatterInterface; +use Monolog\LogRecord; /** * Proxies log messages to an existing PSR-3 compliant logger. * + * If a formatter is configured, the formatter's output MUST be a string and the + * formatted message will be fed to the wrapped PSR logger instead of the original + * log record's message. + * * @author Michael Moussa */ -class PsrHandler extends AbstractHandler +class PsrHandler extends AbstractHandler implements FormattableHandlerInterface { /** * PSR-3 compliant logger - * - * @var LoggerInterface */ - protected $logger; + protected LoggerInterface $logger; + + protected FormatterInterface|null $formatter = null; /** * @param LoggerInterface $logger The underlying PSR-3 compliant logger to which messages will be proxied - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not */ - public function __construct(LoggerInterface $logger, $level = Logger::DEBUG, $bubble = true) + public function __construct(LoggerInterface $logger, int|string|Level $level = Level::Debug, bool $bubble = true) { parent::__construct($level, $bubble); @@ -41,16 +45,43 @@ public function __construct(LoggerInterface $logger, $level = Logger::DEBUG, $bu } /** - * {@inheritDoc} + * @inheritDoc */ - public function handle(array $record): bool + public function handle(LogRecord $record): bool { if (!$this->isHandling($record)) { return false; } - $this->logger->log(strtolower($record['level_name']), $record['message'], $record['context']); + if ($this->formatter !== null) { + $formatted = $this->formatter->format($record); + $this->logger->log($record->level->toPsrLogLevel(), (string) $formatted, $record->context); + } else { + $this->logger->log($record->level->toPsrLogLevel(), $record->message, $record->context); + } return false === $this->bubble; } + + /** + * Sets the formatter. + */ + public function setFormatter(FormatterInterface $formatter): HandlerInterface + { + $this->formatter = $formatter; + + return $this; + } + + /** + * Gets the formatter. + */ + public function getFormatter(): FormatterInterface + { + if ($this->formatter === null) { + throw new \LogicException('No formatter has been set and this handler does not have a default formatter'); + } + + return $this->formatter; + } } diff --git a/src/Monolog/Handler/PushoverHandler.php b/src/Monolog/Handler/PushoverHandler.php index 3e6834228..118f5760a 100644 --- a/src/Monolog/Handler/PushoverHandler.php +++ b/src/Monolog/Handler/PushoverHandler.php @@ -11,7 +11,11 @@ namespace Monolog\Handler; +use Monolog\Level; use Monolog\Logger; +use Monolog\Utils; +use Psr\Log\LogLevel; +use Monolog\LogRecord; /** * Sends notifications through the pushover api to mobile phones @@ -21,23 +25,31 @@ */ class PushoverHandler extends SocketHandler { - private $token; - private $users; - private $title; - private $user; - private $retry; - private $expire; + private string $token; - private $highPriorityLevel; - private $emergencyLevel; - private $useFormattedMessage = false; + /** @var array */ + private array $users; + + private string $title; + + private string|int|null $user = null; + + private int $retry; + + private int $expire; + + private Level $highPriorityLevel; + + private Level $emergencyLevel; + + private bool $useFormattedMessage = false; /** * All parameters that can be sent to Pushover * @see https://pushover.net/api - * @var array + * @var array */ - private $parameterNames = [ + private array $parameterNames = [ 'token' => true, 'user' => true, 'message' => true, @@ -56,59 +68,89 @@ class PushoverHandler extends SocketHandler /** * Sounds the api supports by default * @see https://pushover.net/api#sounds - * @var array + * @var string[] */ - private $sounds = [ + private array $sounds = [ 'pushover', 'bike', 'bugle', 'cashregister', 'classical', 'cosmic', 'falling', 'gamelan', 'incoming', 'intermission', 'magic', 'mechanical', 'pianobar', 'siren', 'spacealarm', 'tugboat', 'alien', 'climb', 'persistent', 'echo', 'updown', 'none', ]; /** - * @param string $token Pushover api token - * @param string|array $users Pushover user id or array of ids the message will be sent to - * @param string $title Title sent to the Pushover API - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not - * @param bool $useSSL Whether to connect via SSL. Required when pushing messages to users that are not - * the pushover.net app owner. OpenSSL is required for this option. - * @param int $highPriorityLevel The minimum logging level at which this handler will start - * sending "high priority" requests to the Pushover API - * @param int $emergencyLevel The minimum logging level at which this handler will start - * sending "emergency" requests to the Pushover API - * @param int $retry The retry parameter specifies how often (in seconds) the Pushover servers will send the same notification to the user. - * @param int $expire The expire parameter specifies how many seconds your notification will continue to be retried for (every retry seconds). + * @param string $token Pushover api token + * @param string|array $users Pushover user id or array of ids the message will be sent to + * @param string|null $title Title sent to the Pushover API + * @param bool $useSSL Whether to connect via SSL. Required when pushing messages to users that are not + * the pushover.net app owner. OpenSSL is required for this option. + * @param int $retry The retry parameter specifies how often (in seconds) the Pushover servers will + * send the same notification to the user. + * @param int $expire The expire parameter specifies how many seconds your notification will continue + * to be retried for (every retry seconds). + * + * @param int|string|Level|LogLevel::* $highPriorityLevel The minimum logging level at which this handler will start + * sending "high priority" requests to the Pushover API + * @param int|string|Level|LogLevel::* $emergencyLevel The minimum logging level at which this handler will start + * sending "emergency" requests to the Pushover API + * + * + * @phpstan-param string|array $users + * @phpstan-param value-of|value-of|Level|LogLevel::* $highPriorityLevel + * @phpstan-param value-of|value-of|Level|LogLevel::* $emergencyLevel */ - public function __construct($token, $users, $title = null, $level = Logger::CRITICAL, $bubble = true, $useSSL = true, $highPriorityLevel = Logger::CRITICAL, $emergencyLevel = Logger::EMERGENCY, $retry = 30, $expire = 25200) - { + public function __construct( + string $token, + $users, + ?string $title = null, + int|string|Level $level = Level::Critical, + bool $bubble = true, + bool $useSSL = true, + int|string|Level $highPriorityLevel = Level::Critical, + int|string|Level $emergencyLevel = Level::Emergency, + int $retry = 30, + int $expire = 25200, + bool $persistent = false, + float $timeout = 0.0, + float $writingTimeout = 10.0, + ?float $connectionTimeout = null, + ?int $chunkSize = null + ) { $connectionString = $useSSL ? 'ssl://api.pushover.net:443' : 'api.pushover.net:80'; - parent::__construct($connectionString, $level, $bubble); + parent::__construct( + $connectionString, + $level, + $bubble, + $persistent, + $timeout, + $writingTimeout, + $connectionTimeout, + $chunkSize + ); $this->token = $token; $this->users = (array) $users; - $this->title = $title ?: gethostname(); + $this->title = $title ?? (string) gethostname(); $this->highPriorityLevel = Logger::toMonologLevel($highPriorityLevel); $this->emergencyLevel = Logger::toMonologLevel($emergencyLevel); $this->retry = $retry; $this->expire = $expire; } - protected function generateDataStream($record) + protected function generateDataStream(LogRecord $record): string { $content = $this->buildContent($record); return $this->buildHeader($content) . $content; } - private function buildContent($record) + private function buildContent(LogRecord $record): string { // Pushover has a limit of 512 characters on title and message combined. $maxMessageLength = 512 - strlen($this->title); - $message = ($this->useFormattedMessage) ? $record['formatted'] : $record['message']; - $message = substr($message, 0, $maxMessageLength); + $message = ($this->useFormattedMessage) ? $record->formatted : $record->message; + $message = Utils::substr($message, 0, $maxMessageLength); - $timestamp = $record['datetime']->getTimestamp(); + $timestamp = $record->datetime->getTimestamp(); $dataArray = [ 'token' => $this->token, @@ -118,30 +160,30 @@ private function buildContent($record) 'timestamp' => $timestamp, ]; - if (isset($record['level']) && $record['level'] >= $this->emergencyLevel) { + if ($record->level->value >= $this->emergencyLevel->value) { $dataArray['priority'] = 2; $dataArray['retry'] = $this->retry; $dataArray['expire'] = $this->expire; - } elseif (isset($record['level']) && $record['level'] >= $this->highPriorityLevel) { + } elseif ($record->level->value >= $this->highPriorityLevel->value) { $dataArray['priority'] = 1; } // First determine the available parameters - $context = array_intersect_key($record['context'], $this->parameterNames); - $extra = array_intersect_key($record['extra'], $this->parameterNames); + $context = array_intersect_key($record->context, $this->parameterNames); + $extra = array_intersect_key($record->extra, $this->parameterNames); // Least important info should be merged with subsequent info $dataArray = array_merge($extra, $context, $dataArray); // Only pass sounds that are supported by the API - if (isset($dataArray['sound']) && !in_array($dataArray['sound'], $this->sounds)) { + if (isset($dataArray['sound']) && !in_array($dataArray['sound'], $this->sounds, true)) { unset($dataArray['sound']); } return http_build_query($dataArray); } - private function buildHeader($content) + private function buildHeader(string $content): string { $header = "POST /1/messages.json HTTP/1.1\r\n"; $header .= "Host: api.pushover.net\r\n"; @@ -152,7 +194,7 @@ private function buildHeader($content) return $header; } - protected function write(array $record) + protected function write(LogRecord $record): void { foreach ($this->users as $user) { $this->user = $user; @@ -164,22 +206,37 @@ protected function write(array $record) $this->user = null; } - public function setHighPriorityLevel($value) + /** + * @param int|string|Level|LogLevel::* $level + * + * @phpstan-param value-of|value-of|Level|LogLevel::* $level + */ + public function setHighPriorityLevel(int|string|Level $level): self { - $this->highPriorityLevel = $value; + $this->highPriorityLevel = Logger::toMonologLevel($level); + + return $this; } - public function setEmergencyLevel($value) + /** + * @param int|string|Level|LogLevel::* $level + * + * @phpstan-param value-of|value-of|Level|LogLevel::* $level + */ + public function setEmergencyLevel(int|string|Level $level): self { - $this->emergencyLevel = $value; + $this->emergencyLevel = Logger::toMonologLevel($level); + + return $this; } /** * Use the formatted message? - * @param bool $value */ - public function useFormattedMessage($value) + public function useFormattedMessage(bool $useFormattedMessage): self { - $this->useFormattedMessage = (bool) $value; + $this->useFormattedMessage = $useFormattedMessage; + + return $this; } } diff --git a/src/Monolog/Handler/RavenHandler.php b/src/Monolog/Handler/RavenHandler.php deleted file mode 100644 index 7ea5fd7d9..000000000 --- a/src/Monolog/Handler/RavenHandler.php +++ /dev/null @@ -1,234 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Monolog\Handler; - -use Monolog\Formatter\LineFormatter; -use Monolog\Formatter\FormatterInterface; -use Monolog\Logger; -use Raven_Client; - -/** - * Handler to send messages to a Sentry (https://github.com/getsentry/sentry) server - * using raven-php (https://github.com/getsentry/raven-php) - * - * @author Marc Abramowitz - */ -class RavenHandler extends AbstractProcessingHandler -{ - /** - * Translates Monolog log levels to Raven log levels. - */ - private $logLevels = [ - Logger::DEBUG => Raven_Client::DEBUG, - Logger::INFO => Raven_Client::INFO, - Logger::NOTICE => Raven_Client::INFO, - Logger::WARNING => Raven_Client::WARNING, - Logger::ERROR => Raven_Client::ERROR, - Logger::CRITICAL => Raven_Client::FATAL, - Logger::ALERT => Raven_Client::FATAL, - Logger::EMERGENCY => Raven_Client::FATAL, - ]; - - /** - * @var string should represent the current version of the calling - * software. Can be any string (git commit, version number) - */ - private $release; - - /** - * @var Raven_Client the client object that sends the message to the server - */ - protected $ravenClient; - - /** - * @var LineFormatter The formatter to use for the logs generated via handleBatch() - */ - protected $batchFormatter; - - /** - * @param Raven_Client $ravenClient - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not - */ - public function __construct(Raven_Client $ravenClient, $level = Logger::DEBUG, $bubble = true) - { - parent::__construct($level, $bubble); - - $this->ravenClient = $ravenClient; - } - - /** - * {@inheritdoc} - */ - public function handleBatch(array $records) - { - $level = $this->level; - - // filter records based on their level - $records = array_filter($records, function ($record) use ($level) { - return $record['level'] >= $level; - }); - - if (!$records) { - return; - } - - // the record with the highest severity is the "main" one - $record = array_reduce($records, function ($highest, $record) { - if ($record['level'] > $highest['level']) { - return $record; - } - - return $highest; - }); - - // the other ones are added as a context item - $logs = []; - foreach ($records as $r) { - $logs[] = $this->processRecord($r); - } - - if ($logs) { - $record['context']['logs'] = (string) $this->getBatchFormatter()->formatBatch($logs); - } - - $this->handle($record); - } - - /** - * Sets the formatter for the logs generated by handleBatch(). - * - * @param FormatterInterface $formatter - */ - public function setBatchFormatter(FormatterInterface $formatter) - { - $this->batchFormatter = $formatter; - } - - /** - * Gets the formatter for the logs generated by handleBatch(). - * - * @return FormatterInterface - */ - public function getBatchFormatter() - { - if (!$this->batchFormatter) { - $this->batchFormatter = $this->getDefaultBatchFormatter(); - } - - return $this->batchFormatter; - } - - /** - * {@inheritdoc} - */ - protected function write(array $record) - { - /** @var bool|null|array This is false, unless set below to null or an array of data, when we read the current user context */ - $previousUserContext = false; - $options = []; - $options['level'] = $this->logLevels[$record['level']]; - $options['tags'] = []; - if (!empty($record['extra']['tags'])) { - $options['tags'] = array_merge($options['tags'], $record['extra']['tags']); - unset($record['extra']['tags']); - } - if (!empty($record['context']['tags'])) { - $options['tags'] = array_merge($options['tags'], $record['context']['tags']); - unset($record['context']['tags']); - } - if (!empty($record['context']['fingerprint'])) { - $options['fingerprint'] = $record['context']['fingerprint']; - unset($record['context']['fingerprint']); - } - if (!empty($record['context']['logger'])) { - $options['logger'] = $record['context']['logger']; - unset($record['context']['logger']); - } else { - $options['logger'] = $record['channel']; - } - foreach ($this->getExtraParameters() as $key) { - foreach (['extra', 'context'] as $source) { - if (!empty($record[$source][$key])) { - $options[$key] = $record[$source][$key]; - unset($record[$source][$key]); - } - } - } - if (!empty($record['context'])) { - $options['extra']['context'] = $record['context']; - if (!empty($record['context']['user'])) { - $previousUserContext = $this->ravenClient->context->user; - $this->ravenClient->user_context($record['context']['user']); - unset($options['extra']['context']['user']); - } - } - if (!empty($record['extra'])) { - $options['extra']['extra'] = $record['extra']; - } - - if (!empty($this->release) && !isset($options['release'])) { - $options['release'] = $this->release; - } - - if (isset($record['context']['exception']) && $record['context']['exception'] instanceof \Throwable) { - $options['message'] = $record['formatted']; - $this->ravenClient->captureException($record['context']['exception'], $options); - } else { - $this->ravenClient->captureMessage($record['formatted'], [], $options); - } - - // restore the user context if it was modified - if (!is_bool($previousUserContext)) { - $this->ravenClient->user_context($previousUserContext); - } - } - - /** - * {@inheritDoc} - */ - protected function getDefaultFormatter(): FormatterInterface - { - return new LineFormatter('[%channel%] %message%'); - } - - /** - * Gets the default formatter for the logs generated by handleBatch(). - * - * @return FormatterInterface - */ - protected function getDefaultBatchFormatter() - { - return new LineFormatter(); - } - - /** - * Gets extra parameters supported by Raven that can be found in "extra" and "context" - * - * @return array - */ - protected function getExtraParameters() - { - return ['checksum', 'release', 'event_id']; - } - - /** - * @param string $value - * @return self - */ - public function setRelease($value) - { - $this->release = $value; - - return $this; - } -} diff --git a/src/Monolog/Handler/RedisHandler.php b/src/Monolog/Handler/RedisHandler.php index e7ddb44bb..5eee5dc69 100644 --- a/src/Monolog/Handler/RedisHandler.php +++ b/src/Monolog/Handler/RedisHandler.php @@ -13,7 +13,10 @@ use Monolog\Formatter\LineFormatter; use Monolog\Formatter\FormatterInterface; -use Monolog\Logger; +use Monolog\Level; +use Monolog\LogRecord; +use Predis\Client as Predis; +use Redis; /** * Logs to a Redis key using rpush @@ -28,23 +31,18 @@ */ class RedisHandler extends AbstractProcessingHandler { - private $redisClient; - private $redisKey; - protected $capSize; + /** @var Predis|Redis */ + private Predis|Redis $redisClient; + private string $redisKey; + protected int $capSize; /** - * @param \Predis\Client|\Redis $redis The redis instance - * @param string $key The key name to push records to - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not - * @param int $capSize Number of entries to limit list size to, 0 = unlimited + * @param Predis|Redis $redis The redis instance + * @param string $key The key name to push records to + * @param int $capSize Number of entries to limit list size to, 0 = unlimited */ - public function __construct($redis, string $key, $level = Logger::DEBUG, bool $bubble = true, int $capSize = 0) + public function __construct(Predis|Redis $redis, string $key, int|string|Level $level = Level::Debug, bool $bubble = true, int $capSize = 0) { - if (!(($redis instanceof \Predis\Client) || ($redis instanceof \Redis))) { - throw new \InvalidArgumentException('Predis\Client or Redis instance required'); - } - $this->redisClient = $redis; $this->redisKey = $key; $this->capSize = $capSize; @@ -53,43 +51,41 @@ public function __construct($redis, string $key, $level = Logger::DEBUG, bool $b } /** - * {@inheritDoc} + * @inheritDoc */ - protected function write(array $record) + protected function write(LogRecord $record): void { - if ($this->capSize) { + if ($this->capSize > 0) { $this->writeCapped($record); } else { - $this->redisClient->rpush($this->redisKey, $record["formatted"]); + $this->redisClient->rpush($this->redisKey, $record->formatted); } } /** * Write and cap the collection * Writes the record to the redis list and caps its - * - * @param array $record associative record array - * @return void */ - protected function writeCapped(array $record) + protected function writeCapped(LogRecord $record): void { - if ($this->redisClient instanceof \Redis) { - $this->redisClient->multi() - ->rpush($this->redisKey, $record["formatted"]) + if ($this->redisClient instanceof Redis) { + $mode = defined('Redis::MULTI') ? Redis::MULTI : 1; + $this->redisClient->multi($mode) + ->rPush($this->redisKey, $record->formatted) ->ltrim($this->redisKey, -$this->capSize, -1) ->exec(); } else { $redisKey = $this->redisKey; $capSize = $this->capSize; $this->redisClient->transaction(function ($tx) use ($record, $redisKey, $capSize) { - $tx->rpush($redisKey, $record["formatted"]); + $tx->rpush($redisKey, $record->formatted); $tx->ltrim($redisKey, -$capSize, -1); }); } } /** - * {@inheritDoc} + * @inheritDoc */ protected function getDefaultFormatter(): FormatterInterface { diff --git a/src/Monolog/Handler/RedisPubSubHandler.php b/src/Monolog/Handler/RedisPubSubHandler.php new file mode 100644 index 000000000..fa8e9e9ff --- /dev/null +++ b/src/Monolog/Handler/RedisPubSubHandler.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Handler; + +use Monolog\Formatter\LineFormatter; +use Monolog\Formatter\FormatterInterface; +use Monolog\Level; +use Monolog\LogRecord; +use Predis\Client as Predis; +use Redis; + +/** + * Sends the message to a Redis Pub/Sub channel using PUBLISH + * + * usage example: + * + * $log = new Logger('application'); + * $redis = new RedisPubSubHandler(new Predis\Client("tcp://localhost:6379"), "logs", Level::Warning); + * $log->pushHandler($redis); + * + * @author Gaëtan Faugère + */ +class RedisPubSubHandler extends AbstractProcessingHandler +{ + /** @var Predis|Redis */ + private Predis|Redis $redisClient; + private string $channelKey; + + /** + * @param Predis|Redis $redis The redis instance + * @param string $key The channel key to publish records to + */ + public function __construct(Predis|Redis $redis, string $key, int|string|Level $level = Level::Debug, bool $bubble = true) + { + $this->redisClient = $redis; + $this->channelKey = $key; + + parent::__construct($level, $bubble); + } + + /** + * @inheritDoc + */ + protected function write(LogRecord $record): void + { + $this->redisClient->publish($this->channelKey, $record->formatted); + } + + /** + * @inheritDoc + */ + protected function getDefaultFormatter(): FormatterInterface + { + return new LineFormatter(); + } +} diff --git a/src/Monolog/Handler/RollbarHandler.php b/src/Monolog/Handler/RollbarHandler.php index 2f35093cb..1d124723b 100644 --- a/src/Monolog/Handler/RollbarHandler.php +++ b/src/Monolog/Handler/RollbarHandler.php @@ -11,9 +11,10 @@ namespace Monolog\Handler; +use Monolog\Level; use Rollbar\RollbarLogger; use Throwable; -use Monolog\Logger; +use Monolog\LogRecord; /** * Sends errors to Rollbar @@ -33,37 +34,19 @@ */ class RollbarHandler extends AbstractProcessingHandler { - /** - * @var RollbarLogger - */ - protected $rollbarLogger; - - protected $levelMap = [ - Logger::DEBUG => 'debug', - Logger::INFO => 'info', - Logger::NOTICE => 'info', - Logger::WARNING => 'warning', - Logger::ERROR => 'error', - Logger::CRITICAL => 'critical', - Logger::ALERT => 'critical', - Logger::EMERGENCY => 'critical', - ]; + protected RollbarLogger $rollbarLogger; /** * Records whether any log records have been added since the last flush of the rollbar notifier - * - * @var bool */ - private $hasRecords = false; + private bool $hasRecords = false; - protected $initialized = false; + protected bool $initialized = false; /** * @param RollbarLogger $rollbarLogger RollbarLogger object constructed with valid token - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not */ - public function __construct(RollbarLogger $rollbarLogger, $level = Logger::ERROR, $bubble = true) + public function __construct(RollbarLogger $rollbarLogger, int|string|Level $level = Level::Error, bool $bubble = true) { $this->rollbarLogger = $rollbarLogger; @@ -71,22 +54,41 @@ public function __construct(RollbarLogger $rollbarLogger, $level = Logger::ERROR } /** - * {@inheritdoc} + * Translates Monolog log levels to Rollbar levels. + * + * @return 'debug'|'info'|'warning'|'error'|'critical' + */ + protected function toRollbarLevel(Level $level): string + { + return match ($level) { + Level::Debug => 'debug', + Level::Info => 'info', + Level::Notice => 'info', + Level::Warning => 'warning', + Level::Error => 'error', + Level::Critical => 'critical', + Level::Alert => 'critical', + Level::Emergency => 'critical', + }; + } + + /** + * @inheritDoc */ - protected function write(array $record) + protected function write(LogRecord $record): void { if (!$this->initialized) { // __destructor() doesn't get called on Fatal errors - register_shutdown_function(array($this, 'close')); + register_shutdown_function([$this, 'close']); $this->initialized = true; } - $context = $record['context']; - $context = array_merge($context, $record['extra'], [ - 'level' => $this->levelMap[$record['level']], - 'monolog_level' => $record['level_name'], - 'channel' => $record['channel'], - 'datetime' => $record['datetime']->format('U'), + $context = $record->context; + $context = array_merge($context, $record->extra, [ + 'level' => $this->toRollbarLevel($record->level), + 'monolog_level' => $record->level->getName(), + 'channel' => $record->channel, + 'datetime' => $record->datetime->format('U'), ]); if (isset($context['exception']) && $context['exception'] instanceof Throwable) { @@ -94,15 +96,16 @@ protected function write(array $record) unset($context['exception']); $toLog = $exception; } else { - $toLog = $record['message']; + $toLog = $record->message; } + // @phpstan-ignore-next-line $this->rollbarLogger->log($context['level'], $toLog, $context); $this->hasRecords = true; } - public function flush() + public function flush(): void { if ($this->hasRecords) { $this->rollbarLogger->flush(); @@ -111,10 +114,20 @@ public function flush() } /** - * {@inheritdoc} + * @inheritDoc + */ + public function close(): void + { + $this->flush(); + } + + /** + * @inheritDoc */ - public function close() + public function reset(): void { $this->flush(); + + parent::reset(); } } diff --git a/src/Monolog/Handler/RotatingFileHandler.php b/src/Monolog/Handler/RotatingFileHandler.php index d9733ba00..12ce69236 100644 --- a/src/Monolog/Handler/RotatingFileHandler.php +++ b/src/Monolog/Handler/RotatingFileHandler.php @@ -12,7 +12,9 @@ namespace Monolog\Handler; use InvalidArgumentException; -use Monolog\Logger; +use Monolog\Level; +use Monolog\Utils; +use Monolog\LogRecord; /** * Stores logs to files that are rotated every day and a limited number of files are kept. @@ -25,40 +27,37 @@ */ class RotatingFileHandler extends StreamHandler { - const FILE_PER_DAY = 'Y-m-d'; - const FILE_PER_MONTH = 'Y-m'; - const FILE_PER_YEAR = 'Y'; + public const FILE_PER_DAY = 'Y-m-d'; + public const FILE_PER_MONTH = 'Y-m'; + public const FILE_PER_YEAR = 'Y'; - protected $filename; - protected $maxFiles; - protected $mustRotate; - protected $nextRotation; - protected $filenameFormat; - protected $dateFormat; + protected string $filename; + protected int $maxFiles; + protected bool|null $mustRotate = null; + protected \DateTimeImmutable $nextRotation; + protected string $filenameFormat; + protected string $dateFormat; /** - * @param string $filename * @param int $maxFiles The maximal amount of files to keep (0 means unlimited) - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not * @param int|null $filePermission Optional file permissions (default (0644) are only for owner read/write) * @param bool $useLocking Try to lock log file before doing any writes */ - public function __construct($filename, $maxFiles = 0, $level = Logger::DEBUG, $bubble = true, $filePermission = null, $useLocking = false) + public function __construct(string $filename, int $maxFiles = 0, int|string|Level $level = Level::Debug, bool $bubble = true, ?int $filePermission = null, bool $useLocking = false) { - $this->filename = $filename; - $this->maxFiles = (int) $maxFiles; + $this->filename = Utils::canonicalizePath($filename); + $this->maxFiles = $maxFiles; $this->nextRotation = new \DateTimeImmutable('tomorrow'); $this->filenameFormat = '{filename}-{date}'; - $this->dateFormat = self::FILE_PER_DAY; + $this->dateFormat = static::FILE_PER_DAY; parent::__construct($this->getTimedFilename(), $level, $bubble, $filePermission, $useLocking); } /** - * {@inheritdoc} + * @inheritDoc */ - public function close() + public function close(): void { parent::close(); @@ -67,9 +66,21 @@ public function close() } } - public function setFilenameFormat($filenameFormat, $dateFormat) + /** + * @inheritDoc + */ + public function reset(): void { - if (!preg_match('{^Y(([/_.-]?m)([/_.-]?d)?)?$}', $dateFormat)) { + parent::reset(); + + if (true === $this->mustRotate) { + $this->rotate(); + } + } + + public function setFilenameFormat(string $filenameFormat, string $dateFormat): self + { + if (0 === preg_match('{^[Yy](([/_.-]?m)([/_.-]?d)?)?$}', $dateFormat)) { throw new InvalidArgumentException( 'Invalid date format - format must be one of '. 'RotatingFileHandler::FILE_PER_DAY ("Y-m-d"), RotatingFileHandler::FILE_PER_MONTH ("Y-m") '. @@ -86,19 +97,21 @@ public function setFilenameFormat($filenameFormat, $dateFormat) $this->dateFormat = $dateFormat; $this->url = $this->getTimedFilename(); $this->close(); + + return $this; } /** - * {@inheritdoc} + * @inheritDoc */ - protected function write(array $record) + protected function write(LogRecord $record): void { // on the first record written, if the log is new, we should rotate (once per day) if (null === $this->mustRotate) { - $this->mustRotate = !file_exists($this->url); + $this->mustRotate = null === $this->url || !file_exists($this->url); } - if ($this->nextRotation <= $record['datetime']) { + if ($this->nextRotation <= $record->datetime) { $this->mustRotate = true; $this->close(); } @@ -109,7 +122,7 @@ protected function write(array $record) /** * Rotates the files. */ - protected function rotate() + protected function rotate(): void { // update filename $this->url = $this->getTimedFilename(); @@ -121,6 +134,11 @@ protected function rotate() } $logFiles = glob($this->getGlobPattern()); + if (false === $logFiles) { + // failed to glob + return; + } + if ($this->maxFiles >= count($logFiles)) { // no files to remove return; @@ -135,7 +153,8 @@ protected function rotate() if (is_writable($file)) { // suppress errors here as unlink() might fail if two processes // are cleaning up/rotating at the same time - set_error_handler(function ($errno, $errstr, $errfile, $errline) { + set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline): bool { + return false; }); unlink($file); restore_error_handler(); @@ -145,7 +164,7 @@ protected function rotate() $this->mustRotate = false; } - protected function getTimedFilename() + protected function getTimedFilename(): string { $fileInfo = pathinfo($this->filename); $timedFilename = str_replace( @@ -154,22 +173,26 @@ protected function getTimedFilename() $fileInfo['dirname'] . '/' . $this->filenameFormat ); - if (!empty($fileInfo['extension'])) { + if (isset($fileInfo['extension'])) { $timedFilename .= '.'.$fileInfo['extension']; } return $timedFilename; } - protected function getGlobPattern() + protected function getGlobPattern(): string { $fileInfo = pathinfo($this->filename); $glob = str_replace( ['{filename}', '{date}'], - [$fileInfo['filename'], '[0-9][0-9][0-9][0-9]*'], + [$fileInfo['filename'], str_replace( + ['Y', 'y', 'm', 'd'], + ['[0-9][0-9][0-9][0-9]', '[0-9][0-9]', '[0-9][0-9]', '[0-9][0-9]'], + $this->dateFormat) + ], $fileInfo['dirname'] . '/' . $this->filenameFormat ); - if (!empty($fileInfo['extension'])) { + if (isset($fileInfo['extension'])) { $glob .= '.'.$fileInfo['extension']; } diff --git a/src/Monolog/Handler/SamplingHandler.php b/src/Monolog/Handler/SamplingHandler.php index 8a25cbb72..511ec5854 100644 --- a/src/Monolog/Handler/SamplingHandler.php +++ b/src/Monolog/Handler/SamplingHandler.php @@ -11,6 +11,10 @@ namespace Monolog\Handler; +use Closure; +use Monolog\Formatter\FormatterInterface; +use Monolog\LogRecord; + /** * Sampling handler * @@ -25,58 +29,93 @@ * @author Bryan Davis * @author Kunal Mehta */ -class SamplingHandler extends AbstractHandler implements ProcessableHandlerInterface +class SamplingHandler extends AbstractHandler implements ProcessableHandlerInterface, FormattableHandlerInterface { use ProcessableHandlerTrait; /** - * @var callable|HandlerInterface $handler + * Handler or factory Closure($record, $this) + * + * @phpstan-var (Closure(LogRecord|null, HandlerInterface): HandlerInterface)|HandlerInterface */ - protected $handler; + protected Closure|HandlerInterface $handler; - /** - * @var int $factor - */ - protected $factor; + protected int $factor; /** - * @param callable|HandlerInterface $handler Handler or factory callable($record, $fingersCrossedHandler). - * @param int $factor Sample factor + * @phpstan-param (Closure(LogRecord|null, HandlerInterface): HandlerInterface)|HandlerInterface $handler + * + * @param Closure|HandlerInterface $handler Handler or factory Closure($record|null, $samplingHandler). + * @param int $factor Sample factor (e.g. 10 means every ~10th record is sampled) */ - public function __construct($handler, $factor) + public function __construct(Closure|HandlerInterface $handler, int $factor) { parent::__construct(); $this->handler = $handler; $this->factor = $factor; - - if (!$this->handler instanceof HandlerInterface && !is_callable($this->handler)) { - throw new \RuntimeException("The given handler (".json_encode($this->handler).") is not a callable nor a Monolog\Handler\HandlerInterface object"); - } } - public function isHandling(array $record): bool + public function isHandling(LogRecord $record): bool { - return $this->handler->isHandling($record); + return $this->getHandler($record)->isHandling($record); } - public function handle(array $record): bool + public function handle(LogRecord $record): bool { if ($this->isHandling($record) && mt_rand(1, $this->factor) === 1) { - // The same logic as in FingersCrossedHandler - if (!$this->handler instanceof HandlerInterface) { - $this->handler = call_user_func($this->handler, $record, $this); - if (!$this->handler instanceof HandlerInterface) { - throw new \RuntimeException("The factory callable should return a HandlerInterface"); - } - } - - if ($this->processors) { + if (\count($this->processors) > 0) { $record = $this->processRecord($record); } - $this->handler->handle($record); + $this->getHandler($record)->handle($record); } return false === $this->bubble; } + + /** + * Return the nested handler + * + * If the handler was provided as a factory, this will trigger the handler's instantiation. + */ + public function getHandler(LogRecord $record = null): HandlerInterface + { + if (!$this->handler instanceof HandlerInterface) { + $handler = ($this->handler)($record, $this); + if (!$handler instanceof HandlerInterface) { + throw new \RuntimeException("The factory Closure should return a HandlerInterface"); + } + $this->handler = $handler; + } + + return $this->handler; + } + + /** + * @inheritDoc + */ + public function setFormatter(FormatterInterface $formatter): HandlerInterface + { + $handler = $this->getHandler(); + if ($handler instanceof FormattableHandlerInterface) { + $handler->setFormatter($formatter); + + return $this; + } + + throw new \UnexpectedValueException('The nested handler of type '.get_class($handler).' does not support formatters.'); + } + + /** + * @inheritDoc + */ + public function getFormatter(): FormatterInterface + { + $handler = $this->getHandler(); + if ($handler instanceof FormattableHandlerInterface) { + return $handler->getFormatter(); + } + + throw new \UnexpectedValueException('The nested handler of type '.get_class($handler).' does not support formatters.'); + } } diff --git a/src/Monolog/Handler/SendGridHandler.php b/src/Monolog/Handler/SendGridHandler.php index 7d82d9465..6228a02f2 100644 --- a/src/Monolog/Handler/SendGridHandler.php +++ b/src/Monolog/Handler/SendGridHandler.php @@ -11,7 +11,7 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; /** * SendGridrHandler uses the SendGrid API v2 function to send Log emails, more information in https://sendgrid.com/docs/API_Reference/Web_API/mail.html @@ -22,45 +22,43 @@ class SendGridHandler extends MailHandler { /** * The SendGrid API User - * @var string */ - protected $apiUser; + protected string $apiUser; /** * The SendGrid API Key - * @var string */ - protected $apiKey; + protected string $apiKey; /** * The email addresses to which the message will be sent - * @var string */ - protected $from; + protected string $from; /** * The email addresses to which the message will be sent - * @var array + * @var string[] */ - protected $to; + protected array $to; /** * The subject of the email - * @var string */ - protected $subject; + protected string $subject; /** - * @param string $apiUser The SendGrid API User - * @param string $apiKey The SendGrid API Key - * @param string $from The sender of the email - * @param string|array $to The recipients of the email - * @param string $subject The subject of the mail - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not + * @param string $apiUser The SendGrid API User + * @param string $apiKey The SendGrid API Key + * @param string $from The sender of the email + * @param string|string[] $to The recipients of the email + * @param string $subject The subject of the mail */ - public function __construct(string $apiUser, string $apiKey, string $from, $to, string $subject, int $level = Logger::ERROR, bool $bubble = true) + public function __construct(string $apiUser, string $apiKey, string $from, string|array $to, string $subject, int|string|Level $level = Level::Error, bool $bubble = true) { + if (!extension_loaded('curl')) { + throw new MissingExtensionException('The curl extension is needed to use the SendGridHandler'); + } + parent::__construct($level, $bubble); $this->apiUser = $apiUser; $this->apiKey = $apiKey; @@ -70,9 +68,9 @@ public function __construct(string $apiUser, string $apiKey, string $from, $to, } /** - * {@inheritdoc} + * @inheritDoc */ - protected function send(string $content, array $records) + protected function send(string $content, array $records): void { $message = []; $message['api_user'] = $this->apiUser; diff --git a/src/Monolog/Handler/Slack/SlackRecord.php b/src/Monolog/Handler/Slack/SlackRecord.php old mode 100755 new mode 100644 index 90286e888..7e9cccc9d --- a/src/Monolog/Handler/Slack/SlackRecord.php +++ b/src/Monolog/Handler/Slack/SlackRecord.php @@ -11,9 +11,11 @@ namespace Monolog\Handler\Slack; -use Monolog\Logger; +use Monolog\Level; +use Monolog\Utils; use Monolog\Formatter\NormalizerFormatter; use Monolog\Formatter\FormatterInterface; +use Monolog\LogRecord; /** * Slack record utility helping to log to Slack webhooks or API. @@ -25,147 +27,156 @@ */ class SlackRecord { - const COLOR_DANGER = 'danger'; + public const COLOR_DANGER = 'danger'; - const COLOR_WARNING = 'warning'; + public const COLOR_WARNING = 'warning'; - const COLOR_GOOD = 'good'; + public const COLOR_GOOD = 'good'; - const COLOR_DEFAULT = '#e3e4e6'; + public const COLOR_DEFAULT = '#e3e4e6'; /** * Slack channel (encoded ID or name) - * @var string|null */ - private $channel; + private string|null $channel; /** * Name of a bot - * @var string|null */ - private $username; + private string|null $username; /** * User icon e.g. 'ghost', 'http://example.com/user.png' - * @var string */ - private $userIcon; + private string|null $userIcon; /** * Whether the message should be added to Slack as attachment (plain text otherwise) - * @var bool */ - private $useAttachment; + private bool $useAttachment; /** * Whether the the context/extra messages added to Slack as attachments are in a short style - * @var bool */ - private $useShortAttachment; + private bool $useShortAttachment; /** * Whether the attachment should include context and extra data - * @var bool */ - private $includeContextAndExtra; + private bool $includeContextAndExtra; /** * Dot separated list of fields to exclude from slack message. E.g. ['context.field1', 'extra.field2'] - * @var array + * @var string[] */ - private $excludeFields; + private array $excludeFields; - /** - * @var FormatterInterface - */ - private $formatter; + private FormatterInterface|null $formatter; + + private NormalizerFormatter $normalizerFormatter; /** - * @var NormalizerFormatter + * @param string[] $excludeFields */ - private $normalizerFormatter; - - public function __construct($channel = null, $username = null, $useAttachment = true, $userIcon = null, $useShortAttachment = false, $includeContextAndExtra = false, array $excludeFields = array(), FormatterInterface $formatter = null) - { - $this->channel = $channel; - $this->username = $username; - $this->userIcon = $userIcon !== null ? trim($userIcon, ':') : null; - $this->useAttachment = $useAttachment; - $this->useShortAttachment = $useShortAttachment; - $this->includeContextAndExtra = $includeContextAndExtra; - $this->excludeFields = $excludeFields; - $this->formatter = $formatter; + public function __construct( + ?string $channel = null, + ?string $username = null, + bool $useAttachment = true, + ?string $userIcon = null, + bool $useShortAttachment = false, + bool $includeContextAndExtra = false, + array $excludeFields = [], + FormatterInterface $formatter = null + ) { + $this + ->setChannel($channel) + ->setUsername($username) + ->useAttachment($useAttachment) + ->setUserIcon($userIcon) + ->useShortAttachment($useShortAttachment) + ->includeContextAndExtra($includeContextAndExtra) + ->excludeFields($excludeFields) + ->setFormatter($formatter); if ($this->includeContextAndExtra) { $this->normalizerFormatter = new NormalizerFormatter(); } } - public function getSlackData(array $record) + /** + * Returns required data in format that Slack + * is expecting. + * + * @phpstan-return mixed[] + */ + public function getSlackData(LogRecord $record): array { - $dataArray = array(); - $record = $this->excludeFields($record); + $dataArray = []; - if ($this->username) { + if ($this->username !== null) { $dataArray['username'] = $this->username; } - if ($this->channel) { + if ($this->channel !== null) { $dataArray['channel'] = $this->channel; } - if ($this->formatter && !$this->useAttachment) { + if ($this->formatter !== null && !$this->useAttachment) { $message = $this->formatter->format($record); } else { - $message = $record['message']; + $message = $record->message; } + $recordData = $this->removeExcludedFields($record); + if ($this->useAttachment) { - $attachment = array( + $attachment = [ 'fallback' => $message, 'text' => $message, - 'color' => $this->getAttachmentColor($record['level']), - 'fields' => array(), - 'mrkdwn_in' => array('fields'), - 'ts' => $record['datetime']->getTimestamp(), - ); + 'color' => $this->getAttachmentColor($record->level), + 'fields' => [], + 'mrkdwn_in' => ['fields'], + 'ts' => $recordData['datetime']->getTimestamp(), + 'footer' => $this->username, + 'footer_icon' => $this->userIcon, + ]; if ($this->useShortAttachment) { - $attachment['title'] = $record['level_name']; + $attachment['title'] = $recordData['level_name']; } else { $attachment['title'] = 'Message'; - $attachment['fields'][] = $this->generateAttachmentField('Level', $record['level_name']); + $attachment['fields'][] = $this->generateAttachmentField('Level', $recordData['level_name']); } if ($this->includeContextAndExtra) { - foreach (array('extra', 'context') as $key) { - if (empty($record[$key])) { + foreach (['extra', 'context'] as $key) { + if (!isset($recordData[$key]) || \count($recordData[$key]) === 0) { continue; } if ($this->useShortAttachment) { $attachment['fields'][] = $this->generateAttachmentField( $key, - $record[$key] + $recordData[$key] ); } else { // Add all extra fields as individual fields in attachment $attachment['fields'] = array_merge( $attachment['fields'], - $this->generateAttachmentFields($record[$key]) + $this->generateAttachmentFields($recordData[$key]) ); } } } - $dataArray['attachments'] = array($attachment); + $dataArray['attachments'] = [$attachment]; } else { $dataArray['text'] = $message; } - if ($this->userIcon) { - if (filter_var($this->userIcon, FILTER_VALIDATE_URL)) { - $dataArray['icon_url'] = $this->userIcon; + if ($this->userIcon !== null) { + if (false !== ($iconUrl = filter_var($this->userIcon, FILTER_VALIDATE_URL))) { + $dataArray['icon_url'] = $iconUrl; } else { $dataArray['icon_emoji'] = ":{$this->userIcon}:"; } @@ -175,89 +186,153 @@ public function getSlackData(array $record) } /** - * Returned a Slack message attachment color associated with + * Returns a Slack message attachment color associated with * provided level. - * - * @param int $level - * @return string */ - public function getAttachmentColor($level) + public function getAttachmentColor(Level $level): string { - switch (true) { - case $level >= Logger::ERROR: - return self::COLOR_DANGER; - case $level >= Logger::WARNING: - return self::COLOR_WARNING; - case $level >= Logger::INFO: - return self::COLOR_GOOD; - default: - return self::COLOR_DEFAULT; - } + return match ($level) { + Level::Error, Level::Critical, Level::Alert, Level::Emergency => static::COLOR_DANGER, + Level::Warning => static::COLOR_WARNING, + Level::Info, Level::Notice => static::COLOR_GOOD, + Level::Debug => static::COLOR_DEFAULT + }; } /** * Stringifies an array of key/value pairs to be used in attachment fields * - * @param array $fields - * - * @return string + * @param mixed[] $fields */ - public function stringify($fields) + public function stringify(array $fields): string { - $normalized = $this->normalizerFormatter->format($fields); - $prettyPrintFlag = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 128; + /** @var array $normalized */ + $normalized = $this->normalizerFormatter->normalizeValue($fields); - $hasSecondDimension = count(array_filter($normalized, 'is_array')); - $hasNonNumericKeys = !count(array_filter(array_keys($normalized), 'is_numeric')); + $hasSecondDimension = \count(array_filter($normalized, 'is_array')) > 0; + $hasOnlyNonNumericKeys = \count(array_filter(array_keys($normalized), 'is_numeric')) === 0; - return $hasSecondDimension || $hasNonNumericKeys - ? json_encode($normalized, $prettyPrintFlag|JSON_UNESCAPED_UNICODE) - : json_encode($normalized, JSON_UNESCAPED_UNICODE); + return $hasSecondDimension || $hasOnlyNonNumericKeys + ? Utils::jsonEncode($normalized, JSON_PRETTY_PRINT|Utils::DEFAULT_JSON_FLAGS) + : Utils::jsonEncode($normalized, Utils::DEFAULT_JSON_FLAGS); } /** - * Sets the formatter + * Channel used by the bot when posting + * + * @param ?string $channel * - * @param FormatterInterface $formatter + * @return static */ - public function setFormatter(FormatterInterface $formatter) + public function setChannel(?string $channel = null): self + { + $this->channel = $channel; + + return $this; + } + + /** + * Username used by the bot when posting + * + * @param ?string $username + * + * @return static + */ + public function setUsername(?string $username = null): self + { + $this->username = $username; + + return $this; + } + + public function useAttachment(bool $useAttachment = true): self + { + $this->useAttachment = $useAttachment; + + return $this; + } + + public function setUserIcon(?string $userIcon = null): self + { + $this->userIcon = $userIcon; + + if (\is_string($userIcon)) { + $this->userIcon = trim($userIcon, ':'); + } + + return $this; + } + + public function useShortAttachment(bool $useShortAttachment = false): self + { + $this->useShortAttachment = $useShortAttachment; + + return $this; + } + + public function includeContextAndExtra(bool $includeContextAndExtra = false): self + { + $this->includeContextAndExtra = $includeContextAndExtra; + + if ($this->includeContextAndExtra) { + $this->normalizerFormatter = new NormalizerFormatter(); + } + + return $this; + } + + /** + * @param string[] $excludeFields + */ + public function excludeFields(array $excludeFields = []): self + { + $this->excludeFields = $excludeFields; + + return $this; + } + + public function setFormatter(?FormatterInterface $formatter = null): self { $this->formatter = $formatter; + + return $this; } /** * Generates attachment field * - * @param string $title - * @param string|array $value + * @param string|mixed[] $value * - * @return array + * @return array{title: string, value: string, short: false} */ - private function generateAttachmentField($title, $value) + private function generateAttachmentField(string $title, $value): array { $value = is_array($value) - ? sprintf('```%s```', $this->stringify($value)) + ? sprintf('```%s```', substr($this->stringify($value), 0, 1990)) : $value; - return array( + return [ 'title' => ucfirst($title), 'value' => $value, 'short' => false, - ); + ]; } /** * Generates a collection of attachment fields from array * - * @param array $data + * @param mixed[] $data * - * @return array + * @return array */ - private function generateAttachmentFields(array $data) + private function generateAttachmentFields(array $data): array { - $fields = array(); - foreach ($this->normalizerFormatter->format($data) as $key => $value) { - $fields[] = $this->generateAttachmentField($key, $value); + /** @var array $normalized */ + $normalized = $this->normalizerFormatter->normalizeValue($data); + + $fields = []; + foreach ($normalized as $key => $value) { + $fields[] = $this->generateAttachmentField((string) $key, $value); } return $fields; @@ -266,15 +341,14 @@ private function generateAttachmentFields(array $data) /** * Get a copy of record with fields excluded according to $this->excludeFields * - * @param array $record - * - * @return array + * @return mixed[] */ - private function excludeFields(array $record) + private function removeExcludedFields(LogRecord $record): array { + $recordData = $record->toArray(); foreach ($this->excludeFields as $field) { $keys = explode('.', $field); - $node = &$record; + $node = &$recordData; $lastKey = end($keys); foreach ($keys as $key) { if (!isset($node[$key])) { @@ -288,6 +362,6 @@ private function excludeFields(array $record) } } - return $record; + return $recordData; } } diff --git a/src/Monolog/Handler/SlackHandler.php b/src/Monolog/Handler/SlackHandler.php index 6f671ac12..321d8660f 100644 --- a/src/Monolog/Handler/SlackHandler.php +++ b/src/Monolog/Handler/SlackHandler.php @@ -12,8 +12,10 @@ namespace Monolog\Handler; use Monolog\Formatter\FormatterInterface; -use Monolog\Logger; +use Monolog\Level; +use Monolog\Utils; use Monolog\Handler\Slack\SlackRecord; +use Monolog\LogRecord; /** * Sends notifications through Slack API @@ -25,15 +27,13 @@ class SlackHandler extends SocketHandler { /** * Slack API token - * @var string */ - private $token; + private string $token; /** * Instance of the SlackRecord util class preparing data for Slack API. - * @var SlackRecord */ - private $slackRecord; + private SlackRecord $slackRecord; /** * @param string $token Slack API token @@ -41,20 +41,42 @@ class SlackHandler extends SocketHandler * @param string|null $username Name of a bot * @param bool $useAttachment Whether the message should be added to Slack as attachment (plain text otherwise) * @param string|null $iconEmoji The emoji name to use (or null) - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not - * @param bool $useShortAttachment Whether the the context/extra messages added to Slack as attachments are in a short style + * @param bool $useShortAttachment Whether the context/extra messages added to Slack as attachments are in a short style * @param bool $includeContextAndExtra Whether the attachment should include context and extra data - * @param array $excludeFields Dot separated list of fields to exclude from slack message. E.g. ['context.field1', 'extra.field2'] + * @param string[] $excludeFields Dot separated list of fields to exclude from slack message. E.g. ['context.field1', 'extra.field2'] * @throws MissingExtensionException If no OpenSSL PHP extension configured */ - public function __construct($token, $channel, $username = null, $useAttachment = true, $iconEmoji = null, $level = Logger::CRITICAL, $bubble = true, $useShortAttachment = false, $includeContextAndExtra = false, array $excludeFields = array()) - { + public function __construct( + string $token, + string $channel, + ?string $username = null, + bool $useAttachment = true, + ?string $iconEmoji = null, + $level = Level::Critical, + bool $bubble = true, + bool $useShortAttachment = false, + bool $includeContextAndExtra = false, + array $excludeFields = [], + bool $persistent = false, + float $timeout = 0.0, + float $writingTimeout = 10.0, + ?float $connectionTimeout = null, + ?int $chunkSize = null + ) { if (!extension_loaded('openssl')) { throw new MissingExtensionException('The OpenSSL PHP extension is required to use the SlackHandler'); } - parent::__construct('ssl://slack.com:443', $level, $bubble); + parent::__construct( + 'ssl://slack.com:443', + $level, + $bubble, + $persistent, + $timeout, + $writingTimeout, + $connectionTimeout, + $chunkSize + ); $this->slackRecord = new SlackRecord( $channel, @@ -69,23 +91,20 @@ public function __construct($token, $channel, $username = null, $useAttachment = $this->token = $token; } - public function getSlackRecord() + public function getSlackRecord(): SlackRecord { return $this->slackRecord; } - public function getToken() + public function getToken(): string { return $this->token; } /** - * {@inheritdoc} - * - * @param array $record - * @return string + * @inheritDoc */ - protected function generateDataStream($record) + protected function generateDataStream(LogRecord $record): string { $content = $this->buildContent($record); @@ -94,11 +113,8 @@ protected function generateDataStream($record) /** * Builds the body of API call - * - * @param array $record - * @return string */ - private function buildContent($record) + private function buildContent(LogRecord $record): string { $dataArray = $this->prepareContentData($record); @@ -106,18 +122,15 @@ private function buildContent($record) } /** - * Prepares content data - * - * @param array $record - * @return array + * @return string[] */ - protected function prepareContentData($record) + protected function prepareContentData(LogRecord $record): array { $dataArray = $this->slackRecord->getSlackData($record); $dataArray['token'] = $this->token; - if (!empty($dataArray['attachments'])) { - $dataArray['attachments'] = json_encode($dataArray['attachments']); + if (isset($dataArray['attachments']) && is_array($dataArray['attachments']) && \count($dataArray['attachments']) > 0) { + $dataArray['attachments'] = Utils::jsonEncode($dataArray['attachments']); } return $dataArray; @@ -125,11 +138,8 @@ protected function prepareContentData($record) /** * Builds the header of the API Call - * - * @param string $content - * @return string */ - private function buildHeader($content) + private function buildHeader(string $content): string { $header = "POST /api/chat.postMessage HTTP/1.1\r\n"; $header .= "Host: slack.com\r\n"; @@ -141,11 +151,9 @@ private function buildHeader($content) } /** - * {@inheritdoc} - * - * @param array $record + * @inheritDoc */ - protected function write(array $record) + protected function write(LogRecord $record): void { parent::write($record); $this->finalizeWrite(); @@ -157,7 +165,7 @@ protected function write(array $record) * If we do not read some but close the socket too early, slack sometimes * drops the request entirely. */ - protected function finalizeWrite() + protected function finalizeWrite(): void { $res = $this->getResource(); if (is_resource($res)) { @@ -166,54 +174,77 @@ protected function finalizeWrite() $this->closeSocket(); } + public function setFormatter(FormatterInterface $formatter): HandlerInterface + { + parent::setFormatter($formatter); + $this->slackRecord->setFormatter($formatter); + + return $this; + } + + public function getFormatter(): FormatterInterface + { + $formatter = parent::getFormatter(); + $this->slackRecord->setFormatter($formatter); + + return $formatter; + } + /** - * Returned a Slack message attachment color associated with - * provided level. - * - * @param int $level - * @return string - * @deprecated Use underlying SlackRecord instead + * Channel used by the bot when posting */ - protected function getAttachmentColor($level) + public function setChannel(string $channel): self { - trigger_error( - 'SlackHandler::getAttachmentColor() is deprecated. Use underlying SlackRecord instead.', - E_USER_DEPRECATED - ); + $this->slackRecord->setChannel($channel); - return $this->slackRecord->getAttachmentColor($level); + return $this; } /** - * Stringifies an array of key/value pairs to be used in attachment fields - * - * @param array $fields - * @return string - * @deprecated Use underlying SlackRecord instead + * Username used by the bot when posting */ - protected function stringify($fields) + public function setUsername(string $username): self { - trigger_error( - 'SlackHandler::stringify() is deprecated. Use underlying SlackRecord instead.', - E_USER_DEPRECATED - ); + $this->slackRecord->setUsername($username); - return $this->slackRecord->stringify($fields); + return $this; } - public function setFormatter(FormatterInterface $formatter): HandlerInterface + public function useAttachment(bool $useAttachment): self { - parent::setFormatter($formatter); - $this->slackRecord->setFormatter($formatter); + $this->slackRecord->useAttachment($useAttachment); return $this; } - public function getFormatter(): FormatterInterface + public function setIconEmoji(string $iconEmoji): self { - $formatter = parent::getFormatter(); - $this->slackRecord->setFormatter($formatter); + $this->slackRecord->setUserIcon($iconEmoji); - return $formatter; + return $this; + } + + public function useShortAttachment(bool $useShortAttachment): self + { + $this->slackRecord->useShortAttachment($useShortAttachment); + + return $this; + } + + public function includeContextAndExtra(bool $includeContextAndExtra): self + { + $this->slackRecord->includeContextAndExtra($includeContextAndExtra); + + return $this; + } + + /** + * @param string[] $excludeFields + */ + public function excludeFields(array $excludeFields): self + { + $this->slackRecord->excludeFields($excludeFields); + + return $this; } } diff --git a/src/Monolog/Handler/SlackWebhookHandler.php b/src/Monolog/Handler/SlackWebhookHandler.php index 2904db3f1..14ed6b1fb 100644 --- a/src/Monolog/Handler/SlackWebhookHandler.php +++ b/src/Monolog/Handler/SlackWebhookHandler.php @@ -12,8 +12,10 @@ namespace Monolog\Handler; use Monolog\Formatter\FormatterInterface; -use Monolog\Logger; +use Monolog\Level; +use Monolog\Utils; use Monolog\Handler\Slack\SlackRecord; +use Monolog\LogRecord; /** * Sends notifications through Slack Webhooks @@ -25,15 +27,13 @@ class SlackWebhookHandler extends AbstractProcessingHandler { /** * Slack Webhook token - * @var string */ - private $webhookUrl; + private string $webhookUrl; /** * Instance of the SlackRecord util class preparing data for Slack API. - * @var SlackRecord */ - private $slackRecord; + private SlackRecord $slackRecord; /** * @param string $webhookUrl Slack Webhook URL @@ -43,12 +43,24 @@ class SlackWebhookHandler extends AbstractProcessingHandler * @param string|null $iconEmoji The emoji name to use (or null) * @param bool $useShortAttachment Whether the the context/extra messages added to Slack as attachments are in a short style * @param bool $includeContextAndExtra Whether the attachment should include context and extra data - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not - * @param array $excludeFields Dot separated list of fields to exclude from slack message. E.g. ['context.field1', 'extra.field2'] + * @param string[] $excludeFields Dot separated list of fields to exclude from slack message. E.g. ['context.field1', 'extra.field2'] */ - public function __construct($webhookUrl, $channel = null, $username = null, $useAttachment = true, $iconEmoji = null, $useShortAttachment = false, $includeContextAndExtra = false, $level = Logger::CRITICAL, $bubble = true, array $excludeFields = array()) - { + public function __construct( + string $webhookUrl, + ?string $channel = null, + ?string $username = null, + bool $useAttachment = true, + ?string $iconEmoji = null, + bool $useShortAttachment = false, + bool $includeContextAndExtra = false, + $level = Level::Critical, + bool $bubble = true, + array $excludeFields = [] + ) { + if (!extension_loaded('curl')) { + throw new MissingExtensionException('The curl extension is needed to use the SlackWebhookHandler'); + } + parent::__construct($level, $bubble); $this->webhookUrl = $webhookUrl; @@ -64,34 +76,32 @@ public function __construct($webhookUrl, $channel = null, $username = null, $use ); } - public function getSlackRecord() + public function getSlackRecord(): SlackRecord { return $this->slackRecord; } - public function getWebhookUrl() + public function getWebhookUrl(): string { return $this->webhookUrl; } /** - * {@inheritdoc} - * - * @param array $record + * @inheritDoc */ - protected function write(array $record) + protected function write(LogRecord $record): void { $postData = $this->slackRecord->getSlackData($record); - $postString = json_encode($postData); + $postString = Utils::jsonEncode($postData); $ch = curl_init(); - $options = array( + $options = [ CURLOPT_URL => $this->webhookUrl, CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => array('Content-type: application/json'), + CURLOPT_HTTPHEADER => ['Content-type: application/json'], CURLOPT_POSTFIELDS => $postString, - ); + ]; if (defined('CURLOPT_SAFE_UPLOAD')) { $options[CURLOPT_SAFE_UPLOAD] = true; } diff --git a/src/Monolog/Handler/SlackbotHandler.php b/src/Monolog/Handler/SlackbotHandler.php deleted file mode 100644 index dd2db593e..000000000 --- a/src/Monolog/Handler/SlackbotHandler.php +++ /dev/null @@ -1,80 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Monolog\Handler; - -use Monolog\Logger; - -/** - * Sends notifications through Slack's Slackbot - * - * @author Haralan Dobrev - * @see https://slack.com/apps/A0F81R8ET-slackbot - */ -class SlackbotHandler extends AbstractProcessingHandler -{ - /** - * The slug of the Slack team - * @var string - */ - private $slackTeam; - - /** - * Slackbot token - * @var string - */ - private $token; - - /** - * Slack channel name - * @var string - */ - private $channel; - - /** - * @param string $slackTeam Slack team slug - * @param string $token Slackbot token - * @param string $channel Slack channel (encoded ID or name) - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not - */ - public function __construct($slackTeam, $token, $channel, $level = Logger::CRITICAL, $bubble = true) - { - parent::__construct($level, $bubble); - - $this->slackTeam = $slackTeam; - $this->token = $token; - $this->channel = $channel; - } - - /** - * {@inheritdoc} - * - * @param array $record - */ - protected function write(array $record) - { - $slackbotUrl = sprintf( - 'https://%s.slack.com/services/hooks/slackbot?token=%s&channel=%s', - $this->slackTeam, - $this->token, - $this->channel - ); - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $slackbotUrl); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, $record['message']); - - Curl\Util::execute($ch); - } -} diff --git a/src/Monolog/Handler/SocketHandler.php b/src/Monolog/Handler/SocketHandler.php index 36ea0b5f2..c5f708884 100644 --- a/src/Monolog/Handler/SocketHandler.php +++ b/src/Monolog/Handler/SocketHandler.php @@ -11,7 +11,8 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; +use Monolog\LogRecord; /** * Stores to any socket - uses fsockopen() or pfsockopen(). @@ -21,42 +22,65 @@ */ class SocketHandler extends AbstractProcessingHandler { - private $connectionString; - private $connectionTimeout; + private string $connectionString; + private float $connectionTimeout; /** @var resource|null */ private $resource; - /** @var float */ - private $timeout = 0; - /** @var float */ - private $writingTimeout = 10; - private $lastSentBytes = null; - private $chunkSize = null; - private $persistent = false; - private $errno; - private $errstr; - private $lastWritingAt; + private float $timeout; + private float $writingTimeout; + private int|null $lastSentBytes = null; + private int|null $chunkSize; + private bool $persistent; + private int|null $errno = null; + private string|null $errstr = null; + private float|null $lastWritingAt = null; /** - * @param string $connectionString Socket connection string - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not + * @param string $connectionString Socket connection string + * @param bool $persistent Flag to enable/disable persistent connections + * @param float $timeout Socket timeout to wait until the request is being aborted + * @param float $writingTimeout Socket timeout to wait until the request should've been sent/written + * @param float|null $connectionTimeout Socket connect timeout to wait until the connection should've been + * established + * @param int|null $chunkSize Sets the chunk size. Only has effect during connection in the writing cycle + * + * @throws \InvalidArgumentException If an invalid timeout value (less than 0) is passed. */ - public function __construct($connectionString, $level = Logger::DEBUG, $bubble = true) - { + public function __construct( + string $connectionString, + $level = Level::Debug, + bool $bubble = true, + bool $persistent = false, + float $timeout = 0.0, + float $writingTimeout = 10.0, + ?float $connectionTimeout = null, + ?int $chunkSize = null + ) { parent::__construct($level, $bubble); $this->connectionString = $connectionString; - $this->connectionTimeout = (float) ini_get('default_socket_timeout'); + + if ($connectionTimeout !== null) { + $this->validateTimeout($connectionTimeout); + } + + $this->connectionTimeout = $connectionTimeout ?? (float) ini_get('default_socket_timeout'); + $this->persistent = $persistent; + $this->validateTimeout($timeout); + $this->timeout = $timeout; + $this->validateTimeout($writingTimeout); + $this->writingTimeout = $writingTimeout; + $this->chunkSize = $chunkSize; } /** * Connect (if necessary) and write to the socket * - * @param array $record + * @inheritDoc * * @throws \UnexpectedValueException * @throws \RuntimeException */ - protected function write(array $record) + protected function write(LogRecord $record): void { $this->connectIfNotConnected(); $data = $this->generateDataStream($record); @@ -66,7 +90,7 @@ protected function write(array $record) /** * We will not close a PersistentSocket instance so it can be reused in other requests. */ - public function close() + public function close(): void { if (!$this->isPersistent()) { $this->closeSocket(); @@ -76,7 +100,7 @@ public function close() /** * Close socket, if open */ - public function closeSocket() + public function closeSocket(): void { if (is_resource($this->resource)) { fclose($this->resource); @@ -85,39 +109,39 @@ public function closeSocket() } /** - * Set socket connection to nbe persistent. It only has effect before the connection is initiated. - * - * @param bool $persistent + * Set socket connection to be persistent. It only has effect before the connection is initiated. */ - public function setPersistent($persistent) + public function setPersistent(bool $persistent): self { - $this->persistent = (bool) $persistent; + $this->persistent = $persistent; + + return $this; } /** * Set connection timeout. Only has effect before we connect. * - * @param float $seconds - * * @see http://php.net/manual/en/function.fsockopen.php */ - public function setConnectionTimeout($seconds) + public function setConnectionTimeout(float $seconds): self { $this->validateTimeout($seconds); - $this->connectionTimeout = (float) $seconds; + $this->connectionTimeout = $seconds; + + return $this; } /** * Set write timeout. Only has effect before we connect. * - * @param float $seconds - * * @see http://php.net/manual/en/function.stream-set-timeout.php */ - public function setTimeout($seconds) + public function setTimeout(float $seconds): self { $this->validateTimeout($seconds); - $this->timeout = (float) $seconds; + $this->timeout = $seconds; + + return $this; } /** @@ -125,38 +149,36 @@ public function setTimeout($seconds) * * @param float $seconds 0 for no timeout */ - public function setWritingTimeout($seconds) + public function setWritingTimeout(float $seconds): self { $this->validateTimeout($seconds); - $this->writingTimeout = (float) $seconds; + $this->writingTimeout = $seconds; + + return $this; } /** * Set chunk size. Only has effect during connection in the writing cycle. - * - * @param float $bytes */ - public function setChunkSize($bytes) + public function setChunkSize(int $bytes): self { $this->chunkSize = $bytes; + + return $this; } /** * Get current connection string - * - * @return string */ - public function getConnectionString() + public function getConnectionString(): string { return $this->connectionString; } /** * Get persistent setting - * - * @return bool */ - public function isPersistent() + public function isPersistent(): bool { return $this->persistent; } @@ -179,8 +201,6 @@ public function getTimeout(): float /** * Get current local writing timeout - * - * @return float */ public function getWritingTimeout(): float { @@ -189,10 +209,8 @@ public function getWritingTimeout(): float /** * Get current chunk size - * - * @return float */ - public function getChunkSize() + public function getChunkSize(): ?int { return $this->chunkSize; } @@ -201,10 +219,8 @@ public function getChunkSize() * Check to see if the socket is currently available. * * UDP might appear to be connected but might fail when writing. See http://php.net/fsockopen for details. - * - * @return bool */ - public function isConnected() + public function isConnected(): bool { return is_resource($this->resource) && !feof($this->resource); // on TCP - other party can close connection. @@ -212,6 +228,8 @@ public function isConnected() /** * Wrapper to allow mocking + * + * @return resource|false */ protected function pfsockopen() { @@ -220,6 +238,8 @@ protected function pfsockopen() /** * Wrapper to allow mocking + * + * @return resource|false */ protected function fsockopen() { @@ -231,11 +251,15 @@ protected function fsockopen() * * @see http://php.net/manual/en/function.stream-set-timeout.php */ - protected function streamSetTimeout() + protected function streamSetTimeout(): bool { $seconds = floor($this->timeout); $microseconds = round(($this->timeout - $seconds) * 1e6); + if (!is_resource($this->resource)) { + throw new \LogicException('streamSetTimeout called but $this->resource is not a resource'); + } + return stream_set_timeout($this->resource, (int) $seconds, (int) $microseconds); } @@ -243,37 +267,58 @@ protected function streamSetTimeout() * Wrapper to allow mocking * * @see http://php.net/manual/en/function.stream-set-chunk-size.php + * + * @return int|false */ - protected function streamSetChunkSize() + protected function streamSetChunkSize(): int|bool { + if (!is_resource($this->resource)) { + throw new \LogicException('streamSetChunkSize called but $this->resource is not a resource'); + } + + if (null === $this->chunkSize) { + throw new \LogicException('streamSetChunkSize called but $this->chunkSize is not set'); + } + return stream_set_chunk_size($this->resource, $this->chunkSize); } /** * Wrapper to allow mocking + * + * @return int|false */ - protected function fwrite($data) + protected function fwrite(string $data): int|bool { + if (!is_resource($this->resource)) { + throw new \LogicException('fwrite called but $this->resource is not a resource'); + } + return @fwrite($this->resource, $data); } /** * Wrapper to allow mocking + * + * @return mixed[]|bool */ - protected function streamGetMetadata() + protected function streamGetMetadata(): array|bool { + if (!is_resource($this->resource)) { + throw new \LogicException('streamGetMetadata called but $this->resource is not a resource'); + } + return stream_get_meta_data($this->resource); } - private function validateTimeout($value) + private function validateTimeout(float $value): void { - $ok = filter_var($value, FILTER_VALIDATE_FLOAT); - if ($ok === false || $value < 0) { + if ($value < 0) { throw new \InvalidArgumentException("Timeout must be 0 or a positive float (got $value)"); } } - private function connectIfNotConnected() + private function connectIfNotConnected(): void { if ($this->isConnected()) { return; @@ -281,9 +326,9 @@ private function connectIfNotConnected() $this->connect(); } - protected function generateDataStream($record) + protected function generateDataStream(LogRecord $record): string { - return (string) $record['formatted']; + return (string) $record->formatted; } /** @@ -294,41 +339,41 @@ protected function getResource() return $this->resource; } - private function connect() + private function connect(): void { $this->createSocketResource(); $this->setSocketTimeout(); $this->setStreamChunkSize(); } - private function createSocketResource() + private function createSocketResource(): void { if ($this->isPersistent()) { $resource = $this->pfsockopen(); } else { $resource = $this->fsockopen(); } - if (!$resource) { + if (is_bool($resource)) { throw new \UnexpectedValueException("Failed connecting to $this->connectionString ($this->errno: $this->errstr)"); } $this->resource = $resource; } - private function setSocketTimeout() + private function setSocketTimeout(): void { if (!$this->streamSetTimeout()) { throw new \UnexpectedValueException("Failed setting timeout with stream_set_timeout()"); } } - private function setStreamChunkSize() + private function setStreamChunkSize(): void { - if ($this->chunkSize && !$this->streamSetChunkSize()) { + if (null !== $this->chunkSize && false === $this->streamSetChunkSize()) { throw new \UnexpectedValueException("Failed setting chunk size with stream_set_chunk_size()"); } } - private function writeToSocket($data) + private function writeToSocket(string $data): void { $length = strlen($data); $sent = 0; @@ -344,7 +389,7 @@ private function writeToSocket($data) } $sent += $chunk; $socketInfo = $this->streamGetMetadata(); - if ($socketInfo['timed_out']) { + if (is_array($socketInfo) && (bool) $socketInfo['timed_out']) { throw new \RuntimeException("Write timed-out"); } @@ -357,15 +402,15 @@ private function writeToSocket($data) } } - private function writingIsTimedOut($sent) + private function writingIsTimedOut(int $sent): bool { - $writingTimeout = (int) floor($this->writingTimeout); - if (0 === $writingTimeout) { + // convert to ms + if (0.0 == $this->writingTimeout) { return false; } if ($sent !== $this->lastSentBytes) { - $this->lastWritingAt = time(); + $this->lastWritingAt = microtime(true); $this->lastSentBytes = $sent; return false; @@ -373,7 +418,7 @@ private function writingIsTimedOut($sent) usleep(100); } - if ((time() - $this->lastWritingAt) >= $writingTimeout) { + if ((microtime(true) - (float) $this->lastWritingAt) >= $this->writingTimeout) { $this->closeSocket(); return true; diff --git a/src/Monolog/Handler/SqsHandler.php b/src/Monolog/Handler/SqsHandler.php index ed595c714..b4512a601 100644 --- a/src/Monolog/Handler/SqsHandler.php +++ b/src/Monolog/Handler/SqsHandler.php @@ -12,7 +12,9 @@ namespace Monolog\Handler; use Aws\Sqs\SqsClient; -use Monolog\Logger; +use Monolog\Level; +use Monolog\Utils; +use Monolog\LogRecord; /** * Writes to any sqs queue. @@ -22,16 +24,14 @@ class SqsHandler extends AbstractProcessingHandler { /** 256 KB in bytes - maximum message size in SQS */ - const MAX_MESSAGE_SIZE = 262144; + protected const MAX_MESSAGE_SIZE = 262144; /** 100 KB in bytes - head message size for new error log */ - const HEAD_MESSAGE_SIZE = 102400; + protected const HEAD_MESSAGE_SIZE = 102400; - /** @var SqsClient */ - private $client; - /** @var string */ - private $queueUrl; + private SqsClient $client; + private string $queueUrl; - public function __construct(SqsClient $sqsClient, $queueUrl, $level = Logger::DEBUG, $bubble = true) + public function __construct(SqsClient $sqsClient, string $queueUrl, int|string|Level $level = Level::Debug, bool $bubble = true) { parent::__construct($level, $bubble); @@ -40,19 +40,17 @@ public function __construct(SqsClient $sqsClient, $queueUrl, $level = Logger::DE } /** - * Writes the record down to the log of the implementing handler. - * - * @param array $record + * @inheritDoc */ - protected function write(array $record) + protected function write(LogRecord $record): void { - if (!isset($record['formatted']) || 'string' !== gettype($record['formatted'])) { - throw new \InvalidArgumentException('SqsHandler accepts only formatted records as a string'); + if (!isset($record->formatted) || 'string' !== gettype($record->formatted)) { + throw new \InvalidArgumentException('SqsHandler accepts only formatted records as a string' . Utils::getRecordMessageForException($record)); } - $messageBody = $record['formatted']; - if (strlen($messageBody) >= self::MAX_MESSAGE_SIZE) { - $messageBody = substr($messageBody, 0, self::HEAD_MESSAGE_SIZE); + $messageBody = $record->formatted; + if (strlen($messageBody) >= static::MAX_MESSAGE_SIZE) { + $messageBody = Utils::substr($messageBody, 0, static::HEAD_MESSAGE_SIZE); } $this->client->sendMessage([ diff --git a/src/Monolog/Handler/StreamHandler.php b/src/Monolog/Handler/StreamHandler.php index 6631ef4e1..027a7217d 100644 --- a/src/Monolog/Handler/StreamHandler.php +++ b/src/Monolog/Handler/StreamHandler.php @@ -11,7 +11,9 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; +use Monolog\Utils; +use Monolog\LogRecord; /** * Stores to any stream resource @@ -22,32 +24,49 @@ */ class StreamHandler extends AbstractProcessingHandler { + protected const MAX_CHUNK_SIZE = 2147483647; + /** 10MB */ + protected const DEFAULT_CHUNK_SIZE = 10 * 1024 * 1024; + protected int $streamChunkSize; /** @var resource|null */ protected $stream; - protected $url; - /** @var string|null */ - private $errorMessage; - protected $filePermission; - protected $useLocking; - private $dirCreated; + protected string|null $url = null; + private string|null $errorMessage = null; + protected int|null $filePermission; + protected bool $useLocking; + /** @var true|null */ + private bool|null $dirCreated = null; /** - * @param resource|string $stream - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not + * @param resource|string $stream If a missing path can't be created, an UnexpectedValueException will be thrown on first write * @param int|null $filePermission Optional file permissions (default (0644) are only for owner read/write) * @param bool $useLocking Try to lock log file before doing any writes * - * @throws \Exception If a missing directory is not buildable * @throws \InvalidArgumentException If stream is not a resource or string */ - public function __construct($stream, $level = Logger::DEBUG, $bubble = true, $filePermission = null, $useLocking = false) + public function __construct($stream, int|string|Level $level = Level::Debug, bool $bubble = true, ?int $filePermission = null, bool $useLocking = false) { parent::__construct($level, $bubble); + + if (($phpMemoryLimit = Utils::expandIniShorthandBytes(ini_get('memory_limit'))) !== false) { + if ($phpMemoryLimit > 0) { + // use max 10% of allowed memory for the chunk size, and at least 100KB + $this->streamChunkSize = min(static::MAX_CHUNK_SIZE, max((int) ($phpMemoryLimit / 10), 100 * 1024)); + } else { + // memory is unlimited, set to the default 10MB + $this->streamChunkSize = static::DEFAULT_CHUNK_SIZE; + } + } else { + // no memory limit information, set to the default 10MB + $this->streamChunkSize = static::DEFAULT_CHUNK_SIZE; + } + if (is_resource($stream)) { $this->stream = $stream; + + stream_set_chunk_size($this->stream, $this->streamChunkSize); } elseif (is_string($stream)) { - $this->url = $stream; + $this->url = Utils::canonicalizePath($stream); } else { throw new \InvalidArgumentException('A stream must either be a resource or a string.'); } @@ -57,14 +76,15 @@ public function __construct($stream, $level = Logger::DEBUG, $bubble = true, $fi } /** - * {@inheritdoc} + * @inheritDoc */ - public function close() + public function close(): void { - if ($this->url && is_resource($this->stream)) { + if (null !== $this->url && is_resource($this->stream)) { fclose($this->stream); } $this->stream = null; + $this->dirCreated = null; } /** @@ -79,71 +99,74 @@ public function getStream() /** * Return the stream URL if it was configured with a URL and not an active resource - * - * @return string|null */ - public function getUrl() + public function getUrl(): ?string { return $this->url; } + public function getStreamChunkSize(): int + { + return $this->streamChunkSize; + } + /** - * {@inheritdoc} + * @inheritDoc */ - protected function write(array $record) + protected function write(LogRecord $record): void { if (!is_resource($this->stream)) { - if (null === $this->url || '' === $this->url) { - throw new \LogicException('Missing stream url, the stream can not be opened. This may be caused by a premature call to close().'); + $url = $this->url; + if (null === $url || '' === $url) { + throw new \LogicException('Missing stream url, the stream can not be opened. This may be caused by a premature call to close().' . Utils::getRecordMessageForException($record)); } - $this->createDir(); + $this->createDir($url); $this->errorMessage = null; set_error_handler([$this, 'customErrorHandler']); - $this->stream = fopen($this->url, 'a'); + $stream = fopen($url, 'a'); if ($this->filePermission !== null) { - @chmod($this->url, $this->filePermission); + @chmod($url, $this->filePermission); } restore_error_handler(); - if (!is_resource($this->stream)) { + if (!is_resource($stream)) { $this->stream = null; - throw new \UnexpectedValueException(sprintf('The stream or file "%s" could not be opened: '.$this->errorMessage, $this->url)); + throw new \UnexpectedValueException(sprintf('The stream or file "%s" could not be opened in append mode: '.$this->errorMessage, $url) . Utils::getRecordMessageForException($record)); } + stream_set_chunk_size($stream, $this->streamChunkSize); + $this->stream = $stream; } + $stream = $this->stream; if ($this->useLocking) { // ignoring errors here, there's not much we can do about them - flock($this->stream, LOCK_EX); + flock($stream, LOCK_EX); } - $this->streamWrite($this->stream, $record); + $this->streamWrite($stream, $record); if ($this->useLocking) { - flock($this->stream, LOCK_UN); + flock($stream, LOCK_UN); } } /** * Write to stream * @param resource $stream - * @param array $record */ - protected function streamWrite($stream, array $record) + protected function streamWrite($stream, LogRecord $record): void { - fwrite($stream, (string) $record['formatted']); + fwrite($stream, (string) $record->formatted); } - private function customErrorHandler($code, $msg) + private function customErrorHandler(int $code, string $msg): bool { $this->errorMessage = preg_replace('{^(fopen|mkdir)\(.*?\): }', '', $msg); + + return true; } - /** - * @param string $stream - * - * @return null|string - */ - private function getDirFromStream($stream) + private function getDirFromStream(string $stream): ?string { $pos = strpos($stream, '://'); if ($pos === false) { @@ -154,24 +177,24 @@ private function getDirFromStream($stream) return dirname(substr($stream, 7)); } - return; + return null; } - private function createDir() + private function createDir(string $url): void { // Do not try to create dir if it has already been tried. - if ($this->dirCreated) { + if (true === $this->dirCreated) { return; } - $dir = $this->getDirFromStream($this->url); + $dir = $this->getDirFromStream($url); if (null !== $dir && !is_dir($dir)) { $this->errorMessage = null; set_error_handler([$this, 'customErrorHandler']); $status = mkdir($dir, 0777, true); restore_error_handler(); - if (false === $status && !is_dir($dir)) { - throw new \UnexpectedValueException(sprintf('There is no existing directory at "%s" and its not buildable: '.$this->errorMessage, $dir)); + if (false === $status && !is_dir($dir) && strpos((string) $this->errorMessage, 'File exists') === false) { + throw new \UnexpectedValueException(sprintf('There is no existing directory at "%s" and it could not be created: '.$this->errorMessage, $dir)); } } $this->dirCreated = true; diff --git a/src/Monolog/Handler/SwiftMailerHandler.php b/src/Monolog/Handler/SwiftMailerHandler.php deleted file mode 100644 index 73ef3cfc9..000000000 --- a/src/Monolog/Handler/SwiftMailerHandler.php +++ /dev/null @@ -1,102 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Monolog\Handler; - -use Monolog\Logger; -use Monolog\Formatter\FormatterInterface; -use Monolog\Formatter\LineFormatter; -use Swift_Message; -use Swift; - -/** - * SwiftMailerHandler uses Swift_Mailer to send the emails - * - * @author Gyula Sallai - */ -class SwiftMailerHandler extends MailHandler -{ - protected $mailer; - private $messageTemplate; - - /** - * @param \Swift_Mailer $mailer The mailer to use - * @param callable|Swift_Message $message An example message for real messages, only the body will be replaced - * @param int|string $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not - */ - public function __construct(\Swift_Mailer $mailer, $message, $level = Logger::ERROR, bool $bubble = true) - { - parent::__construct($level, $bubble); - - $this->mailer = $mailer; - $this->messageTemplate = $message; - } - - /** - * {@inheritdoc} - */ - protected function send(string $content, array $records) - { - $this->mailer->send($this->buildMessage($content, $records)); - } - - /** - * Gets the formatter for the Swift_Message subject. - * - * @param string $format The format of the subject - */ - protected function getSubjectFormatter(string $format): FormatterInterface - { - return new LineFormatter($format); - } - - /** - * Creates instance of Swift_Message to be sent - * - * @param string $content formatted email body to be sent - * @param array $records Log records that formed the content - * @return Swift_Message - */ - protected function buildMessage(string $content, array $records): Swift_Message - { - $message = null; - if ($this->messageTemplate instanceof Swift_Message) { - $message = clone $this->messageTemplate; - $message->generateId(); - } elseif (is_callable($this->messageTemplate)) { - $message = call_user_func($this->messageTemplate, $content, $records); - } - - if (!$message instanceof Swift_Message) { - throw new \InvalidArgumentException('Could not resolve message as instance of Swift_Message or a callable returning it'); - } - - if ($records) { - $subjectFormatter = $this->getSubjectFormatter($message->getSubject()); - $message->setSubject($subjectFormatter->format($this->getHighestRecord($records))); - } - - $mime = null; - if ($this->isHtmlBody($content)) { - $mime = 'text/html'; - } - - $message->setBody($content, $mime); - if (version_compare(Swift::VERSION, '6.0.0', '>=')) { - $message->setDate(new \DateTimeImmutable()); - } else { - $message->setDate(time()); - } - - return $message; - } -} diff --git a/src/Monolog/Handler/SymfonyMailerHandler.php b/src/Monolog/Handler/SymfonyMailerHandler.php new file mode 100644 index 000000000..842b6577f --- /dev/null +++ b/src/Monolog/Handler/SymfonyMailerHandler.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Handler; + +use Closure; +use Monolog\Level; +use Monolog\Logger; +use Monolog\LogRecord; +use Monolog\Utils; +use Monolog\Formatter\FormatterInterface; +use Monolog\Formatter\LineFormatter; +use Symfony\Component\Mailer\MailerInterface; +use Symfony\Component\Mailer\Transport\TransportInterface; +use Symfony\Component\Mime\Email; + +/** + * SymfonyMailerHandler uses Symfony's Mailer component to send the emails + * + * @author Jordi Boggiano + */ +class SymfonyMailerHandler extends MailHandler +{ + protected MailerInterface|TransportInterface $mailer; + /** @var Email|Closure(string, LogRecord[]): Email */ + private Email|Closure $emailTemplate; + + /** + * @phpstan-param Email|Closure(string, LogRecord[]): Email $email + * + * @param MailerInterface|TransportInterface $mailer The mailer to use + * @param Closure|Email $email An email template, the subject/body will be replaced + */ + public function __construct($mailer, Email|Closure $email, int|string|Level $level = Level::Error, bool $bubble = true) + { + parent::__construct($level, $bubble); + + $this->mailer = $mailer; + $this->emailTemplate = $email; + } + + /** + * {@inheritDoc} + */ + protected function send(string $content, array $records): void + { + $this->mailer->send($this->buildMessage($content, $records)); + } + + /** + * Gets the formatter for the Swift_Message subject. + * + * @param string|null $format The format of the subject + */ + protected function getSubjectFormatter(?string $format): FormatterInterface + { + return new LineFormatter($format); + } + + /** + * Creates instance of Email to be sent + * + * @param string $content formatted email body to be sent + * @param LogRecord[] $records Log records that formed the content + */ + protected function buildMessage(string $content, array $records): Email + { + $message = null; + if ($this->emailTemplate instanceof Email) { + $message = clone $this->emailTemplate; + } elseif (is_callable($this->emailTemplate)) { + $message = ($this->emailTemplate)($content, $records); + } + + if (!$message instanceof Email) { + $record = reset($records); + throw new \InvalidArgumentException('Could not resolve message as instance of Email or a callable returning it' . ($record instanceof LogRecord ? Utils::getRecordMessageForException($record) : '')); + } + + if (\count($records) > 0) { + $subjectFormatter = $this->getSubjectFormatter($message->getSubject()); + $message->subject($subjectFormatter->format($this->getHighestRecord($records))); + } + + if ($this->isHtmlBody($content)) { + if (null !== ($charset = $message->getHtmlCharset())) { + $message->html($content, $charset); + } else { + $message->html($content); + } + } else { + if (null !== ($charset = $message->getTextCharset())) { + $message->text($content, $charset); + } else { + $message->text($content); + } + } + + return $message->date(new \DateTimeImmutable()); + } +} diff --git a/src/Monolog/Handler/SyslogHandler.php b/src/Monolog/Handler/SyslogHandler.php index df7c89a85..0816a0119 100644 --- a/src/Monolog/Handler/SyslogHandler.php +++ b/src/Monolog/Handler/SyslogHandler.php @@ -11,7 +11,9 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; +use Monolog\Utils; +use Monolog\LogRecord; /** * Logs to syslog service. @@ -28,17 +30,14 @@ */ class SyslogHandler extends AbstractSyslogHandler { - protected $ident; - protected $logopts; + protected string $ident; + protected int $logopts; /** - * @param string $ident - * @param mixed $facility - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not - * @param int $logopts Option flags for the openlog() call, defaults to LOG_PID + * @param string|int $facility Either one of the names of the keys in $this->facilities, or a LOG_* facility constant + * @param int $logopts Option flags for the openlog() call, defaults to LOG_PID */ - public function __construct($ident, $facility = LOG_USER, $level = Logger::DEBUG, $bubble = true, $logopts = LOG_PID) + public function __construct(string $ident, string|int $facility = LOG_USER, int|string|Level $level = Level::Debug, bool $bubble = true, int $logopts = LOG_PID) { parent::__construct($facility, $level, $bubble); @@ -47,21 +46,21 @@ public function __construct($ident, $facility = LOG_USER, $level = Logger::DEBUG } /** - * {@inheritdoc} + * @inheritDoc */ - public function close() + public function close(): void { closelog(); } /** - * {@inheritdoc} + * @inheritDoc */ - protected function write(array $record) + protected function write(LogRecord $record): void { if (!openlog($this->ident, $this->logopts, $this->facility)) { - throw new \LogicException('Can\'t open syslog for ident "'.$this->ident.'" and facility "'.$this->facility.'"'); + throw new \LogicException('Can\'t open syslog for ident "'.$this->ident.'" and facility "'.$this->facility.'"' . Utils::getRecordMessageForException($record)); } - syslog($this->logLevels[$record['level']], (string) $record['formatted']); + syslog($this->toSyslogPriority($record->level), (string) $record->formatted); } } diff --git a/src/Monolog/Handler/SyslogUdp/UdpSocket.php b/src/Monolog/Handler/SyslogUdp/UdpSocket.php index f1801b375..6a4833450 100644 --- a/src/Monolog/Handler/SyslogUdp/UdpSocket.php +++ b/src/Monolog/Handler/SyslogUdp/UdpSocket.php @@ -11,48 +11,67 @@ namespace Monolog\Handler\SyslogUdp; +use Monolog\Utils; +use Socket; + class UdpSocket { - const DATAGRAM_MAX_LENGTH = 65023; - - protected $ip; - protected $port; + protected const DATAGRAM_MAX_LENGTH = 65023; - /** @var resource|null */ - protected $socket; + protected string $ip; + protected int $port; + protected ?Socket $socket = null; - public function __construct($ip, $port = 514) + public function __construct(string $ip, int $port = 514) { $this->ip = $ip; $this->port = $port; - $this->socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP); } - public function write($line, $header = "") + public function write(string $line, string $header = ""): void { $this->send($this->assembleMessage($line, $header)); } - public function close() + public function close(): void { - if (is_resource($this->socket)) { + if ($this->socket instanceof Socket) { socket_close($this->socket); $this->socket = null; } } - protected function send($chunk) + protected function getSocket(): Socket { - if (!is_resource($this->socket)) { - throw new \RuntimeException('The UdpSocket to '.$this->ip.':'.$this->port.' has been closed and can not be written to anymore'); + if (null !== $this->socket) { + return $this->socket; + } + + $domain = AF_INET; + $protocol = SOL_UDP; + // Check if we are using unix sockets. + if ($this->port === 0) { + $domain = AF_UNIX; + $protocol = IPPROTO_IP; + } + + $socket = socket_create($domain, SOCK_DGRAM, $protocol); + if ($socket instanceof Socket) { + return $this->socket = $socket; } - socket_sendto($this->socket, $chunk, strlen($chunk), $flags = 0, $this->ip, $this->port); + + throw new \RuntimeException('The UdpSocket to '.$this->ip.':'.$this->port.' could not be opened via socket_create'); + } + + protected function send(string $chunk): void + { + socket_sendto($this->getSocket(), $chunk, strlen($chunk), $flags = 0, $this->ip, $this->port); } - protected function assembleMessage($line, $header) + protected function assembleMessage(string $line, string $header): string { - $chunkSize = self::DATAGRAM_MAX_LENGTH - strlen($header); + $chunkSize = static::DATAGRAM_MAX_LENGTH - strlen($header); - return $header . substr($line, 0, $chunkSize); + return $header . Utils::substr($line, 0, $chunkSize); } } diff --git a/src/Monolog/Handler/SyslogUdpHandler.php b/src/Monolog/Handler/SyslogUdpHandler.php index dc8440200..abb8be9b2 100644 --- a/src/Monolog/Handler/SyslogUdpHandler.php +++ b/src/Monolog/Handler/SyslogUdpHandler.php @@ -11,93 +11,142 @@ namespace Monolog\Handler; -use Monolog\Logger; +use DateTimeInterface; use Monolog\Handler\SyslogUdp\UdpSocket; +use Monolog\Level; +use Monolog\LogRecord; +use Monolog\Utils; /** * A Handler for logging to a remote syslogd server. * * @author Jesper Skovgaard Nielsen + * @author Dominik Kukacka */ class SyslogUdpHandler extends AbstractSyslogHandler { - protected $socket; - protected $ident; + const RFC3164 = 0; + const RFC5424 = 1; + const RFC5424e = 2; + + /** @var array */ + private array $dateFormats = [ + self::RFC3164 => 'M d H:i:s', + self::RFC5424 => \DateTime::RFC3339, + self::RFC5424e => \DateTime::RFC3339_EXTENDED, + ]; + + protected UdpSocket $socket; + protected string $ident; + /** @var self::RFC* */ + protected int $rfc; /** - * @param string $host - * @param int $port - * @param mixed $facility - * @param int $level The minimum logging level at which this handler will be triggered - * @param bool $bubble Whether the messages that are handled can bubble up the stack or not - * @param string $ident Program name or tag for each log message. + * @param string $host Either IP/hostname or a path to a unix socket (port must be 0 then) + * @param int $port Port number, or 0 if $host is a unix socket + * @param string|int $facility Either one of the names of the keys in $this->facilities, or a LOG_* facility constant + * @param bool $bubble Whether the messages that are handled can bubble up the stack or not + * @param string $ident Program name or tag for each log message. + * @param int $rfc RFC to format the message for. + * @throws MissingExtensionException + * + * @phpstan-param self::RFC* $rfc */ - public function __construct($host, $port = 514, $facility = LOG_USER, $level = Logger::DEBUG, $bubble = true, $ident = 'php') + public function __construct(string $host, int $port = 514, string|int $facility = LOG_USER, int|string|Level $level = Level::Debug, bool $bubble = true, string $ident = 'php', int $rfc = self::RFC5424) { + if (!extension_loaded('sockets')) { + throw new MissingExtensionException('The sockets extension is required to use the SyslogUdpHandler'); + } + parent::__construct($facility, $level, $bubble); $this->ident = $ident; + $this->rfc = $rfc; - $this->socket = new UdpSocket($host, $port ?: 514); + $this->socket = new UdpSocket($host, $port); } - protected function write(array $record) + protected function write(LogRecord $record): void { - $lines = $this->splitMessageIntoLines($record['formatted']); + $lines = $this->splitMessageIntoLines($record->formatted); - $header = $this->makeCommonSyslogHeader($this->logLevels[$record['level']]); + $header = $this->makeCommonSyslogHeader($this->toSyslogPriority($record->level), $record->datetime); foreach ($lines as $line) { $this->socket->write($line, $header); } } - public function close() + public function close(): void { $this->socket->close(); } + /** + * @param string|string[] $message + * @return string[] + */ private function splitMessageIntoLines($message): array { if (is_array($message)) { $message = implode("\n", $message); } - return preg_split('/$\R?^/m', (string) $message, -1, PREG_SPLIT_NO_EMPTY); + $lines = preg_split('/$\R?^/m', (string) $message, -1, PREG_SPLIT_NO_EMPTY); + if (false === $lines) { + $pcreErrorCode = preg_last_error(); + + throw new \RuntimeException('Could not preg_split: ' . $pcreErrorCode . ' / ' . Utils::pcreLastErrorMessage($pcreErrorCode)); + } + + return $lines; } /** - * Make common syslog header (see rfc5424) + * Make common syslog header (see rfc5424 or rfc3164) */ - protected function makeCommonSyslogHeader($severity): string + protected function makeCommonSyslogHeader(int $severity, DateTimeInterface $datetime): string { $priority = $severity + $this->facility; - if (!$pid = getmypid()) { + $pid = getmypid(); + if (false === $pid) { $pid = '-'; } - if (!$hostname = gethostname()) { + $hostname = gethostname(); + if (false === $hostname) { $hostname = '-'; } + if ($this->rfc === self::RFC3164) { + // see https://github.com/phpstan/phpstan/issues/5348 + // @phpstan-ignore-next-line + $dateNew = $datetime->setTimezone(new \DateTimeZone('UTC')); + $date = $dateNew->format($this->dateFormats[$this->rfc]); + + return "<$priority>" . + $date . " " . + $hostname . " " . + $this->ident . "[" . $pid . "]: "; + } + + $date = $datetime->format($this->dateFormats[$this->rfc]); + return "<$priority>1 " . - $this->getDateTime() . " " . + $date . " " . $hostname . " " . $this->ident . " " . $pid . " - - "; } - protected function getDateTime() - { - return date(\DateTime::RFC3339); - } - /** * Inject your own socket, mainly used for testing */ - public function setSocket(UdpSocket $socket) + public function setSocket(UdpSocket $socket): self { $this->socket = $socket; + + return $this; } } diff --git a/src/Monolog/Handler/TelegramBotHandler.php b/src/Monolog/Handler/TelegramBotHandler.php new file mode 100644 index 000000000..2e1be9f6b --- /dev/null +++ b/src/Monolog/Handler/TelegramBotHandler.php @@ -0,0 +1,259 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Handler; + +use RuntimeException; +use Monolog\Level; +use Monolog\Utils; +use Monolog\LogRecord; + +/** + * Handler send logs to Telegram using Telegram Bot API. + * + * How to use: + * 1) Create telegram bot with https://telegram.me/BotFather + * 2) Create a telegram channel where logs will be recorded. + * 3) Add created bot from step 1 to the created channel from step 2. + * + * Use telegram bot API key from step 1 and channel name with '@' prefix from step 2 to create instance of TelegramBotHandler + * + * @link https://core.telegram.org/bots/api + * + * @author Mazur Alexandr + */ +class TelegramBotHandler extends AbstractProcessingHandler +{ + private const BOT_API = 'https://api.telegram.org/bot'; + + /** + * The available values of parseMode according to the Telegram api documentation + */ + private const AVAILABLE_PARSE_MODES = [ + 'HTML', + 'MarkdownV2', + 'Markdown', // legacy mode without underline and strikethrough, use MarkdownV2 instead + ]; + + /** + * The maximum number of characters allowed in a message according to the Telegram api documentation + */ + private const MAX_MESSAGE_LENGTH = 4096; + + /** + * Telegram bot access token provided by BotFather. + * Create telegram bot with https://telegram.me/BotFather and use access token from it. + */ + private string $apiKey; + + /** + * Telegram channel name. + * Since to start with '@' symbol as prefix. + */ + private string $channel; + + /** + * The kind of formatting that is used for the message. + * See available options at https://core.telegram.org/bots/api#formatting-options + * or in AVAILABLE_PARSE_MODES + */ + private string|null $parseMode; + + /** + * Disables link previews for links in the message. + */ + private bool|null $disableWebPagePreview; + + /** + * Sends the message silently. Users will receive a notification with no sound. + */ + private bool|null $disableNotification; + + /** + * True - split a message longer than MAX_MESSAGE_LENGTH into parts and send in multiple messages. + * False - truncates a message that is too long. + */ + private bool $splitLongMessages; + + /** + * Adds 1-second delay between sending a split message (according to Telegram API to avoid 429 Too Many Requests). + */ + private bool $delayBetweenMessages; + + /** + * @param string $apiKey Telegram bot access token provided by BotFather + * @param string $channel Telegram channel name + * @param bool $splitLongMessages Split a message longer than MAX_MESSAGE_LENGTH into parts and send in multiple messages + * @param bool $delayBetweenMessages Adds delay between sending a split message according to Telegram API + * @throws MissingExtensionException + */ + public function __construct( + string $apiKey, + string $channel, + $level = Level::Debug, + bool $bubble = true, + string $parseMode = null, + bool $disableWebPagePreview = null, + bool $disableNotification = null, + bool $splitLongMessages = false, + bool $delayBetweenMessages = false + ) { + if (!extension_loaded('curl')) { + throw new MissingExtensionException('The curl extension is needed to use the TelegramBotHandler'); + } + + parent::__construct($level, $bubble); + + $this->apiKey = $apiKey; + $this->channel = $channel; + $this->setParseMode($parseMode); + $this->disableWebPagePreview($disableWebPagePreview); + $this->disableNotification($disableNotification); + $this->splitLongMessages($splitLongMessages); + $this->delayBetweenMessages($delayBetweenMessages); + } + + public function setParseMode(string $parseMode = null): self + { + if ($parseMode !== null && !in_array($parseMode, self::AVAILABLE_PARSE_MODES, true)) { + throw new \InvalidArgumentException('Unknown parseMode, use one of these: ' . implode(', ', self::AVAILABLE_PARSE_MODES) . '.'); + } + + $this->parseMode = $parseMode; + + return $this; + } + + public function disableWebPagePreview(bool $disableWebPagePreview = null): self + { + $this->disableWebPagePreview = $disableWebPagePreview; + + return $this; + } + + public function disableNotification(bool $disableNotification = null): self + { + $this->disableNotification = $disableNotification; + + return $this; + } + + /** + * True - split a message longer than MAX_MESSAGE_LENGTH into parts and send in multiple messages. + * False - truncates a message that is too long. + * @return $this + */ + public function splitLongMessages(bool $splitLongMessages = false): self + { + $this->splitLongMessages = $splitLongMessages; + + return $this; + } + + /** + * Adds 1-second delay between sending a split message (according to Telegram API to avoid 429 Too Many Requests). + * @return $this + */ + public function delayBetweenMessages(bool $delayBetweenMessages = false): self + { + $this->delayBetweenMessages = $delayBetweenMessages; + + return $this; + } + + /** + * @inheritDoc + */ + public function handleBatch(array $records): void + { + $messages = []; + + foreach ($records as $record) { + if (!$this->isHandling($record)) { + continue; + } + + if (\count($this->processors) > 0) { + $record = $this->processRecord($record); + } + + $messages[] = $record; + } + + if (\count($messages) > 0) { + $this->send((string) $this->getFormatter()->formatBatch($messages)); + } + } + + /** + * @inheritDoc + */ + protected function write(LogRecord $record): void + { + $this->send($record->formatted); + } + + /** + * Send request to @link https://api.telegram.org/bot on SendMessage action. + */ + protected function send(string $message): void + { + $messages = $this->handleMessageLength($message); + + foreach ($messages as $key => $msg) { + if ($this->delayBetweenMessages && $key > 0) { + sleep(1); + } + + $this->sendCurl($msg); + } + } + + protected function sendCurl(string $message): void + { + $ch = curl_init(); + $url = self::BOT_API . $this->apiKey . '/SendMessage'; + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([ + 'text' => $message, + 'chat_id' => $this->channel, + 'parse_mode' => $this->parseMode, + 'disable_web_page_preview' => $this->disableWebPagePreview, + 'disable_notification' => $this->disableNotification, + ])); + + $result = Curl\Util::execute($ch); + if (!is_string($result)) { + throw new RuntimeException('Telegram API error. Description: No response'); + } + $result = json_decode($result, true); + + if ($result['ok'] === false) { + throw new RuntimeException('Telegram API error. Description: ' . $result['description']); + } + } + + /** + * Handle a message that is too long: truncates or splits into several + * @return string[] + */ + private function handleMessageLength(string $message): array + { + $truncatedMarker = ' (...truncated)'; + if (!$this->splitLongMessages && strlen($message) > self::MAX_MESSAGE_LENGTH) { + return [Utils::substr($message, 0, self::MAX_MESSAGE_LENGTH - strlen($truncatedMarker)) . $truncatedMarker]; + } + + return str_split($message, self::MAX_MESSAGE_LENGTH); + } +} diff --git a/src/Monolog/Handler/TestHandler.php b/src/Monolog/Handler/TestHandler.php index 55c483342..1884f83fc 100644 --- a/src/Monolog/Handler/TestHandler.php +++ b/src/Monolog/Handler/TestHandler.php @@ -11,6 +11,11 @@ namespace Monolog\Handler; +use Monolog\Level; +use Monolog\Logger; +use Psr\Log\LogLevel; +use Monolog\LogRecord; + /** * Used for testing purposes. * @@ -65,40 +70,64 @@ */ class TestHandler extends AbstractProcessingHandler { - protected $records = []; - protected $recordsByLevel = []; + /** @var LogRecord[] */ + protected array $records = []; + /** @phpstan-var array, LogRecord[]> */ + protected array $recordsByLevel = []; + private bool $skipReset = false; - public function getRecords() + /** + * @return array + */ + public function getRecords(): array { return $this->records; } - public function clear() + public function clear(): void { $this->records = []; $this->recordsByLevel = []; } - public function hasRecords($level) + public function reset(): void + { + if (!$this->skipReset) { + $this->clear(); + } + } + + public function setSkipReset(bool $skipReset): void { - return isset($this->recordsByLevel[$level]); + $this->skipReset = $skipReset; } /** - * @param string|array $record Either a message string or an array containing message and optionally context keys that will be checked against all records - * @param int $level Logger::LEVEL constant value + * @param int|string|Level|LogLevel::* $level Logging level value or name + * + * @phpstan-param value-of|value-of|Level|LogLevel::* $level */ - public function hasRecord($record, $level) + public function hasRecords(int|string|Level $level): bool { - if (is_string($record)) { - $record = array('message' => $record); + return isset($this->recordsByLevel[Logger::toMonologLevel($level)->value]); + } + + /** + * @param string|array $recordAssertions Either a message string or an array containing message and optionally context keys that will be checked against all records + * + * @phpstan-param array{message: string, context?: mixed[]}|string $recordAssertions + */ + public function hasRecord(string|array $recordAssertions, Level $level): bool + { + if (is_string($recordAssertions)) { + $recordAssertions = ['message' => $recordAssertions]; } - return $this->hasRecordThatPasses(function ($rec) use ($record) { - if ($rec['message'] !== $record['message']) { + return $this->hasRecordThatPasses(function (LogRecord $rec) use ($recordAssertions) { + if ($rec->message !== $recordAssertions['message']) { return false; } - if (isset($record['context']) && $rec['context'] !== $record['context']) { + if (isset($recordAssertions['context']) && $rec->context !== $recordAssertions['context']) { return false; } @@ -106,28 +135,29 @@ public function hasRecord($record, $level) }, $level); } - public function hasRecordThatContains($message, $level) + public function hasRecordThatContains(string $message, Level $level): bool { - return $this->hasRecordThatPasses(function ($rec) use ($message) { - return strpos($rec['message'], $message) !== false; - }, $level); + return $this->hasRecordThatPasses(fn (LogRecord $rec) => str_contains($rec->message, $message), $level); } - public function hasRecordThatMatches($regex, $level) + public function hasRecordThatMatches(string $regex, Level $level): bool { - return $this->hasRecordThatPasses(function ($rec) use ($regex) { - return preg_match($regex, $rec['message']) > 0; - }, $level); + return $this->hasRecordThatPasses(fn (LogRecord $rec) => preg_match($regex, $rec->message) > 0, $level); } - public function hasRecordThatPasses(callable $predicate, $level) + /** + * @phpstan-param callable(LogRecord, int): mixed $predicate + */ + public function hasRecordThatPasses(callable $predicate, Level $level): bool { - if (!isset($this->recordsByLevel[$level])) { + $level = Logger::toMonologLevel($level); + + if (!isset($this->recordsByLevel[$level->value])) { return false; } - foreach ($this->recordsByLevel[$level] as $i => $rec) { - if (call_user_func($predicate, $rec, $i)) { + foreach ($this->recordsByLevel[$level->value] as $i => $rec) { + if ((bool) $predicate($rec, $i)) { return true; } } @@ -136,23 +166,27 @@ public function hasRecordThatPasses(callable $predicate, $level) } /** - * {@inheritdoc} + * @inheritDoc */ - protected function write(array $record) + protected function write(LogRecord $record): void { - $this->recordsByLevel[$record['level']][] = $record; + $this->recordsByLevel[$record->level->value][] = $record; $this->records[] = $record; } - public function __call($method, $args) + /** + * @param mixed[] $args + */ + public function __call(string $method, array $args): bool { if (preg_match('/(.*)(Debug|Info|Notice|Warning|Error|Critical|Alert|Emergency)(.*)/', $method, $matches) > 0) { $genericMethod = $matches[1] . ('Records' !== $matches[3] ? 'Record' : '') . $matches[3]; - $level = constant('Monolog\Logger::' . strtoupper($matches[2])); - if (method_exists($this, $genericMethod)) { + $level = constant(Level::class.'::' . $matches[2]); + $callback = [$this, $genericMethod]; + if (is_callable($callback)) { $args[] = $level; - return call_user_func_array([$this, $genericMethod], $args); + return call_user_func_array($callback, $args); } } diff --git a/src/Monolog/Handler/WebRequestRecognizerTrait.php b/src/Monolog/Handler/WebRequestRecognizerTrait.php index c81835288..9c12c3d56 100644 --- a/src/Monolog/Handler/WebRequestRecognizerTrait.php +++ b/src/Monolog/Handler/WebRequestRecognizerTrait.php @@ -15,7 +15,6 @@ trait WebRequestRecognizerTrait { /** * Checks if PHP's serving a web request - * @return bool */ protected function isWebRequest(): bool { diff --git a/src/Monolog/Handler/WhatFailureGroupHandler.php b/src/Monolog/Handler/WhatFailureGroupHandler.php index c2a58d5da..2dbc5fe8d 100644 --- a/src/Monolog/Handler/WhatFailureGroupHandler.php +++ b/src/Monolog/Handler/WhatFailureGroupHandler.php @@ -11,6 +11,9 @@ namespace Monolog\Handler; +use Monolog\LogRecord; +use Throwable; + /** * Forwards records to multiple handlers suppressing failures of each handler * and continuing through to give every handler a chance to succeed. @@ -20,20 +23,18 @@ class WhatFailureGroupHandler extends GroupHandler { /** - * {@inheritdoc} + * @inheritDoc */ - public function handle(array $record): bool + public function handle(LogRecord $record): bool { - if ($this->processors) { - foreach ($this->processors as $processor) { - $record = call_user_func($processor, $record); - } + if (\count($this->processors) > 0) { + $record = $this->processRecord($record); } foreach ($this->handlers as $handler) { try { $handler->handle($record); - } catch (\Throwable $e) { + } catch (Throwable) { // What failure? } } @@ -42,16 +43,14 @@ public function handle(array $record): bool } /** - * {@inheritdoc} + * @inheritDoc */ - public function handleBatch(array $records) + public function handleBatch(array $records): void { - if ($this->processors) { - $processed = array(); + if (\count($this->processors) > 0) { + $processed = []; foreach ($records as $record) { - foreach ($this->processors as $processor) { - $processed[] = call_user_func($processor, $record); - } + $processed[] = $this->processRecord($record); } $records = $processed; } @@ -59,7 +58,7 @@ public function handleBatch(array $records) foreach ($this->handlers as $handler) { try { $handler->handleBatch($records); - } catch (\Throwable $e) { + } catch (Throwable) { // What failure? } } diff --git a/src/Monolog/Handler/ZendMonitorHandler.php b/src/Monolog/Handler/ZendMonitorHandler.php index c0bceb423..1e71194bc 100644 --- a/src/Monolog/Handler/ZendMonitorHandler.php +++ b/src/Monolog/Handler/ZendMonitorHandler.php @@ -13,85 +13,78 @@ use Monolog\Formatter\FormatterInterface; use Monolog\Formatter\NormalizerFormatter; -use Monolog\Logger; +use Monolog\Level; +use Monolog\LogRecord; /** * Handler sending logs to Zend Monitor * * @author Christian Bergau + * @author Jason Davis */ class ZendMonitorHandler extends AbstractProcessingHandler { /** - * Monolog level / ZendMonitor Custom Event priority map - * - * @var array - */ - protected $levelMap = [ - Logger::DEBUG => 1, - Logger::INFO => 2, - Logger::NOTICE => 3, - Logger::WARNING => 4, - Logger::ERROR => 5, - Logger::CRITICAL => 6, - Logger::ALERT => 7, - Logger::EMERGENCY => 0, - ]; - - /** - * Construct - * - * @param int $level - * @param bool $bubble * @throws MissingExtensionException */ - public function __construct($level = Logger::DEBUG, $bubble = true) + public function __construct(int|string|Level $level = Level::Debug, bool $bubble = true) { if (!function_exists('zend_monitor_custom_event')) { - throw new MissingExtensionException('You must have Zend Server installed in order to use this handler'); + throw new MissingExtensionException( + 'You must have Zend Server installed with Zend Monitor enabled in order to use this handler' + ); } + parent::__construct($level, $bubble); } /** - * {@inheritdoc} + * Translates Monolog log levels to ZendMonitor levels. */ - protected function write(array $record) + protected function toZendMonitorLevel(Level $level): int { - $this->writeZendMonitorCustomEvent( - $this->levelMap[$record['level']], - $record['message'], - $record['formatted'] - ); + return match ($level) { + Level::Debug => \ZEND_MONITOR_EVENT_SEVERITY_INFO, + Level::Info => \ZEND_MONITOR_EVENT_SEVERITY_INFO, + Level::Notice => \ZEND_MONITOR_EVENT_SEVERITY_INFO, + Level::Warning => \ZEND_MONITOR_EVENT_SEVERITY_WARNING, + Level::Error => \ZEND_MONITOR_EVENT_SEVERITY_ERROR, + Level::Critical => \ZEND_MONITOR_EVENT_SEVERITY_ERROR, + Level::Alert => \ZEND_MONITOR_EVENT_SEVERITY_ERROR, + Level::Emergency => \ZEND_MONITOR_EVENT_SEVERITY_ERROR, + }; } /** - * Write a record to Zend Monitor - * - * @param int $level - * @param string $message - * @param array $formatted + * @inheritDoc */ - protected function writeZendMonitorCustomEvent($level, $message, $formatted) + protected function write(LogRecord $record): void { - zend_monitor_custom_event($level, $message, $formatted); + $this->writeZendMonitorCustomEvent( + $record->level->getName(), + $record->message, + $record->formatted, + $this->toZendMonitorLevel($record->level) + ); } /** - * {@inheritdoc} + * Write to Zend Monitor Events + * @param string $type Text displayed in "Class Name (custom)" field + * @param string $message Text displayed in "Error String" + * @param array $formatted Displayed in Custom Variables tab + * @param int $severity Set the event severity level (-1,0,1) */ - public function getDefaultFormatter(): FormatterInterface + protected function writeZendMonitorCustomEvent(string $type, string $message, array $formatted, int $severity): void { - return new NormalizerFormatter(); + zend_monitor_custom_event($type, $message, $formatted, $severity); } /** - * Get the level map - * - * @return array + * @inheritDoc */ - public function getLevelMap() + public function getDefaultFormatter(): FormatterInterface { - return $this->levelMap; + return new NormalizerFormatter(); } } diff --git a/src/Monolog/Level.php b/src/Monolog/Level.php new file mode 100644 index 000000000..ab04cf4d4 --- /dev/null +++ b/src/Monolog/Level.php @@ -0,0 +1,209 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog; + +use Psr\Log\LogLevel; + +/** + * Represents the log levels + * + * Monolog supports the logging levels described by RFC 5424 {@see https://datatracker.ietf.org/doc/html/rfc5424} + * but due to BC the severity values used internally are not 0-7. + * + * To get the level name out of a Level there are three options: + * + * - Use ->getName() to get the standard Monolog name which is full uppercased (e.g. "DEBUG") + * - Use ->toPsrLogLevel() to get the standard PSR-3 name which is full lowercased (e.g. "debug") + * - Use ->toRFC5424Level() to get the standard RFC 5424 value (e.g. 7 for debug, 0 for emergency) + * - Use ->name to get the enum case's name which is capitalized (e.g. "Debug") + * + * To get the value for filtering, if the includes/isLowerThan/isHigherThan methods are + * not enough, you can use ->value to get the enum case's integer value. + */ +enum Level: int +{ + /** + * Detailed debug information + */ + case Debug = 100; + + /** + * Interesting events + * + * Examples: User logs in, SQL logs. + */ + case Info = 200; + + /** + * Uncommon events + */ + case Notice = 250; + + /** + * Exceptional occurrences that are not errors + * + * Examples: Use of deprecated APIs, poor use of an API, + * undesirable things that are not necessarily wrong. + */ + case Warning = 300; + + /** + * Runtime errors + */ + case Error = 400; + + /** + * Critical conditions + * + * Example: Application component unavailable, unexpected exception. + */ + case Critical = 500; + + /** + * Action must be taken immediately + * + * Example: Entire website down, database unavailable, etc. + * This should trigger the SMS alerts and wake you up. + */ + case Alert = 550; + + /** + * Urgent alert. + */ + case Emergency = 600; + + /** + * @param value-of|LogLevel::*|'Debug'|'Info'|'Notice'|'Warning'|'Error'|'Critical'|'Alert'|'Emergency' $name + * @return static + */ + public static function fromName(string $name): self + { + return match ($name) { + 'debug', 'Debug', 'DEBUG' => self::Debug, + 'info', 'Info', 'INFO' => self::Info, + 'notice', 'Notice', 'NOTICE' => self::Notice, + 'warning', 'Warning', 'WARNING' => self::Warning, + 'error', 'Error', 'ERROR' => self::Error, + 'critical', 'Critical', 'CRITICAL' => self::Critical, + 'alert', 'Alert', 'ALERT' => self::Alert, + 'emergency', 'Emergency', 'EMERGENCY' => self::Emergency, + }; + } + + /** + * @param value-of $value + * @return static + */ + public static function fromValue(int $value): self + { + return self::from($value); + } + + /** + * Returns true if the passed $level is higher or equal to $this + */ + public function includes(Level $level): bool + { + return $this->value <= $level->value; + } + + public function isHigherThan(Level $level): bool + { + return $this->value > $level->value; + } + + public function isLowerThan(Level $level): bool + { + return $this->value < $level->value; + } + + /** + * Returns the monolog standardized all-capitals name of the level + * + * Use this instead of $level->name which returns the enum case name (e.g. Debug vs DEBUG if you use getName()) + * + * @return value-of + */ + public function getName(): string + { + return match ($this) { + self::Debug => 'DEBUG', + self::Info => 'INFO', + self::Notice => 'NOTICE', + self::Warning => 'WARNING', + self::Error => 'ERROR', + self::Critical => 'CRITICAL', + self::Alert => 'ALERT', + self::Emergency => 'EMERGENCY', + }; + } + + /** + * Returns the PSR-3 level matching this instance + * + * @phpstan-return \Psr\Log\LogLevel::* + */ + public function toPsrLogLevel(): string + { + return match ($this) { + self::Debug => LogLevel::DEBUG, + self::Info => LogLevel::INFO, + self::Notice => LogLevel::NOTICE, + self::Warning => LogLevel::WARNING, + self::Error => LogLevel::ERROR, + self::Critical => LogLevel::CRITICAL, + self::Alert => LogLevel::ALERT, + self::Emergency => LogLevel::EMERGENCY, + }; + } + + /** + * Returns the RFC 5424 level matching this instance + * + * @phpstan-return int<0, 7> + */ + public function toRFC5424Level(): int + { + return match ($this) { + self::Debug => 7, + self::Info => 6, + self::Notice => 5, + self::Warning => 4, + self::Error => 3, + self::Critical => 2, + self::Alert => 1, + self::Emergency => 0, + }; + } + + public const VALUES = [ + 100, + 200, + 250, + 300, + 400, + 500, + 550, + 600, + ]; + + public const NAMES = [ + 'DEBUG', + 'INFO', + 'NOTICE', + 'WARNING', + 'ERROR', + 'CRITICAL', + 'ALERT', + 'EMERGENCY', + ]; +} diff --git a/src/Monolog/LogRecord.php b/src/Monolog/LogRecord.php new file mode 100644 index 000000000..df758c58b --- /dev/null +++ b/src/Monolog/LogRecord.php @@ -0,0 +1,124 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog; + +use ArrayAccess; + +/** + * Monolog log record + * + * @author Jordi Boggiano + * @template-implements ArrayAccess<'message'|'level'|'context'|'level_name'|'channel'|'datetime'|'extra', int|string|\DateTimeImmutable|array> + */ +class LogRecord implements ArrayAccess +{ + private const MODIFIABLE_FIELDS = [ + 'extra' => true, + 'formatted' => true, + ]; + + public function __construct( + public readonly \DateTimeImmutable $datetime, + public readonly string $channel, + public readonly Level $level, + public readonly string $message, + /** @var array */ + public readonly array $context = [], + /** @var array */ + public array $extra = [], + public mixed $formatted = null, + ) { + } + + public function offsetSet(mixed $offset, mixed $value): void + { + if ($offset === 'extra') { + if (!is_array($value)) { + throw new \InvalidArgumentException('extra must be an array'); + } + + $this->extra = $value; + + return; + } + + if ($offset === 'formatted') { + $this->formatted = $value; + + return; + } + + throw new \LogicException('Unsupported operation: setting '.$offset); + } + + public function offsetExists(mixed $offset): bool + { + if ($offset === 'level_name') { + return true; + } + + return isset($this->{$offset}); + } + + public function offsetUnset(mixed $offset): void + { + throw new \LogicException('Unsupported operation'); + } + + public function &offsetGet(mixed $offset): mixed + { + if ($offset === 'level_name' || $offset === 'level') { + // avoid returning readonly props by ref as this is illegal + if ($offset === 'level_name') { + $copy = $this->level->getName(); + } else { + $copy = $this->level->value; + } + + return $copy; + } + + if (isset(self::MODIFIABLE_FIELDS[$offset])) { + return $this->{$offset}; + } + + // avoid returning readonly props by ref as this is illegal + $copy = $this->{$offset}; + + return $copy; + } + + /** + * @phpstan-return array{message: string, context: mixed[], level: value-of, level_name: value-of, channel: string, datetime: \DateTimeImmutable, extra: mixed[]} + */ + public function toArray(): array + { + return [ + 'message' => $this->message, + 'context' => $this->context, + 'level' => $this->level->value, + 'level_name' => $this->level->getName(), + 'channel' => $this->channel, + 'datetime' => $this->datetime, + 'extra' => $this->extra, + ]; + } + + public function with(mixed ...$args): self + { + foreach (['message', 'context', 'level', 'channel', 'datetime', 'extra'] as $prop) { + $args[$prop] ??= $this->{$prop}; + } + + return new self(...$args); + } +} diff --git a/src/Monolog/Logger.php b/src/Monolog/Logger.php index a22bf7ec0..5aacc6f87 100644 --- a/src/Monolog/Logger.php +++ b/src/Monolog/Logger.php @@ -11,11 +11,15 @@ namespace Monolog; +use Closure; use DateTimeZone; use Monolog\Handler\HandlerInterface; +use Monolog\Processor\ProcessorInterface; use Psr\Log\LoggerInterface; use Psr\Log\InvalidArgumentException; +use Psr\Log\LogLevel; use Throwable; +use Stringable; /** * Monolog log channel @@ -24,133 +28,150 @@ * and uses them to store records that are added to it. * * @author Jordi Boggiano + * @final */ -class Logger implements LoggerInterface +class Logger implements LoggerInterface, ResettableInterface { /** * Detailed debug information + * + * @deprecated Use \Monolog\Level::Debug */ - const DEBUG = 100; + public const DEBUG = 100; /** * Interesting events * * Examples: User logs in, SQL logs. + * + * @deprecated Use \Monolog\Level::Info */ - const INFO = 200; + public const INFO = 200; /** * Uncommon events + * + * @deprecated Use \Monolog\Level::Notice */ - const NOTICE = 250; + public const NOTICE = 250; /** * Exceptional occurrences that are not errors * * Examples: Use of deprecated APIs, poor use of an API, * undesirable things that are not necessarily wrong. + * + * @deprecated Use \Monolog\Level::Warning */ - const WARNING = 300; + public const WARNING = 300; /** * Runtime errors + * + * @deprecated Use \Monolog\Level::Error */ - const ERROR = 400; + public const ERROR = 400; /** * Critical conditions * * Example: Application component unavailable, unexpected exception. + * + * @deprecated Use \Monolog\Level::Critical */ - const CRITICAL = 500; + public const CRITICAL = 500; /** * Action must be taken immediately * * Example: Entire website down, database unavailable, etc. * This should trigger the SMS alerts and wake you up. + * + * @deprecated Use \Monolog\Level::Alert */ - const ALERT = 550; + public const ALERT = 550; /** * Urgent alert. + * + * @deprecated Use \Monolog\Level::Emergency */ - const EMERGENCY = 600; + public const EMERGENCY = 600; /** * Monolog API version * * This is only bumped when API breaks are done and should * follow the major version of the library - * - * @var int */ - const API = 2; + public const API = 3; /** - * This is a static variable and not a constant to serve as an extension point for custom levels + * Mapping between levels numbers defined in RFC 5424 and Monolog ones * - * @var string[] $levels Logging levels with the levels as key + * @phpstan-var array $rfc_5424_levels */ - protected static $levels = [ - self::DEBUG => 'DEBUG', - self::INFO => 'INFO', - self::NOTICE => 'NOTICE', - self::WARNING => 'WARNING', - self::ERROR => 'ERROR', - self::CRITICAL => 'CRITICAL', - self::ALERT => 'ALERT', - self::EMERGENCY => 'EMERGENCY', + private const RFC_5424_LEVELS = [ + 7 => Level::Debug, + 6 => Level::Info, + 5 => Level::Notice, + 4 => Level::Warning, + 3 => Level::Error, + 2 => Level::Critical, + 1 => Level::Alert, + 0 => Level::Emergency, ]; - /** - * @var string - */ - protected $name; + protected string $name; /** * The handler stack * - * @var HandlerInterface[] + * @var list */ - protected $handlers; + protected array $handlers; /** * Processors that will process all log records * * To process records of a single handler instead, add the processor on that specific handler * - * @var callable[] + * @var array<(callable(LogRecord): LogRecord)|ProcessorInterface> */ - protected $processors; + protected array $processors; - /** - * @var bool - */ - protected $microsecondTimestamps = true; + protected bool $microsecondTimestamps = true; + + protected DateTimeZone $timezone; + + protected Closure|null $exceptionHandler = null; /** - * @var DateTimeZone + * Keeps track of depth to prevent infinite logging loops */ - protected $timezone; + private int $logDepth = 0; /** - * @var callable + * Whether to detect infinite logging loops + * + * This can be disabled via {@see useLoggingLoopDetection} if you have async handlers that do not play well with this */ - protected $exceptionHandler; + private bool $detectCycles = true; /** * @param string $name The logging channel, a simple descriptive name that is attached to all log records * @param HandlerInterface[] $handlers Optional stack of handlers, the first one in the array is called first, etc. * @param callable[] $processors Optional array of processors - * @param DateTimeZone $timezone Optional timezone, if not provided date_default_timezone_get() will be used + * @param DateTimeZone|null $timezone Optional timezone, if not provided date_default_timezone_get() will be used + * + * @phpstan-param array<(callable(LogRecord): LogRecord)|ProcessorInterface> $processors */ - public function __construct(string $name, array $handlers = [], array $processors = [], DateTimeZone $timezone = null) + public function __construct(string $name, array $handlers = [], array $processors = [], DateTimeZone|null $timezone = null) { $this->name = $name; $this->setHandlers($handlers); $this->processors = $processors; - $this->timezone = $timezone ?: new DateTimeZone(date_default_timezone_get() ?: 'UTC'); + $this->timezone = $timezone ?? new DateTimeZone(date_default_timezone_get()); } public function getName(): string @@ -186,7 +207,7 @@ public function pushHandler(HandlerInterface $handler): self */ public function popHandler(): HandlerInterface { - if (!$this->handlers) { + if (0 === \count($this->handlers)) { throw new \LogicException('You tried to pop from an empty handler stack.'); } @@ -198,7 +219,7 @@ public function popHandler(): HandlerInterface * * If a map is passed, keys will be ignored. * - * @param HandlerInterface[] $handlers + * @param list $handlers */ public function setHandlers(array $handlers): self { @@ -211,7 +232,7 @@ public function setHandlers(array $handlers): self } /** - * @return HandlerInterface[] + * @return list */ public function getHandlers(): array { @@ -220,8 +241,10 @@ public function getHandlers(): array /** * Adds a processor on to the stack. + * + * @phpstan-param ProcessorInterface|(callable(LogRecord): LogRecord) $callback */ - public function pushProcessor(callable $callback): self + public function pushProcessor(ProcessorInterface|callable $callback): self { array_unshift($this->processors, $callback); @@ -231,12 +254,12 @@ public function pushProcessor(callable $callback): self /** * Removes the processor on top of the stack and returns it. * + * @phpstan-return ProcessorInterface|(callable(LogRecord): LogRecord) * @throws \LogicException If empty processor stack - * @return callable */ public function popProcessor(): callable { - if (!$this->processors) { + if (0 === \count($this->processors)) { throw new \LogicException('You tried to pop from an empty processor stack.'); } @@ -245,6 +268,7 @@ public function popProcessor(): callable /** * @return callable[] + * @phpstan-return array */ public function getProcessors(): array { @@ -255,135 +279,221 @@ public function getProcessors(): array * Control the use of microsecond resolution timestamps in the 'datetime' * member of new records. * - * On PHP7.0, generating microsecond resolution timestamps by calling - * microtime(true), formatting the result via sprintf() and then parsing - * the resulting string via \DateTime::createFromFormat() can incur - * a measurable runtime overhead vs simple usage of DateTime to capture - * a second resolution timestamp in systems which generate a large number - * of log events. - * - * On PHP7.1 however microseconds are always included by the engine, so - * this setting can be left alone unless you really want to suppress - * microseconds in the output. + * As of PHP7.1 microseconds are always included by the engine, so + * there is no performance penalty and Monolog 2 enabled microseconds + * by default. This function lets you disable them though in case you want + * to suppress microseconds from the output. * * @param bool $micro True to use microtime() to create timestamps */ - public function useMicrosecondTimestamps(bool $micro) + public function useMicrosecondTimestamps(bool $micro): self { $this->microsecondTimestamps = $micro; + + return $this; + } + + public function useLoggingLoopDetection(bool $detectCycles): self + { + $this->detectCycles = $detectCycles; + + return $this; } /** * Adds a log record. * - * @param int $level The logging level - * @param string $message The log message - * @param array $context The log context - * @return bool Whether the record has been processed + * @param int $level The logging level (a Monolog or RFC 5424 level) + * @param string $message The log message + * @param mixed[] $context The log context + * @param DateTimeImmutable $datetime Optional log date to log into the past or future + * @return bool Whether the record has been processed + * + * @phpstan-param value-of|Level $level */ - public function addRecord(int $level, string $message, array $context = []): bool + public function addRecord(int|Level $level, string $message, array $context = [], DateTimeImmutable $datetime = null): bool { - // check if any handler will handle this message so we can return early and save cycles - $handlerKey = null; - foreach ($this->handlers as $key => $handler) { - if ($handler->isHandling(['level' => $level])) { - $handlerKey = $key; - break; - } + if (is_int($level) && isset(self::RFC_5424_LEVELS[$level])) { + $level = self::RFC_5424_LEVELS[$level]; } - if (null === $handlerKey) { + if ($this->detectCycles) { + $this->logDepth += 1; + } + if ($this->logDepth === 3) { + $this->warning('A possible infinite logging loop was detected and aborted. It appears some of your handler code is triggering logging, see the previous log record for a hint as to what may be the cause.'); + return false; + } elseif ($this->logDepth >= 5) { // log depth 4 is let through so we can log the warning above return false; } - $levelName = static::getLevelName($level); - - $record = [ - 'message' => $message, - 'context' => $context, - 'level' => $level, - 'level_name' => $levelName, - 'channel' => $this->name, - 'datetime' => new DateTimeImmutable($this->microsecondTimestamps, $this->timezone), - 'extra' => [], - ]; - try { - foreach ($this->processors as $processor) { - $record = call_user_func($processor, $record); - } + $recordInitialized = count($this->processors) === 0; + + $record = new LogRecord( + message: $message, + context: $context, + level: self::toMonologLevel($level), + channel: $this->name, + datetime: $datetime ?? new DateTimeImmutable($this->microsecondTimestamps, $this->timezone), + extra: [], + ); + $handled = false; + + foreach ($this->handlers as $handler) { + if (false === $recordInitialized) { + // skip initializing the record as long as no handler is going to handle it + if (!$handler->isHandling($record)) { + continue; + } + + try { + foreach ($this->processors as $processor) { + $record = $processor($record); + } + $recordInitialized = true; + } catch (Throwable $e) { + $this->handleException($e, $record); + + return true; + } + } - // advance the array pointer to the first handler that will handle this record - reset($this->handlers); - while ($handlerKey !== key($this->handlers)) { - next($this->handlers); - } + // once the record is initialized, send it to all handlers as long as the bubbling chain is not interrupted + try { + $handled = true; + if (true === $handler->handle($record)) { + break; + } + } catch (Throwable $e) { + $this->handleException($e, $record); - while ($handler = current($this->handlers)) { - if (true === $handler->handle($record)) { - break; + return true; } + } - next($this->handlers); + return $handled; + } finally { + if ($this->detectCycles) { + $this->logDepth--; } - } catch (Throwable $e) { - $this->handleException($e, $record); } - - return true; } /** - * Gets all supported logging levels. + * Ends a log cycle and frees all resources used by handlers. * - * @return array Assoc array with human-readable level names => level codes. + * Closing a Handler means flushing all buffers and freeing any open resources/handles. + * Handlers that have been closed should be able to accept log records again and re-open + * themselves on demand, but this may not always be possible depending on implementation. + * + * This is useful at the end of a request and will be called automatically on every handler + * when they get destructed. */ - public static function getLevels(): array + public function close(): void { - return array_flip(static::$levels); + foreach ($this->handlers as $handler) { + $handler->close(); + } } /** - * Gets the name of the logging level. + * Ends a log cycle and resets all handlers and processors to their initial state. * - * @throws \Psr\Log\InvalidArgumentException If level is not defined + * Resetting a Handler or a Processor means flushing/cleaning all buffers, resetting internal + * state, and getting it back to a state in which it can receive log records again. + * + * This is useful in case you want to avoid logs leaking between two requests or jobs when you + * have a long running process like a worker or an application server serving multiple requests + * in one process. */ - public static function getLevelName(int $level): string + public function reset(): void { - if (!isset(static::$levels[$level])) { - throw new InvalidArgumentException('Level "'.$level.'" is not defined, use one of: '.implode(', ', array_keys(static::$levels))); + foreach ($this->handlers as $handler) { + if ($handler instanceof ResettableInterface) { + $handler->reset(); + } } - return static::$levels[$level]; + foreach ($this->processors as $processor) { + if ($processor instanceof ResettableInterface) { + $processor->reset(); + } + } } /** - * Converts PSR-3 levels to Monolog ones if necessary + * Gets the name of the logging level as a string. + * + * This still returns a string instead of a Level for BC, but new code should not rely on this method. * - * @param string|int Level number (monolog) or name (PSR-3) * @throws \Psr\Log\InvalidArgumentException If level is not defined + * + * @phpstan-param value-of|Level $level + * @phpstan-return value-of + * + * @deprecated Since 3.0, use {@see toMonologLevel} or {@see \Monolog\Level->getName()} instead + */ + public static function getLevelName(int|Level $level): string + { + return self::toMonologLevel($level)->getName(); + } + + /** + * Converts PSR-3 levels to Monolog ones if necessary + * + * @param int|string|Level|LogLevel::* $level Level number (monolog) or name (PSR-3) + * @throws \Psr\Log\InvalidArgumentException If level is not defined + * + * @phpstan-param value-of|value-of|Level|LogLevel::* $level */ - public static function toMonologLevel($level): int + public static function toMonologLevel(string|int|Level $level): Level { - if (is_string($level)) { - if (defined(__CLASS__.'::'.strtoupper($level))) { - return constant(__CLASS__.'::'.strtoupper($level)); + if ($level instanceof Level) { + return $level; + } + + if (\is_string($level)) { + if (\is_numeric($level)) { + $levelEnum = Level::tryFrom((int) $level); + if ($levelEnum === null) { + throw new InvalidArgumentException('Level "'.$level.'" is not defined, use one of: '.implode(', ', Level::NAMES + Level::VALUES)); + } + + return $levelEnum; + } + + // Contains first char of all log levels and avoids using strtoupper() which may have + // strange results depending on locale (for example, "i" will become "İ" in Turkish locale) + $upper = strtr(substr($level, 0, 1), 'dinweca', 'DINWECA') . strtolower(substr($level, 1)); + if (defined(Level::class.'::'.$upper)) { + return constant(Level::class . '::' . $upper); } - throw new InvalidArgumentException('Level "'.$level.'" is not defined, use one of: '.implode(', ', array_keys(static::$levels))); + throw new InvalidArgumentException('Level "'.$level.'" is not defined, use one of: '.implode(', ', Level::NAMES + Level::VALUES)); } - return $level; + $levelEnum = Level::tryFrom($level); + if ($levelEnum === null) { + throw new InvalidArgumentException('Level "'.var_export($level, true).'" is not defined, use one of: '.implode(', ', Level::NAMES + Level::VALUES)); + } + + return $levelEnum; } /** * Checks whether the Logger has a handler that listens on the given level + * + * @phpstan-param value-of|value-of|Level|LogLevel::* $level */ - public function isHandling(int $level): bool + public function isHandling(int|string|Level $level): bool { - $record = [ - 'level' => $level, - ]; + $record = new LogRecord( + datetime: new DateTimeImmutable($this->microsecondTimestamps, $this->timezone), + channel: $this->name, + message: '', + level: self::toMonologLevel($level), + ); foreach ($this->handlers as $handler) { if ($handler->isHandling($record)) { @@ -397,16 +507,16 @@ public function isHandling(int $level): bool /** * Set a custom exception handler that will be called if adding a new record fails * - * The callable will receive an exception object and the record that failed to be logged + * The Closure will receive an exception object and the record that failed to be logged */ - public function setExceptionHandler(?callable $callback): self + public function setExceptionHandler(Closure|null $callback): self { $this->exceptionHandler = $callback; return $this; } - public function getExceptionHandler(): ?callable + public function getExceptionHandler(): Closure|null { return $this->exceptionHandler; } @@ -416,13 +526,25 @@ public function getExceptionHandler(): ?callable * * This method allows for compatibility with common interfaces. * - * @param mixed $level The log level - * @param string $message The log message - * @param array $context The log context + * @param mixed $level The log level (a Monolog, PSR-3 or RFC 5424 level) + * @param string|Stringable $message The log message + * @param mixed[] $context The log context + * + * @phpstan-param Level|LogLevel::* $level */ - public function log($level, $message, array $context = []) + public function log($level, string|\Stringable $message, array $context = []): void { - $level = static::toMonologLevel($level); + if (!$level instanceof Level) { + if (!is_string($level) && !is_int($level)) { + throw new \InvalidArgumentException('$level is expected to be a string, int or '.Level::class.' instance'); + } + + if (isset(self::RFC_5424_LEVELS[$level])) { + $level = self::RFC_5424_LEVELS[$level]; + } + + $level = static::toMonologLevel($level); + } $this->addRecord($level, (string) $message, $context); } @@ -432,12 +554,12 @@ public function log($level, $message, array $context = []) * * This method allows for compatibility with common interfaces. * - * @param string $message The log message - * @param array $context The log context + * @param string|Stringable $message The log message + * @param mixed[] $context The log context */ - public function debug($message, array $context = []) + public function debug(string|\Stringable $message, array $context = []): void { - $this->addRecord(static::DEBUG, (string) $message, $context); + $this->addRecord(Level::Debug, (string) $message, $context); } /** @@ -445,12 +567,12 @@ public function debug($message, array $context = []) * * This method allows for compatibility with common interfaces. * - * @param string $message The log message - * @param array $context The log context + * @param string|Stringable $message The log message + * @param mixed[] $context The log context */ - public function info($message, array $context = []) + public function info(string|\Stringable $message, array $context = []): void { - $this->addRecord(static::INFO, (string) $message, $context); + $this->addRecord(Level::Info, (string) $message, $context); } /** @@ -458,12 +580,12 @@ public function info($message, array $context = []) * * This method allows for compatibility with common interfaces. * - * @param string $message The log message - * @param array $context The log context + * @param string|Stringable $message The log message + * @param mixed[] $context The log context */ - public function notice($message, array $context = []) + public function notice(string|\Stringable $message, array $context = []): void { - $this->addRecord(static::NOTICE, (string) $message, $context); + $this->addRecord(Level::Notice, (string) $message, $context); } /** @@ -471,12 +593,12 @@ public function notice($message, array $context = []) * * This method allows for compatibility with common interfaces. * - * @param string $message The log message - * @param array $context The log context + * @param string|Stringable $message The log message + * @param mixed[] $context The log context */ - public function warning($message, array $context = []) + public function warning(string|\Stringable $message, array $context = []): void { - $this->addRecord(static::WARNING, (string) $message, $context); + $this->addRecord(Level::Warning, (string) $message, $context); } /** @@ -484,12 +606,12 @@ public function warning($message, array $context = []) * * This method allows for compatibility with common interfaces. * - * @param string $message The log message - * @param array $context The log context + * @param string|Stringable $message The log message + * @param mixed[] $context The log context */ - public function error($message, array $context = []) + public function error(string|\Stringable $message, array $context = []): void { - $this->addRecord(static::ERROR, (string) $message, $context); + $this->addRecord(Level::Error, (string) $message, $context); } /** @@ -497,12 +619,12 @@ public function error($message, array $context = []) * * This method allows for compatibility with common interfaces. * - * @param string $message The log message - * @param array $context The log context + * @param string|Stringable $message The log message + * @param mixed[] $context The log context */ - public function critical($message, array $context = []) + public function critical(string|\Stringable $message, array $context = []): void { - $this->addRecord(static::CRITICAL, (string) $message, $context); + $this->addRecord(Level::Critical, (string) $message, $context); } /** @@ -510,12 +632,12 @@ public function critical($message, array $context = []) * * This method allows for compatibility with common interfaces. * - * @param string $message The log message - * @param array $context The log context + * @param string|Stringable $message The log message + * @param mixed[] $context The log context */ - public function alert($message, array $context = []) + public function alert(string|\Stringable $message, array $context = []): void { - $this->addRecord(static::ALERT, (string) $message, $context); + $this->addRecord(Level::Alert, (string) $message, $context); } /** @@ -523,12 +645,12 @@ public function alert($message, array $context = []) * * This method allows for compatibility with common interfaces. * - * @param string $message The log message - * @param array $context The log context + * @param string|Stringable $message The log message + * @param mixed[] $context The log context */ - public function emergency($message, array $context = []) + public function emergency(string|\Stringable $message, array $context = []): void { - $this->addRecord(static::EMERGENCY, (string) $message, $context); + $this->addRecord(Level::Emergency, (string) $message, $context); } /** @@ -553,12 +675,12 @@ public function getTimezone(): DateTimeZone * Delegates exception management to the custom exception handler, * or throws the exception if no custom handler is set. */ - protected function handleException(Throwable $e, array $record) + protected function handleException(Throwable $e, LogRecord $record): void { - if (!$this->exceptionHandler) { + if (null === $this->exceptionHandler) { throw $e; } - call_user_func($this->exceptionHandler, $e, $record); + ($this->exceptionHandler)($e, $record); } } diff --git a/src/Monolog/Processor/GitProcessor.php b/src/Monolog/Processor/GitProcessor.php index 9eec18622..5a70ac2e2 100644 --- a/src/Monolog/Processor/GitProcessor.php +++ b/src/Monolog/Processor/GitProcessor.php @@ -11,7 +11,10 @@ namespace Monolog\Processor; +use Monolog\Level; use Monolog\Logger; +use Psr\Log\LogLevel; +use Monolog\LogRecord; /** * Injects Git branch and Git commit SHA in all records @@ -19,36 +22,48 @@ * @author Nick Otter * @author Jordi Boggiano */ -class GitProcessor +class GitProcessor implements ProcessorInterface { - private $level; - private static $cache; + private Level $level; + /** @var array{branch: string, commit: string}|array|null */ + private static $cache = null; - public function __construct($level = Logger::DEBUG) + /** + * @param int|string|Level|LogLevel::* $level The minimum logging level at which this Processor will be triggered + * + * @phpstan-param value-of|value-of|Level|LogLevel::* $level + */ + public function __construct(int|string|Level $level = Level::Debug) { $this->level = Logger::toMonologLevel($level); } - public function __invoke(array $record): array + /** + * @inheritDoc + */ + public function __invoke(LogRecord $record): LogRecord { // return if the level is not high enough - if ($record['level'] < $this->level) { + if ($record->level->isLowerThan($this->level)) { return $record; } - $record['extra']['git'] = self::getGitInfo(); + $record->extra['git'] = self::getGitInfo(); return $record; } + /** + * @return array{branch: string, commit: string}|array + */ private static function getGitInfo(): array { - if (self::$cache) { + if (self::$cache !== null) { return self::$cache; } - $branches = `git branch -v --no-abbrev`; - if (preg_match('{^\* (.+?)\s+([a-f0-9]{40})(?:\s|$)}m', $branches, $matches)) { + $branches = shell_exec('git branch -v --no-abbrev'); + if (is_string($branches) && 1 === preg_match('{^\* (.+?)\s+([a-f0-9]{40})(?:\s|$)}m', $branches, $matches)) { return self::$cache = [ 'branch' => $matches[1], 'commit' => $matches[2], diff --git a/src/Monolog/Processor/HostnameProcessor.php b/src/Monolog/Processor/HostnameProcessor.php index fef408497..cba6e0963 100644 --- a/src/Monolog/Processor/HostnameProcessor.php +++ b/src/Monolog/Processor/HostnameProcessor.php @@ -11,21 +11,26 @@ namespace Monolog\Processor; +use Monolog\LogRecord; + /** * Injects value of gethostname in all records */ -class HostnameProcessor +class HostnameProcessor implements ProcessorInterface { - private static $host; + private static string $host; public function __construct() { self::$host = (string) gethostname(); } - public function __invoke(array $record): array + /** + * @inheritDoc + */ + public function __invoke(LogRecord $record): LogRecord { - $record['extra']['hostname'] = self::$host; + $record->extra['hostname'] = self::$host; return $record; } diff --git a/src/Monolog/Processor/IntrospectionProcessor.php b/src/Monolog/Processor/IntrospectionProcessor.php index 5fe5b2aa5..30e7dfed8 100644 --- a/src/Monolog/Processor/IntrospectionProcessor.php +++ b/src/Monolog/Processor/IntrospectionProcessor.php @@ -11,7 +11,10 @@ namespace Monolog\Processor; +use Monolog\Level; use Monolog\Logger; +use Psr\Log\LogLevel; +use Monolog\LogRecord; /** * Injects line/file:class/function where the log message came from @@ -24,30 +27,40 @@ * * @author Jordi Boggiano */ -class IntrospectionProcessor +class IntrospectionProcessor implements ProcessorInterface { - private $level; + private Level $level; - private $skipClassesPartials; + /** @var string[] */ + private array $skipClassesPartials; - private $skipStackFramesCount; + private int $skipStackFramesCount; - private $skipFunctions = [ + private const SKIP_FUNCTIONS = [ 'call_user_func', 'call_user_func_array', ]; - public function __construct($level = Logger::DEBUG, array $skipClassesPartials = [], int $skipStackFramesCount = 0) + /** + * @param string|int|Level $level The minimum logging level at which this Processor will be triggered + * @param string[] $skipClassesPartials + * + * @phpstan-param value-of|value-of|Level|LogLevel::* $level + */ + public function __construct(int|string|Level $level = Level::Debug, array $skipClassesPartials = [], int $skipStackFramesCount = 0) { $this->level = Logger::toMonologLevel($level); $this->skipClassesPartials = array_merge(['Monolog\\'], $skipClassesPartials); $this->skipStackFramesCount = $skipStackFramesCount; } - public function __invoke(array $record): array + /** + * @inheritDoc + */ + public function __invoke(LogRecord $record): LogRecord { // return if the level is not high enough - if ($record['level'] < $this->level) { + if ($record->level->isLowerThan($this->level)) { return $record; } @@ -69,7 +82,7 @@ public function __invoke(array $record): array continue 2; } } - } elseif (in_array($trace[$i]['function'], $this->skipFunctions)) { + } elseif (in_array($trace[$i]['function'], self::SKIP_FUNCTIONS, true)) { $i++; continue; @@ -81,12 +94,13 @@ public function __invoke(array $record): array $i += $this->skipStackFramesCount; // we should have the call source now - $record['extra'] = array_merge( - $record['extra'], + $record->extra = array_merge( + $record->extra, [ 'file' => isset($trace[$i - 1]['file']) ? $trace[$i - 1]['file'] : null, 'line' => isset($trace[$i - 1]['line']) ? $trace[$i - 1]['line'] : null, 'class' => isset($trace[$i]['class']) ? $trace[$i]['class'] : null, + 'callType' => isset($trace[$i]['type']) ? $trace[$i]['type'] : null, 'function' => isset($trace[$i]['function']) ? $trace[$i]['function'] : null, ] ); @@ -94,12 +108,15 @@ public function __invoke(array $record): array return $record; } - private function isTraceClassOrSkippedFunction(array $trace, int $index) + /** + * @param array $trace + */ + private function isTraceClassOrSkippedFunction(array $trace, int $index): bool { if (!isset($trace[$index])) { return false; } - return isset($trace[$index]['class']) || in_array($trace[$index]['function'], $this->skipFunctions); + return isset($trace[$index]['class']) || in_array($trace[$index]['function'], self::SKIP_FUNCTIONS, true); } } diff --git a/src/Monolog/Processor/MemoryPeakUsageProcessor.php b/src/Monolog/Processor/MemoryPeakUsageProcessor.php index b6f8acc19..adc32c65d 100644 --- a/src/Monolog/Processor/MemoryPeakUsageProcessor.php +++ b/src/Monolog/Processor/MemoryPeakUsageProcessor.php @@ -11,6 +11,8 @@ namespace Monolog\Processor; +use Monolog\LogRecord; + /** * Injects memory_get_peak_usage in all records * @@ -19,12 +21,18 @@ */ class MemoryPeakUsageProcessor extends MemoryProcessor { - public function __invoke(array $record): array + /** + * @inheritDoc + */ + public function __invoke(LogRecord $record): LogRecord { - $bytes = memory_get_peak_usage($this->realUsage); - $formatted = $this->formatBytes($bytes); + $usage = memory_get_peak_usage($this->realUsage); + + if ($this->useFormatting) { + $usage = $this->formatBytes($usage); + } - $record['extra']['memory_peak_usage'] = $formatted; + $record->extra['memory_peak_usage'] = $usage; return $record; } diff --git a/src/Monolog/Processor/MemoryProcessor.php b/src/Monolog/Processor/MemoryProcessor.php index 5b06c2e99..f808e51b4 100644 --- a/src/Monolog/Processor/MemoryProcessor.php +++ b/src/Monolog/Processor/MemoryProcessor.php @@ -16,17 +16,17 @@ * * @author Rob Jensen */ -abstract class MemoryProcessor +abstract class MemoryProcessor implements ProcessorInterface { /** * @var bool If true, get the real size of memory allocated from system. Else, only the memory used by emalloc() is reported. */ - protected $realUsage; + protected bool $realUsage; /** * @var bool If true, then format memory size to human readable string (MB, KB, B depending on size) */ - protected $useFormatting; + protected bool $useFormatting; /** * @param bool $realUsage Set this to true to get the real size of memory allocated from system. @@ -41,7 +41,6 @@ public function __construct(bool $realUsage = true, bool $useFormatting = true) /** * Formats bytes into a human readable string if $this->useFormatting is true, otherwise return $bytes as is * - * @param int $bytes * @return string|int Formatted string if $this->useFormatting is true, otherwise return $bytes as int */ protected function formatBytes(int $bytes) diff --git a/src/Monolog/Processor/MemoryUsageProcessor.php b/src/Monolog/Processor/MemoryUsageProcessor.php index 31c9396ff..a814b1df3 100644 --- a/src/Monolog/Processor/MemoryUsageProcessor.php +++ b/src/Monolog/Processor/MemoryUsageProcessor.php @@ -11,6 +11,8 @@ namespace Monolog\Processor; +use Monolog\LogRecord; + /** * Injects memory_get_usage in all records * @@ -19,12 +21,18 @@ */ class MemoryUsageProcessor extends MemoryProcessor { - public function __invoke(array $record): array + /** + * @inheritDoc + */ + public function __invoke(LogRecord $record): LogRecord { - $bytes = memory_get_usage($this->realUsage); - $formatted = $this->formatBytes($bytes); + $usage = memory_get_usage($this->realUsage); + + if ($this->useFormatting) { + $usage = $this->formatBytes($usage); + } - $record['extra']['memory_usage'] = $formatted; + $record->extra['memory_usage'] = $usage; return $record; } diff --git a/src/Monolog/Processor/MercurialProcessor.php b/src/Monolog/Processor/MercurialProcessor.php index 6ab5bba94..47b1e64ff 100644 --- a/src/Monolog/Processor/MercurialProcessor.php +++ b/src/Monolog/Processor/MercurialProcessor.php @@ -11,42 +11,57 @@ namespace Monolog\Processor; +use Monolog\Level; use Monolog\Logger; +use Psr\Log\LogLevel; +use Monolog\LogRecord; /** * Injects Hg branch and Hg revision number in all records * * @author Jonathan A. Schweder */ -class MercurialProcessor +class MercurialProcessor implements ProcessorInterface { - private $level; - private static $cache; + private Level $level; + /** @var array{branch: string, revision: string}|array|null */ + private static $cache = null; - public function __construct($level = Logger::DEBUG) + /** + * @param int|string|Level $level The minimum logging level at which this Processor will be triggered + * + * @phpstan-param value-of|value-of|Level|LogLevel::* $level + */ + public function __construct(int|string|Level $level = Level::Debug) { $this->level = Logger::toMonologLevel($level); } - public function __invoke(array $record): array + /** + * @inheritDoc + */ + public function __invoke(LogRecord $record): LogRecord { // return if the level is not high enough - if ($record['level'] < $this->level) { + if ($record->level->isLowerThan($this->level)) { return $record; } - $record['extra']['hg'] = self::getMercurialInfo(); + $record->extra['hg'] = self::getMercurialInfo(); return $record; } + /** + * @return array{branch: string, revision: string}|array + */ private static function getMercurialInfo(): array { - if (self::$cache) { + if (self::$cache !== null) { return self::$cache; } - $result = explode(' ', trim(`hg id -nb`)); + $result = explode(' ', trim((string) shell_exec('hg id -nb'))); if (count($result) >= 3) { return self::$cache = [ diff --git a/src/Monolog/Processor/ProcessIdProcessor.php b/src/Monolog/Processor/ProcessIdProcessor.php index 392bd1314..bb9a52243 100644 --- a/src/Monolog/Processor/ProcessIdProcessor.php +++ b/src/Monolog/Processor/ProcessIdProcessor.php @@ -11,16 +11,21 @@ namespace Monolog\Processor; +use Monolog\LogRecord; + /** * Adds value of getmypid into records * * @author Andreas Hörnicke */ -class ProcessIdProcessor +class ProcessIdProcessor implements ProcessorInterface { - public function __invoke(array $record): array + /** + * @inheritDoc + */ + public function __invoke(LogRecord $record): LogRecord { - $record['extra']['process_id'] = getmypid(); + $record->extra['process_id'] = getmypid(); return $record; } diff --git a/src/Monolog/Processor/ProcessorInterface.php b/src/Monolog/Processor/ProcessorInterface.php new file mode 100644 index 000000000..ebe41fc20 --- /dev/null +++ b/src/Monolog/Processor/ProcessorInterface.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Processor; + +use Monolog\LogRecord; + +/** + * An optional interface to allow labelling Monolog processors. + * + * @author Nicolas Grekas + */ +interface ProcessorInterface +{ + /** + * @return LogRecord The processed record + */ + public function __invoke(LogRecord $record); +} diff --git a/src/Monolog/Processor/PsrLogMessageProcessor.php b/src/Monolog/Processor/PsrLogMessageProcessor.php index 7311c2be1..f2407d563 100644 --- a/src/Monolog/Processor/PsrLogMessageProcessor.php +++ b/src/Monolog/Processor/PsrLogMessageProcessor.php @@ -11,6 +11,9 @@ namespace Monolog\Processor; +use Monolog\Utils; +use Monolog\LogRecord; + /** * Processes a record's message according to PSR-3 rules * @@ -18,67 +21,65 @@ * * @author Jordi Boggiano */ -class PsrLogMessageProcessor +class PsrLogMessageProcessor implements ProcessorInterface { - const SIMPLE_DATE = "Y-m-d\TH:i:s.uP"; + public const SIMPLE_DATE = "Y-m-d\TH:i:s.uP"; - private $dateFormat; + private ?string $dateFormat; - /** @var bool */ - private $removeUsedContextFields; + private bool $removeUsedContextFields; /** - * @param string $dateFormat The format of the timestamp: one supported by DateTime::format - * @param bool $removeUsedContextFields If set to true the fields interpolated into message gets unset + * @param string|null $dateFormat The format of the timestamp: one supported by DateTime::format + * @param bool $removeUsedContextFields If set to true the fields interpolated into message gets unset */ - public function __construct(string $dateFormat = null, bool $removeUsedContextFields = false) + public function __construct(?string $dateFormat = null, bool $removeUsedContextFields = false) { $this->dateFormat = $dateFormat; $this->removeUsedContextFields = $removeUsedContextFields; } /** - * @param array $record - * @return array + * @inheritDoc */ - public function __invoke(array $record): array + public function __invoke(LogRecord $record): LogRecord { - if (false === strpos($record['message'], '{')) { + if (false === strpos($record->message, '{')) { return $record; } $replacements = []; - foreach ($record['context'] as $key => $val) { + $context = $record->context; + + foreach ($context as $key => $val) { $placeholder = '{' . $key . '}'; - if (strpos($record['message'], $placeholder) === false) { + if (strpos($record->message, $placeholder) === false) { continue; } - if (is_null($val) || is_scalar($val) || (is_object($val) && method_exists($val, "__toString"))) { + if (null === $val || is_scalar($val) || (is_object($val) && method_exists($val, "__toString"))) { $replacements[$placeholder] = $val; } elseif ($val instanceof \DateTimeInterface) { - if (!$this->dateFormat && $val instanceof \Monolog\DateTimeImmutable) { + if (null === $this->dateFormat && $val instanceof \Monolog\DateTimeImmutable) { // handle monolog dates using __toString if no specific dateFormat was asked for // so that it follows the useMicroseconds flag $replacements[$placeholder] = (string) $val; } else { - $replacements[$placeholder] = $val->format($this->dateFormat ?: static::SIMPLE_DATE); + $replacements[$placeholder] = $val->format($this->dateFormat ?? static::SIMPLE_DATE); } } elseif (is_object($val)) { - $replacements[$placeholder] = '[object '.get_class($val).']'; + $replacements[$placeholder] = '[object '.Utils::getClass($val).']'; } elseif (is_array($val)) { - $replacements[$placeholder] = 'array'.@json_encode($val); + $replacements[$placeholder] = 'array'.Utils::jsonEncode($val, null, true); } else { $replacements[$placeholder] = '['.gettype($val).']'; } if ($this->removeUsedContextFields) { - unset($record['context'][$key]); + unset($context[$key]); } } - $record['message'] = strtr($record['message'], $replacements); - - return $record; + return $record->with(message: strtr($record->message, $replacements), context: $context); } } diff --git a/src/Monolog/Processor/TagProcessor.php b/src/Monolog/Processor/TagProcessor.php index 6371986f4..4543ccf61 100644 --- a/src/Monolog/Processor/TagProcessor.php +++ b/src/Monolog/Processor/TagProcessor.php @@ -11,33 +11,52 @@ namespace Monolog\Processor; +use Monolog\LogRecord; + /** * Adds a tags array into record * * @author Martijn Riemers */ -class TagProcessor +class TagProcessor implements ProcessorInterface { - private $tags; + /** @var string[] */ + private array $tags; + /** + * @param string[] $tags + */ public function __construct(array $tags = []) { $this->setTags($tags); } - public function addTags(array $tags = []) + /** + * @param string[] $tags + */ + public function addTags(array $tags = []): self { $this->tags = array_merge($this->tags, $tags); + + return $this; } - public function setTags(array $tags = []) + /** + * @param string[] $tags + */ + public function setTags(array $tags = []): self { $this->tags = $tags; + + return $this; } - public function __invoke(array $record): array + /** + * @inheritDoc + */ + public function __invoke(LogRecord $record): LogRecord { - $record['extra']['tags'] = $this->tags; + $record->extra['tags'] = $this->tags; return $record; } diff --git a/src/Monolog/Processor/UidProcessor.php b/src/Monolog/Processor/UidProcessor.php index 601171b35..3a0c128c2 100644 --- a/src/Monolog/Processor/UidProcessor.php +++ b/src/Monolog/Processor/UidProcessor.php @@ -11,36 +11,57 @@ namespace Monolog\Processor; +use Monolog\ResettableInterface; +use Monolog\LogRecord; + /** * Adds a unique identifier into records * * @author Simon Mönch */ -class UidProcessor +class UidProcessor implements ProcessorInterface, ResettableInterface { - private $uid; + /** @var non-empty-string */ + private string $uid; + /** + * @param int<1, 32> $length + */ public function __construct(int $length = 7) { - if (!is_int($length) || $length > 32 || $length < 1) { + if ($length > 32 || $length < 1) { throw new \InvalidArgumentException('The uid length must be an integer between 1 and 32'); } - $this->uid = substr(bin2hex(random_bytes((int) ceil($length / 2))), 0, $length); + $this->uid = $this->generateUid($length); } - public function __invoke(array $record): array + /** + * @inheritDoc + */ + public function __invoke(LogRecord $record): LogRecord { - $record['extra']['uid'] = $this->uid; + $record->extra['uid'] = $this->uid; return $record; } - /** - * @return string - */ public function getUid(): string { return $this->uid; } + + public function reset(): void + { + $this->uid = $this->generateUid(strlen($this->uid)); + } + + /** + * @param positive-int $length + * @return non-empty-string + */ + private function generateUid(int $length): string + { + return substr(bin2hex(random_bytes((int) ceil($length / 2))), 0, $length); + } } diff --git a/src/Monolog/Processor/WebProcessor.php b/src/Monolog/Processor/WebProcessor.php index 37324af4b..2088b180b 100644 --- a/src/Monolog/Processor/WebProcessor.php +++ b/src/Monolog/Processor/WebProcessor.php @@ -11,61 +11,73 @@ namespace Monolog\Processor; +use ArrayAccess; +use Monolog\LogRecord; + /** * Injects url/method and remote IP of the current web request in all records * * @author Jordi Boggiano */ -class WebProcessor +class WebProcessor implements ProcessorInterface { /** - * @var array|\ArrayAccess + * @var array|ArrayAccess */ - protected $serverData; + protected array|ArrayAccess $serverData; /** * Default fields * * Array is structured as [key in record.extra => key in $serverData] * - * @var array + * @var array */ - protected $extraFields = [ + protected array $extraFields = [ 'url' => 'REQUEST_URI', 'ip' => 'REMOTE_ADDR', 'http_method' => 'REQUEST_METHOD', 'server' => 'SERVER_NAME', 'referrer' => 'HTTP_REFERER', + 'user_agent' => 'HTTP_USER_AGENT', ]; /** - * @param array|\ArrayAccess $serverData Array or object w/ ArrayAccess that provides access to the $_SERVER data - * @param array|null $extraFields Field names and the related key inside $serverData to be added. If not provided it defaults to: url, ip, http_method, server, referrer + * @param array|ArrayAccess|null $serverData Array or object w/ ArrayAccess that provides access to the $_SERVER data + * @param array|array|null $extraFields Field names and the related key inside $serverData to be added (or just a list of field names to use the default configured $serverData mapping). If not provided it defaults to: [url, ip, http_method, server, referrer] + unique_id if present in server data */ - public function __construct($serverData = null, array $extraFields = null) + public function __construct(array|ArrayAccess|null $serverData = null, array|null $extraFields = null) { if (null === $serverData) { $this->serverData = &$_SERVER; - } elseif (is_array($serverData) || $serverData instanceof \ArrayAccess) { - $this->serverData = $serverData; } else { - throw new \UnexpectedValueException('$serverData must be an array or object implementing ArrayAccess.'); + $this->serverData = $serverData; + } + + $defaultEnabled = ['url', 'ip', 'http_method', 'server', 'referrer']; + if (isset($this->serverData['UNIQUE_ID'])) { + $this->extraFields['unique_id'] = 'UNIQUE_ID'; + $defaultEnabled[] = 'unique_id'; } - if (null !== $extraFields) { - if (isset($extraFields[0])) { - foreach (array_keys($this->extraFields) as $fieldName) { - if (!in_array($fieldName, $extraFields)) { - unset($this->extraFields[$fieldName]); - } + if (null === $extraFields) { + $extraFields = $defaultEnabled; + } + if (isset($extraFields[0])) { + foreach (array_keys($this->extraFields) as $fieldName) { + if (!in_array($fieldName, $extraFields, true)) { + unset($this->extraFields[$fieldName]); } - } else { - $this->extraFields = $extraFields; } + } else { + $this->extraFields = $extraFields; } } - public function __invoke(array $record): array + /** + * @inheritDoc + */ + public function __invoke(LogRecord $record): LogRecord { // skip processing if for some reason request data // is not present (CLI or wonky SAPIs) @@ -73,7 +85,7 @@ public function __invoke(array $record): array return $record; } - $record['extra'] = $this->appendExtraFields($record['extra']); + $record->extra = $this->appendExtraFields($record->extra); return $record; } @@ -85,16 +97,16 @@ public function addExtraField(string $extraName, string $serverName): self return $this; } + /** + * @param mixed[] $extra + * @return mixed[] + */ private function appendExtraFields(array $extra): array { foreach ($this->extraFields as $extraName => $serverName) { $extra[$extraName] = $this->serverData[$serverName] ?? null; } - if (isset($this->serverData['UNIQUE_ID'])) { - $extra['unique_id'] = $this->serverData['UNIQUE_ID']; - } - return $extra; } } diff --git a/src/Monolog/Registry.php b/src/Monolog/Registry.php index 99a434401..2ef2edceb 100644 --- a/src/Monolog/Registry.php +++ b/src/Monolog/Registry.php @@ -42,7 +42,7 @@ class Registry * * @var Logger[] */ - private static $loggers = []; + private static array $loggers = []; /** * Adds new logging channel to the registry @@ -52,9 +52,9 @@ class Registry * @param bool $overwrite Overwrite instance in the registry if the given name already exists? * @throws \InvalidArgumentException If $overwrite set to false and named Logger instance already exists */ - public static function addLogger(Logger $logger, $name = null, $overwrite = false) + public static function addLogger(Logger $logger, ?string $name = null, bool $overwrite = false): void { - $name = $name ?: $logger->getName(); + $name = $name ?? $logger->getName(); if (isset(self::$loggers[$name]) && !$overwrite) { throw new InvalidArgumentException('Logger with the given name already exists'); @@ -84,7 +84,7 @@ public static function hasLogger($logger): bool * * @param string|Logger $logger Name or logger instance */ - public static function removeLogger($logger) + public static function removeLogger($logger): void { if ($logger instanceof Logger) { if (false !== ($idx = array_search($logger, self::$loggers, true))) { @@ -98,7 +98,7 @@ public static function removeLogger($logger) /** * Clears the registry */ - public static function clear() + public static function clear(): void { self::$loggers = []; } @@ -109,7 +109,7 @@ public static function clear() * @param string $name Name of the requested Logger instance * @throws \InvalidArgumentException If named Logger instance is not in the registry */ - public static function getInstance($name): Logger + public static function getInstance(string $name): Logger { if (!isset(self::$loggers[$name])) { throw new InvalidArgumentException(sprintf('Requested "%s" logger instance is not in the registry', $name)); @@ -122,11 +122,11 @@ public static function getInstance($name): Logger * Gets Logger instance from the registry via static method call * * @param string $name Name of the requested Logger instance - * @param array $arguments Arguments passed to static method call + * @param mixed[] $arguments Arguments passed to static method call * @throws \InvalidArgumentException If named Logger instance is not in the registry * @return Logger Requested instance of Logger */ - public static function __callStatic($name, $arguments) + public static function __callStatic(string $name, array $arguments): Logger { return self::getInstance($name); } diff --git a/src/Monolog/ResettableInterface.php b/src/Monolog/ResettableInterface.php new file mode 100644 index 000000000..4983a6b35 --- /dev/null +++ b/src/Monolog/ResettableInterface.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog; + +/** + * Handler or Processor implementing this interface will be reset when Logger::reset() is called. + * + * Resetting ends a log cycle gets them back to their initial state. + * + * Resetting a Handler or a Processor means flushing/cleaning all buffers, resetting internal + * state, and getting it back to a state in which it can receive log records again. + * + * This is useful in case you want to avoid logs leaking between two requests or jobs when you + * have a long running process like a worker or an application server serving multiple requests + * in one process. + * + * @author Grégoire Pineau + */ +interface ResettableInterface +{ + public function reset(): void; +} diff --git a/src/Monolog/SignalHandler.php b/src/Monolog/SignalHandler.php new file mode 100644 index 000000000..e6b02b083 --- /dev/null +++ b/src/Monolog/SignalHandler.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog; + +use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; +use ReflectionExtension; + +/** + * Monolog POSIX signal handler + * + * @author Robert Gust-Bardon + */ +class SignalHandler +{ + private LoggerInterface $logger; + + /** @var array SIG_DFL, SIG_IGN or previous callable */ + private array $previousSignalHandler = []; + /** @var array */ + private array $signalLevelMap = []; + /** @var array */ + private array $signalRestartSyscalls = []; + + public function __construct(LoggerInterface $logger) + { + $this->logger = $logger; + } + + /** + * @param int|string|Level $level Level or level name + * @return $this + * + * @phpstan-param value-of|value-of|Level|LogLevel::* $level + */ + public function registerSignalHandler(int $signo, int|string|Level $level = LogLevel::CRITICAL, bool $callPrevious = true, bool $restartSyscalls = true, ?bool $async = true): self + { + if (!extension_loaded('pcntl') || !function_exists('pcntl_signal')) { + return $this; + } + + $level = Logger::toMonologLevel($level)->toPsrLogLevel(); + + if ($callPrevious) { + $handler = pcntl_signal_get_handler($signo); + $this->previousSignalHandler[$signo] = $handler; + } else { + unset($this->previousSignalHandler[$signo]); + } + $this->signalLevelMap[$signo] = $level; + $this->signalRestartSyscalls[$signo] = $restartSyscalls; + + if ($async !== null) { + pcntl_async_signals($async); + } + + pcntl_signal($signo, [$this, 'handleSignal'], $restartSyscalls); + + return $this; + } + + /** + * @param mixed $siginfo + */ + public function handleSignal(int $signo, $siginfo = null): void + { + static $signals = []; + + if (!$signals && extension_loaded('pcntl')) { + $pcntl = new ReflectionExtension('pcntl'); + foreach ($pcntl->getConstants() as $name => $value) { + if (substr($name, 0, 3) === 'SIG' && $name[3] !== '_' && is_int($value)) { + $signals[$value] = $name; + } + } + } + + $level = $this->signalLevelMap[$signo] ?? LogLevel::CRITICAL; + $signal = $signals[$signo] ?? $signo; + $context = $siginfo ?? []; + $this->logger->log($level, sprintf('Program received signal %s', $signal), $context); + + if (!isset($this->previousSignalHandler[$signo])) { + return; + } + + if ($this->previousSignalHandler[$signo] === SIG_DFL) { + if (extension_loaded('pcntl') && function_exists('pcntl_signal') && function_exists('pcntl_sigprocmask') && function_exists('pcntl_signal_dispatch') + && extension_loaded('posix') && function_exists('posix_getpid') && function_exists('posix_kill') + ) { + $restartSyscalls = $this->signalRestartSyscalls[$signo] ?? true; + pcntl_signal($signo, SIG_DFL, $restartSyscalls); + pcntl_sigprocmask(SIG_UNBLOCK, [$signo], $oldset); + posix_kill(posix_getpid(), $signo); + pcntl_signal_dispatch(); + pcntl_sigprocmask(SIG_SETMASK, $oldset); + pcntl_signal($signo, [$this, 'handleSignal'], $restartSyscalls); + } + } elseif (is_callable($this->previousSignalHandler[$signo])) { + $this->previousSignalHandler[$signo]($signo, $siginfo); + } + } +} diff --git a/src/Monolog/Test/TestCase.php b/src/Monolog/Test/TestCase.php index 23cf9add8..98204a95c 100644 --- a/src/Monolog/Test/TestCase.php +++ b/src/Monolog/Test/TestCase.php @@ -11,54 +11,70 @@ namespace Monolog\Test; +use Monolog\Level; use Monolog\Logger; +use Monolog\LogRecord; use Monolog\DateTimeImmutable; use Monolog\Formatter\FormatterInterface; +use Psr\Log\LogLevel; /** * Lets you easily generate log records and a dummy formatter for testing purposes - * * + * * @author Jordi Boggiano + * + * @internal feel free to reuse this to test your own handlers, this is marked internal to avoid issues with PHPStorm https://github.com/Seldaek/monolog/issues/1677 */ class TestCase extends \PHPUnit\Framework\TestCase { + public function tearDown(): void + { + parent::tearDown(); + + if (isset($this->handler)) { + unset($this->handler); + } + } + /** - * @return array Record + * @param array $context + * @param array $extra + * + * @phpstan-param value-of|value-of|Level|LogLevel::* $level */ - protected function getRecord($level = Logger::WARNING, $message = 'test', $context = []) + protected function getRecord(int|string|Level $level = Level::Warning, string|\Stringable $message = 'test', array $context = [], string $channel = 'test', \DateTimeImmutable $datetime = new DateTimeImmutable(true), array $extra = []): LogRecord { - return [ - 'message' => $message, - 'context' => $context, - 'level' => $level, - 'level_name' => Logger::getLevelName($level), - 'channel' => 'test', - 'datetime' => new DateTimeImmutable(true), - 'extra' => [], - ]; + return new LogRecord( + message: (string) $message, + context: $context, + level: Logger::toMonologLevel($level), + channel: $channel, + datetime: $datetime, + extra: $extra, + ); } /** - * @return array + * @phpstan-return list */ - protected function getMultipleRecords() + protected function getMultipleRecords(): array { return [ - $this->getRecord(Logger::DEBUG, 'debug message 1'), - $this->getRecord(Logger::DEBUG, 'debug message 2'), - $this->getRecord(Logger::INFO, 'information'), - $this->getRecord(Logger::WARNING, 'warning'), - $this->getRecord(Logger::ERROR, 'error'), + $this->getRecord(Level::Debug, 'debug message 1'), + $this->getRecord(Level::Debug, 'debug message 2'), + $this->getRecord(Level::Info, 'information'), + $this->getRecord(Level::Warning, 'warning'), + $this->getRecord(Level::Error, 'error'), ]; } protected function getIdentityFormatter(): FormatterInterface { $formatter = $this->createMock(FormatterInterface::class); - $formatter->expects($this->any()) + $formatter->expects(self::any()) ->method('format') - ->will($this->returnCallback(function ($record) { - return $record['message']; + ->will(self::returnCallback(function ($record) { + return $record->message; })); return $formatter; diff --git a/src/Monolog/Utils.php b/src/Monolog/Utils.php new file mode 100644 index 000000000..9dae2535f --- /dev/null +++ b/src/Monolog/Utils.php @@ -0,0 +1,274 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog; + +final class Utils +{ + const DEFAULT_JSON_FLAGS = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION | JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR; + + public static function getClass(object $object): string + { + $class = \get_class($object); + + if (false === ($pos = \strpos($class, "@anonymous\0"))) { + return $class; + } + + if (false === ($parent = \get_parent_class($class))) { + return \substr($class, 0, $pos + 10); + } + + return $parent . '@anonymous'; + } + + public static function substr(string $string, int $start, ?int $length = null): string + { + if (extension_loaded('mbstring')) { + return mb_strcut($string, $start, $length); + } + + return substr($string, $start, (null === $length) ? strlen($string) : $length); + } + + /** + * Makes sure if a relative path is passed in it is turned into an absolute path + * + * @param string $streamUrl stream URL or path without protocol + */ + public static function canonicalizePath(string $streamUrl): string + { + $prefix = ''; + if ('file://' === substr($streamUrl, 0, 7)) { + $streamUrl = substr($streamUrl, 7); + $prefix = 'file://'; + } + + // other type of stream, not supported + if (false !== strpos($streamUrl, '://')) { + return $streamUrl; + } + + // already absolute + if (substr($streamUrl, 0, 1) === '/' || substr($streamUrl, 1, 1) === ':' || substr($streamUrl, 0, 2) === '\\\\') { + return $prefix.$streamUrl; + } + + $streamUrl = getcwd() . '/' . $streamUrl; + + return $prefix.$streamUrl; + } + + /** + * Return the JSON representation of a value + * + * @param mixed $data + * @param int $encodeFlags flags to pass to json encode, defaults to DEFAULT_JSON_FLAGS + * @param bool $ignoreErrors whether to ignore encoding errors or to throw on error, when ignored and the encoding fails, "null" is returned which is valid json for null + * @throws \RuntimeException if encoding fails and errors are not ignored + * @return string when errors are ignored and the encoding fails, "null" is returned which is valid json for null + */ + public static function jsonEncode($data, ?int $encodeFlags = null, bool $ignoreErrors = false): string + { + if (null === $encodeFlags) { + $encodeFlags = self::DEFAULT_JSON_FLAGS; + } + + if ($ignoreErrors) { + $json = @json_encode($data, $encodeFlags); + if (false === $json) { + return 'null'; + } + + return $json; + } + + $json = json_encode($data, $encodeFlags); + if (false === $json) { + $json = self::handleJsonError(json_last_error(), $data); + } + + return $json; + } + + /** + * Handle a json_encode failure. + * + * If the failure is due to invalid string encoding, try to clean the + * input and encode again. If the second encoding attempt fails, the + * initial error is not encoding related or the input can't be cleaned then + * raise a descriptive exception. + * + * @param int $code return code of json_last_error function + * @param mixed $data data that was meant to be encoded + * @param int $encodeFlags flags to pass to json encode, defaults to JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION + * @throws \RuntimeException if failure can't be corrected + * @return string JSON encoded data after error correction + */ + public static function handleJsonError(int $code, $data, ?int $encodeFlags = null): string + { + if ($code !== JSON_ERROR_UTF8) { + self::throwEncodeError($code, $data); + } + + if (is_string($data)) { + self::detectAndCleanUtf8($data); + } elseif (is_array($data)) { + array_walk_recursive($data, ['Monolog\Utils', 'detectAndCleanUtf8']); + } else { + self::throwEncodeError($code, $data); + } + + if (null === $encodeFlags) { + $encodeFlags = self::DEFAULT_JSON_FLAGS; + } + + $json = json_encode($data, $encodeFlags); + + if ($json === false) { + self::throwEncodeError(json_last_error(), $data); + } + + return $json; + } + + /** + * @internal + */ + public static function pcreLastErrorMessage(int $code): string + { + if (PHP_VERSION_ID >= 80000) { + return preg_last_error_msg(); + } + + $constants = (get_defined_constants(true))['pcre']; + $constants = array_filter($constants, function ($key) { + return substr($key, -6) == '_ERROR'; + }, ARRAY_FILTER_USE_KEY); + + $constants = array_flip($constants); + + return $constants[$code] ?? 'UNDEFINED_ERROR'; + } + + /** + * Throws an exception according to a given code with a customized message + * + * @param int $code return code of json_last_error function + * @param mixed $data data that was meant to be encoded + * @throws \RuntimeException + */ + private static function throwEncodeError(int $code, $data): never + { + $msg = match ($code) { + JSON_ERROR_DEPTH => 'Maximum stack depth exceeded', + JSON_ERROR_STATE_MISMATCH => 'Underflow or the modes mismatch', + JSON_ERROR_CTRL_CHAR => 'Unexpected control character found', + JSON_ERROR_UTF8 => 'Malformed UTF-8 characters, possibly incorrectly encoded', + default => 'Unknown error', + }; + + throw new \RuntimeException('JSON encoding failed: '.$msg.'. Encoding: '.var_export($data, true)); + } + + /** + * Detect invalid UTF-8 string characters and convert to valid UTF-8. + * + * Valid UTF-8 input will be left unmodified, but strings containing + * invalid UTF-8 codepoints will be reencoded as UTF-8 with an assumed + * original encoding of ISO-8859-15. This conversion may result in + * incorrect output if the actual encoding was not ISO-8859-15, but it + * will be clean UTF-8 output and will not rely on expensive and fragile + * detection algorithms. + * + * Function converts the input in place in the passed variable so that it + * can be used as a callback for array_walk_recursive. + * + * @param mixed $data Input to check and convert if needed, passed by ref + */ + private static function detectAndCleanUtf8(&$data): void + { + if (is_string($data) && preg_match('//u', $data) !== 1) { + $data = preg_replace_callback( + '/[\x80-\xFF]+/', + function ($m) { + return function_exists('mb_convert_encoding') ? mb_convert_encoding($m[0], 'UTF-8', 'ISO-8859-1') : utf8_encode($m[0]); + }, + $data + ); + if (!is_string($data)) { + $pcreErrorCode = preg_last_error(); + + throw new \RuntimeException('Failed to preg_replace_callback: ' . $pcreErrorCode . ' / ' . self::pcreLastErrorMessage($pcreErrorCode)); + } + $data = str_replace( + ['¤', '¦', '¨', '´', '¸', '¼', '½', '¾'], + ['€', 'Š', 'š', 'Ž', 'ž', 'Œ', 'œ', 'Ÿ'], + $data + ); + } + } + + /** + * Converts a string with a valid 'memory_limit' format, to bytes. + * + * @param string|false $val + * @return int|false Returns an integer representing bytes. Returns FALSE in case of error. + */ + public static function expandIniShorthandBytes($val) + { + if (!is_string($val)) { + return false; + } + + // support -1 + if ((int) $val < 0) { + return (int) $val; + } + + if (preg_match('/^\s*(?\d+)(?:\.\d+)?\s*(?[gmk]?)\s*$/i', $val, $match) !== 1) { + return false; + } + + $val = (int) $match['val']; + switch (strtolower($match['unit'] ?? '')) { + case 'g': + $val *= 1024; + // no break + case 'm': + $val *= 1024; + // no break + case 'k': + $val *= 1024; + } + + return $val; + } + + public static function getRecordMessageForException(LogRecord $record): string + { + $context = ''; + $extra = ''; + + try { + if (\count($record->context) > 0) { + $context = "\nContext: " . json_encode($record->context, JSON_THROW_ON_ERROR); + } + if (\count($record->extra) > 0) { + $extra = "\nExtra: " . json_encode($record->extra, JSON_THROW_ON_ERROR); + } + } catch (\Throwable $e) { + // noop + } + + return "\nThe exception occurred while attempting to log: " . $record->message . $context . $extra; + } +} diff --git a/tests/Monolog/Attribute/AsMonologProcessorTest.php b/tests/Monolog/Attribute/AsMonologProcessorTest.php new file mode 100644 index 000000000..b0af8f6fb --- /dev/null +++ b/tests/Monolog/Attribute/AsMonologProcessorTest.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Attribute; + +use PHPUnit\Framework\TestCase; + +/** + * @requires PHP 8.0 + */ +final class AsMonologProcessorTest extends TestCase +{ + public function test(): void + { + $asMonologProcessor = new AsMonologProcessor('channel', 'handler', 'method'); + $this->assertSame('channel', $asMonologProcessor->channel); + $this->assertSame('handler', $asMonologProcessor->handler); + $this->assertSame('method', $asMonologProcessor->method); + + $asMonologProcessor = new AsMonologProcessor(null, null, null); + $this->assertNull($asMonologProcessor->channel); + $this->assertNull($asMonologProcessor->handler); + $this->assertNull($asMonologProcessor->method); + } +} diff --git a/tests/Monolog/ErrorHandlerTest.php b/tests/Monolog/ErrorHandlerTest.php index 9a8a5be03..01f875451 100644 --- a/tests/Monolog/ErrorHandlerTest.php +++ b/tests/Monolog/ErrorHandlerTest.php @@ -28,25 +28,34 @@ public function testHandleError() $logger = new Logger('test', [$handler = new TestHandler]); $errHandler = new ErrorHandler($logger); - $resHandler = $errHandler->registerErrorHandler([E_USER_NOTICE => Logger::EMERGENCY], false); - $this->assertSame($errHandler, $resHandler); - trigger_error('Foo', E_USER_ERROR); - $this->assertCount(1, $handler->getRecords()); - $this->assertTrue($handler->hasErrorRecords()); - trigger_error('Foo', E_USER_NOTICE); - $this->assertCount(2, $handler->getRecords()); - $this->assertTrue($handler->hasEmergencyRecords()); - - $errHandler->registerErrorHandler([], true); - $prop = $this->getPrivatePropertyValue($errHandler, 'previousErrorHandler'); - $this->assertTrue(is_callable($prop)); + $phpunitHandler = set_error_handler($prevHandler = function () { + }); + + try { + $errHandler->registerErrorHandler([], true); + $prop = $this->getPrivatePropertyValue($errHandler, 'previousErrorHandler'); + $this->assertTrue(is_callable($prop)); + $this->assertSame($prevHandler, $prop); + + $resHandler = $errHandler->registerErrorHandler([E_USER_NOTICE => LogLevel::EMERGENCY], false); + $this->assertSame($errHandler, $resHandler); + trigger_error('Foo', E_USER_ERROR); + $this->assertCount(1, $handler->getRecords()); + $this->assertTrue($handler->hasErrorRecords()); + trigger_error('Foo', E_USER_NOTICE); + $this->assertCount(2, $handler->getRecords()); + $this->assertTrue($handler->hasEmergencyRecords()); + } finally { + // restore previous handler + set_error_handler($phpunitHandler); + } } public function fatalHandlerProvider() { return [ - [null, 10, str_repeat(' ', 1024 * 10), null], - [E_ALL, 15, str_repeat(' ', 1024 * 15), E_ALL], + [null, 10, str_repeat(' ', 1024 * 10), LogLevel::ALERT], + [LogLevel::DEBUG, 15, str_repeat(' ', 1024 * 15), LogLevel::DEBUG], ]; } @@ -116,7 +125,6 @@ public function testCodeToString() $this->assertEquals('E_DEPRECATED', $method->invokeArgs(null, [E_DEPRECATED])); $this->assertEquals('E_USER_DEPRECATED', $method->invokeArgs(null, [E_USER_DEPRECATED])); - $this->assertEquals('Unknown PHP error', $method->invokeArgs(null, ['RANDOM_TEXT'])); $this->assertEquals('Unknown PHP error', $method->invokeArgs(null, [E_ALL])); } } diff --git a/tests/Monolog/Formatter/ChromePHPFormatterTest.php b/tests/Monolog/Formatter/ChromePHPFormatterTest.php index e44de85af..f290ba699 100644 --- a/tests/Monolog/Formatter/ChromePHPFormatterTest.php +++ b/tests/Monolog/Formatter/ChromePHPFormatterTest.php @@ -11,9 +11,10 @@ namespace Monolog\Formatter; -use Monolog\Logger; +use Monolog\Level; +use Monolog\Test\TestCase; -class ChromePHPFormatterTest extends \PHPUnit\Framework\TestCase +class ChromePHPFormatterTest extends TestCase { /** * @covers Monolog\Formatter\ChromePHPFormatter::format @@ -21,15 +22,14 @@ class ChromePHPFormatterTest extends \PHPUnit\Framework\TestCase public function testDefaultFormat() { $formatter = new ChromePHPFormatter(); - $record = [ - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => ['from' => 'logger'], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => ['ip' => '127.0.0.1'], - 'message' => 'log', - ]; + $record = $this->getRecord( + Level::Error, + 'log', + channel: 'meh', + context: ['from' => 'logger'], + datetime: new \DateTimeImmutable("@0"), + extra: ['ip' => '127.0.0.1'], + ); $message = $formatter->format($record); @@ -54,15 +54,14 @@ public function testDefaultFormat() public function testFormatWithFileAndLine() { $formatter = new ChromePHPFormatter(); - $record = [ - 'level' => Logger::CRITICAL, - 'level_name' => 'CRITICAL', - 'channel' => 'meh', - 'context' => ['from' => 'logger'], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => ['ip' => '127.0.0.1', 'file' => 'test', 'line' => 14], - 'message' => 'log', - ]; + $record = $this->getRecord( + Level::Critical, + 'log', + channel: 'meh', + context: ['from' => 'logger'], + datetime: new \DateTimeImmutable("@0"), + extra: ['ip' => '127.0.0.1', 'file' => 'test', 'line' => 14], + ); $message = $formatter->format($record); @@ -87,15 +86,12 @@ public function testFormatWithFileAndLine() public function testFormatWithoutContext() { $formatter = new ChromePHPFormatter(); - $record = [ - 'level' => Logger::DEBUG, - 'level_name' => 'DEBUG', - 'channel' => 'meh', - 'context' => [], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => [], - 'message' => 'log', - ]; + $record = $this->getRecord( + Level::Debug, + 'log', + channel: 'meh', + datetime: new \DateTimeImmutable("@0"), + ); $message = $formatter->format($record); @@ -117,24 +113,18 @@ public function testBatchFormatThrowException() { $formatter = new ChromePHPFormatter(); $records = [ - [ - 'level' => Logger::INFO, - 'level_name' => 'INFO', - 'channel' => 'meh', - 'context' => [], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => [], - 'message' => 'log', - ], - [ - 'level' => Logger::WARNING, - 'level_name' => 'WARNING', - 'channel' => 'foo', - 'context' => [], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => [], - 'message' => 'log2', - ], + $this->getRecord( + Level::Info, + 'log', + channel: 'meh', + datetime: new \DateTimeImmutable("@0"), + ), + $this->getRecord( + Level::Warning, + 'log2', + channel: 'foo', + datetime: new \DateTimeImmutable("@0"), + ), ]; $this->assertEquals( diff --git a/tests/Monolog/Formatter/ElasticaFormatterTest.php b/tests/Monolog/Formatter/ElasticaFormatterTest.php index 5e4eeb9c4..2698b60a4 100644 --- a/tests/Monolog/Formatter/ElasticaFormatterTest.php +++ b/tests/Monolog/Formatter/ElasticaFormatterTest.php @@ -11,11 +11,12 @@ namespace Monolog\Formatter; -use Monolog\Logger; +use Monolog\Level; +use Monolog\Test\TestCase; -class ElasticaFormatterTest extends \PHPUnit\Framework\TestCase +class ElasticaFormatterTest extends TestCase { - public function setUp() + public function setUp(): void { if (!class_exists("Elastica\Document")) { $this->markTestSkipped("ruflin/elastica not installed"); @@ -30,18 +31,16 @@ public function setUp() public function testFormat() { // test log message - $msg = [ - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => ['foo' => 7, 'bar', 'class' => new \stdClass], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => [], - 'message' => 'log', - ]; + $msg = $this->getRecord( + Level::Error, + 'log', + channel: 'meh', + context: ['foo' => 7, 'bar', 'class' => new \stdClass], + datetime: new \DateTimeImmutable("@0"), + ); // expected values - $expected = $msg; + $expected = $msg->toArray(); $expected['datetime'] = '1970-01-01T00:00:00.000000+00:00'; $expected['context'] = [ 'class' => ['stdClass' => []], @@ -55,9 +54,10 @@ public function testFormat() $this->assertInstanceOf('Elastica\Document', $doc); // Document parameters - $params = $doc->getParams(); - $this->assertEquals('my_index', $params['_index']); - $this->assertEquals('doc_type', $params['_type']); + $this->assertEquals('my_index', $doc->getIndex()); + if (method_exists($doc, 'getType')) { + $this->assertEquals('doc_type', $doc->getType()); + } // Document data values $data = $doc->getData(); diff --git a/tests/Monolog/Formatter/ElasticsearchFormatterTest.php b/tests/Monolog/Formatter/ElasticsearchFormatterTest.php new file mode 100644 index 000000000..438bbfd39 --- /dev/null +++ b/tests/Monolog/Formatter/ElasticsearchFormatterTest.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Formatter; + +use Monolog\Level; +use Monolog\Test\TestCase; + +class ElasticsearchFormatterTest extends TestCase +{ + /** + * @covers Monolog\Formatter\ElasticsearchFormatter::__construct + * @covers Monolog\Formatter\ElasticsearchFormatter::format + * @covers Monolog\Formatter\ElasticsearchFormatter::getDocument + */ + public function testFormat() + { + // Test log message + $msg = $this->getRecord( + Level::Error, + 'log', + channel: 'meh', + context: ['foo' => 7, 'bar', 'class' => new \stdClass], + datetime: new \DateTimeImmutable("@0"), + ); + + // Expected values + $expected = $msg->toArray(); + $expected['datetime'] = '1970-01-01T00:00:00+0000'; + $expected['context'] = [ + 'class' => ['stdClass' => []], + 'foo' => 7, + 0 => 'bar', + ]; + + // Format log message + $formatter = new ElasticsearchFormatter('my_index', 'doc_type'); + $doc = $formatter->format($msg); + $this->assertIsArray($doc); + + // Record parameters + $this->assertEquals('my_index', $doc['_index']); + $this->assertEquals('doc_type', $doc['_type']); + + // Record data values + foreach (array_keys($expected) as $key) { + $this->assertEquals($expected[$key], $doc[$key]); + } + } + + /** + * @covers Monolog\Formatter\ElasticsearchFormatter::getIndex + * @covers Monolog\Formatter\ElasticsearchFormatter::getType + */ + public function testGetters() + { + $formatter = new ElasticsearchFormatter('my_index', 'doc_type'); + $this->assertEquals('my_index', $formatter->getIndex()); + $this->assertEquals('doc_type', $formatter->getType()); + } +} diff --git a/tests/Monolog/Formatter/FlowdockFormatterTest.php b/tests/Monolog/Formatter/FlowdockFormatterTest.php index f922d5302..1b4e81768 100644 --- a/tests/Monolog/Formatter/FlowdockFormatterTest.php +++ b/tests/Monolog/Formatter/FlowdockFormatterTest.php @@ -11,7 +11,7 @@ namespace Monolog\Formatter; -use Monolog\Logger; +use Monolog\Level; use Monolog\Test\TestCase; class FlowdockFormatterTest extends TestCase @@ -34,7 +34,7 @@ public function testFormat() ]; $formatted = $formatter->format($record); - $this->assertEquals($expected, $formatted['flowdock']); + $this->assertEquals($expected, $formatted); } /** @@ -44,12 +44,12 @@ public function testFormatBatch() { $formatter = new FlowdockFormatter('test_source', 'source@test.com'); $records = [ - $this->getRecord(Logger::WARNING), - $this->getRecord(Logger::DEBUG), + $this->getRecord(Level::Warning), + $this->getRecord(Level::Debug), ]; $formatted = $formatter->formatBatch($records); - $this->assertArrayHasKey('flowdock', $formatted[0]); - $this->assertArrayHasKey('flowdock', $formatted[1]); + $this->assertArrayHasKey('from_address', $formatted[0]); + $this->assertArrayHasKey('from_address', $formatted[1]); } } diff --git a/tests/Monolog/Formatter/FluentdFormatterTest.php b/tests/Monolog/Formatter/FluentdFormatterTest.php index ca6ba3bb6..318a5ca04 100644 --- a/tests/Monolog/Formatter/FluentdFormatterTest.php +++ b/tests/Monolog/Formatter/FluentdFormatterTest.php @@ -11,7 +11,7 @@ namespace Monolog\Formatter; -use Monolog\Logger; +use Monolog\Level; use Monolog\Test\TestCase; class FluentdFormatterTest extends TestCase @@ -35,8 +35,7 @@ public function testConstruct() */ public function testFormat() { - $record = $this->getRecord(Logger::WARNING); - $record['datetime'] = new \DateTimeImmutable("@0"); + $record = $this->getRecord(Level::Warning, datetime: new \DateTimeImmutable("@0")); $formatter = new FluentdFormatter(); $this->assertEquals( @@ -50,8 +49,7 @@ public function testFormat() */ public function testFormatWithTag() { - $record = $this->getRecord(Logger::ERROR); - $record['datetime'] = new \DateTimeImmutable("@0"); + $record = $this->getRecord(Level::Error, datetime: new \DateTimeImmutable("@0")); $formatter = new FluentdFormatter(true); $this->assertEquals( diff --git a/tests/Monolog/Formatter/GelfMessageFormatterTest.php b/tests/Monolog/Formatter/GelfMessageFormatterTest.php index 012de3cd1..eab7022ce 100644 --- a/tests/Monolog/Formatter/GelfMessageFormatterTest.php +++ b/tests/Monolog/Formatter/GelfMessageFormatterTest.php @@ -11,11 +11,12 @@ namespace Monolog\Formatter; -use Monolog\Logger; +use Monolog\Level; +use Monolog\Test\TestCase; -class GelfMessageFormatterTest extends \PHPUnit\Framework\TestCase +class GelfMessageFormatterTest extends TestCase { - public function setUp() + public function setUp(): void { if (!class_exists('\Gelf\Message')) { $this->markTestSkipped("graylog2/gelf-php is not installed"); @@ -28,24 +29,21 @@ public function setUp() public function testDefaultFormatter() { $formatter = new GelfMessageFormatter(); - $record = [ - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => [], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => [], - 'message' => 'log', - ]; + $record = $this->getRecord( + Level::Error, + 'log', + channel: 'meh', + datetime: new \DateTimeImmutable("@0"), + ); $message = $formatter->format($record); $this->assertInstanceOf('Gelf\Message', $message); $this->assertEquals(0, $message->getTimestamp()); $this->assertEquals('log', $message->getShortMessage()); - $this->assertEquals('meh', $message->getFacility()); - $this->assertEquals(null, $message->getLine()); - $this->assertEquals(null, $message->getFile()); + $this->assertEquals('meh', $message->getAdditional('facility')); + $this->assertEquals(false, $message->hasAdditional('line')); + $this->assertEquals(false, $message->hasAdditional('file')); $this->assertEquals($this->isLegacy() ? 3 : 'error', $message->getLevel()); $this->assertNotEmpty($message->getHost()); @@ -63,36 +61,20 @@ public function testDefaultFormatter() public function testFormatWithFileAndLine() { $formatter = new GelfMessageFormatter(); - $record = [ - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => ['from' => 'logger'], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => ['file' => 'test', 'line' => 14], - 'message' => 'log', - ]; + $record = $this->getRecord( + Level::Error, + 'log', + channel: 'meh', + context: ['from' => 'logger'], + datetime: new \DateTimeImmutable("@0"), + extra: ['file' => 'test', 'line' => 14], + ); $message = $formatter->format($record); $this->assertInstanceOf('Gelf\Message', $message); - $this->assertEquals('test', $message->getFile()); - $this->assertEquals(14, $message->getLine()); - } - - /** - * @covers Monolog\Formatter\GelfMessageFormatter::format - * @expectedException InvalidArgumentException - */ - public function testFormatInvalidFails() - { - $formatter = new GelfMessageFormatter(); - $record = [ - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - ]; - - $formatter->format($record); + $this->assertEquals('test', $message->getAdditional('file')); + $this->assertEquals(14, $message->getAdditional('line')); } /** @@ -101,15 +83,14 @@ public function testFormatInvalidFails() public function testFormatWithContext() { $formatter = new GelfMessageFormatter(); - $record = [ - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => ['from' => 'logger'], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => ['key' => 'pair'], - 'message' => 'log', - ]; + $record = $this->getRecord( + Level::Error, + 'log', + channel: 'meh', + context: ['from' => 'logger'], + datetime: new \DateTimeImmutable("@0"), + extra: ['key' => 'pair'], + ); $message = $formatter->format($record); @@ -138,26 +119,24 @@ public function testFormatWithContext() public function testFormatWithContextContainingException() { $formatter = new GelfMessageFormatter(); - $record = [ - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => ['from' => 'logger', 'exception' => [ + $record = $this->getRecord( + Level::Error, + 'log', + channel: 'meh', + context: ['from' => 'logger', 'exception' => [ 'class' => '\Exception', 'file' => '/some/file/in/dir.php:56', 'trace' => ['/some/file/1.php:23', '/some/file/2.php:3'], ]], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => [], - 'message' => 'log', - ]; + datetime: new \DateTimeImmutable("@0"), + ); $message = $formatter->format($record); $this->assertInstanceOf('Gelf\Message', $message); - $this->assertEquals("/some/file/in/dir.php", $message->getFile()); - $this->assertEquals("56", $message->getLine()); + $this->assertEquals("/some/file/in/dir.php", $message->getAdditional('file')); + $this->assertEquals("56", $message->getAdditional('line')); } /** @@ -166,15 +145,14 @@ public function testFormatWithContextContainingException() public function testFormatWithExtra() { $formatter = new GelfMessageFormatter(); - $record = [ - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => ['from' => 'logger'], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => ['key' => 'pair'], - 'message' => 'log', - ]; + $record = $this->getRecord( + Level::Error, + 'log', + channel: 'meh', + context: ['from' => 'logger'], + datetime: new \DateTimeImmutable("@0"), + extra: ['key' => 'pair'], + ); $message = $formatter->format($record); @@ -200,15 +178,14 @@ public function testFormatWithExtra() public function testFormatWithLargeData() { $formatter = new GelfMessageFormatter(); - $record = [ - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => ['exception' => str_repeat(' ', 32767)], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => ['key' => str_repeat(' ', 32767)], - 'message' => 'log', - ]; + $record = $this->getRecord( + Level::Error, + 'log', + channel: 'meh', + context: ['exception' => str_repeat(' ', 32767)], + datetime: new \DateTimeImmutable("@0"), + extra: ['key' => str_repeat(' ', 32767)], + ); $message = $formatter->format($record); $messageArray = $message->toArray(); @@ -227,14 +204,13 @@ public function testFormatWithLargeData() public function testFormatWithUnlimitedLength() { $formatter = new GelfMessageFormatter('LONG_SYSTEM_NAME', null, 'ctxt_', PHP_INT_MAX); - $record = array( - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => array('exception' => str_repeat(' ', 32767 * 2)), - 'datetime' => new \DateTime("@0"), - 'extra' => array('key' => str_repeat(' ', 32767 * 2)), - 'message' => 'log', + $record = $this->getRecord( + Level::Error, + 'log', + channel: 'meh', + context: ['exception' => str_repeat(' ', 32767 * 2)], + datetime: new \DateTimeImmutable("@0"), + extra: ['key' => str_repeat(' ', 32767 * 2)], ); $message = $formatter->format($record); $messageArray = $message->toArray(); @@ -243,7 +219,7 @@ public function testFormatWithUnlimitedLength() $length = 200; foreach ($messageArray as $key => $value) { - if (!in_array($key, array('level', 'timestamp'))) { + if (!in_array($key, ['level', 'timestamp'])) { $length += strlen($value); } } @@ -251,6 +227,25 @@ public function testFormatWithUnlimitedLength() $this->assertGreaterThanOrEqual(131289, $length, 'The message should not be truncated'); } + public function testFormatWithLargeCyrillicData() + { + $formatter = new GelfMessageFormatter(); + $record = $this->getRecord( + Level::Error, + str_repeat('в', 32767), + channel: 'meh', + context: ['exception' => str_repeat('а', 32767)], + datetime: new \DateTimeImmutable("@0"), + extra: ['key' => str_repeat('б', 32767)], + ); + $message = $formatter->format($record); + $messageArray = $message->toArray(); + + $messageString = json_encode($messageArray); + + $this->assertIsString($messageString); + } + private function isLegacy() { return interface_exists('\Gelf\IMessagePublisher'); diff --git a/tests/Monolog/Formatter/GoogleCloudLoggingFormatterTest.php b/tests/Monolog/Formatter/GoogleCloudLoggingFormatterTest.php new file mode 100644 index 000000000..e46846aec --- /dev/null +++ b/tests/Monolog/Formatter/GoogleCloudLoggingFormatterTest.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Formatter; + +use DateTimeInterface; +use Monolog\Test\TestCase; +use function json_decode; + +class GoogleCloudLoggingFormatterTest extends TestCase +{ + /** + * @test + * + * @covers \Monolog\Formatter\JsonFormatter + * @covers \Monolog\Formatter\GoogleCloudLoggingFormatter::normalizeRecord + */ + public function formatProvidesRfc3339Timestamps(): void + { + $formatter = new GoogleCloudLoggingFormatter(); + $record = $this->getRecord(); + + $formatted_decoded = json_decode($formatter->format($record), true); + $this->assertArrayNotHasKey("datetime", $formatted_decoded); + $this->assertArrayHasKey("timestamp", $formatted_decoded); + $this->assertSame($record->datetime->format(DateTimeInterface::RFC3339_EXTENDED), $formatted_decoded["timestamp"]); + } + + /** + * @test + * + * @covers \Monolog\Formatter\JsonFormatter + * @covers \Monolog\Formatter\GoogleCloudLoggingFormatter::normalizeRecord + */ + public function formatIntroducesLogSeverity(): void + { + $formatter = new GoogleCloudLoggingFormatter(); + $record = $this->getRecord(); + + $formatted_decoded = json_decode($formatter->format($record), true); + $this->assertArrayNotHasKey("level", $formatted_decoded); + $this->assertArrayNotHasKey("level_name", $formatted_decoded); + $this->assertArrayHasKey("severity", $formatted_decoded); + $this->assertSame($record->level->getName(), $formatted_decoded["severity"]); + } +} diff --git a/tests/Monolog/Formatter/JsonFormatterTest.php b/tests/Monolog/Formatter/JsonFormatterTest.php index 7ccf5bc84..42936e1f7 100644 --- a/tests/Monolog/Formatter/JsonFormatterTest.php +++ b/tests/Monolog/Formatter/JsonFormatterTest.php @@ -11,7 +11,9 @@ namespace Monolog\Formatter; -use Monolog\Logger; +use Monolog\Level; +use Monolog\LogRecord; +use JsonSerializable; use Monolog\Test\TestCase; class JsonFormatterTest extends TestCase @@ -38,12 +40,42 @@ public function testFormat() { $formatter = new JsonFormatter(); $record = $this->getRecord(); - $record['context'] = $record['extra'] = new \stdClass; - $this->assertEquals(json_encode($record)."\n", $formatter->format($record)); + $this->assertEquals(json_encode($record->toArray(), JSON_FORCE_OBJECT)."\n", $formatter->format($record)); $formatter = new JsonFormatter(JsonFormatter::BATCH_MODE_JSON, false); $record = $this->getRecord(); - $this->assertEquals('{"message":"test","context":{},"level":300,"level_name":"WARNING","channel":"test","datetime":"'.$record['datetime']->format('Y-m-d\TH:i:s.uP').'","extra":{}}', $formatter->format($record)); + $this->assertEquals('{"message":"test","context":{},"level":300,"level_name":"WARNING","channel":"test","datetime":"'.$record->datetime->format('Y-m-d\TH:i:s.uP').'","extra":{}}', $formatter->format($record)); + } + + /** + * @covers Monolog\Formatter\JsonFormatter::format + */ + public function testFormatWithPrettyPrint() + { + $formatter = new JsonFormatter(); + $formatter->setJsonPrettyPrint(true); + $record = $this->getRecord(); + $this->assertEquals(json_encode($record->toArray(), JSON_PRETTY_PRINT | JSON_FORCE_OBJECT)."\n", $formatter->format($record)); + + $formatter = new JsonFormatter(JsonFormatter::BATCH_MODE_JSON, false); + $formatter->setJsonPrettyPrint(true); + $record = $this->getRecord(); + $this->assertEquals( + '{ + "message": "test", + "context": {}, + "level": 300, + "level_name": "WARNING", + "channel": "test", + "datetime": "'.$record->datetime->format('Y-m-d\TH:i:s.uP').'", + "extra": {} +}', + $formatter->format($record) + ); + + $formatter->setJsonPrettyPrint(false); + $record = $this->getRecord(); + $this->assertEquals('{"message":"test","context":{},"level":300,"level_name":"WARNING","channel":"test","datetime":"'.$record->datetime->format('Y-m-d\TH:i:s.uP').'","extra":{}}', $formatter->format($record)); } /** @@ -54,8 +86,8 @@ public function testFormatBatch() { $formatter = new JsonFormatter(); $records = [ - $this->getRecord(Logger::WARNING), - $this->getRecord(Logger::DEBUG), + $this->getRecord(Level::Warning), + $this->getRecord(Level::Debug), ]; $this->assertEquals(json_encode($records), $formatter->formatBatch($records)); } @@ -67,14 +99,11 @@ public function testFormatBatch() public function testFormatBatchNewlines() { $formatter = new JsonFormatter(JsonFormatter::BATCH_MODE_NEWLINES); - $records = $expected = [ - $this->getRecord(Logger::WARNING), - $this->getRecord(Logger::DEBUG), + $records = [ + $this->getRecord(Level::Warning), + $this->getRecord(Level::Debug), ]; - array_walk($expected, function (&$value, $key) { - $value['context'] = $value['extra'] = new \stdClass; - $value = json_encode($value); - }); + $expected = array_map(fn (LogRecord $record) => json_encode($record->toArray(), JSON_FORCE_OBJECT), $records); $this->assertEquals(implode("\n", $expected), $formatter->formatBatch($records)); } @@ -133,7 +162,7 @@ public function testMaxNormalizeItemCountWith0ItemsMax() $message = $this->formatRecordWithExceptionInContext($formatter, $throwable); $this->assertEquals( - '{"...":"Over 0 items (6 total), aborting normalization"}'."\n", + '{"...":"Over 0 items (7 total), aborting normalization"}'."\n", $message ); } @@ -148,51 +177,48 @@ public function testMaxNormalizeItemCountWith2ItemsMax() $message = $this->formatRecordWithExceptionInContext($formatter, $throwable); $this->assertEquals( - '{"level_name":"CRITICAL","channel":"core","...":"Over 2 items (6 total), aborting normalization"}'."\n", + '{"message":"foobar","context":{"exception":{"class":"Error","message":"Foo","code":0,"file":"'.__FILE__.':'.(__LINE__ - 5).'"}},"...":"Over 2 items (7 total), aborting normalization"}'."\n", $message ); } + public function testDefFormatWithResource() + { + $formatter = new JsonFormatter(JsonFormatter::BATCH_MODE_JSON, false); + $record = $this->getRecord( + context: ['field_resource' => opendir(__DIR__)], + ); + $this->assertEquals('{"message":"test","context":{"field_resource":"[resource(stream)]"},"level":300,"level_name":"WARNING","channel":"test","datetime":"'.$record->datetime->format('Y-m-d\TH:i:s.uP').'","extra":{}}', $formatter->format($record)); + } + /** - * @param string $expected - * @param string $actual - * * @internal param string $exception */ - private function assertContextContainsFormattedException($expected, $actual) + private function assertContextContainsFormattedException(string $expected, string $actual) { $this->assertEquals( - '{"level_name":"CRITICAL","channel":"core","context":{"exception":'.$expected.'},"datetime":null,"extra":{},"message":"foobar"}'."\n", + '{"message":"foobar","context":{"exception":'.$expected.'},"level":500,"level_name":"CRITICAL","channel":"core","datetime":"2022-02-22T00:00:00+00:00","extra":{}}'."\n", $actual ); } - /** - * @param JsonFormatter $formatter - * @param \Throwable $exception - * - * @return string - */ - private function formatRecordWithExceptionInContext(JsonFormatter $formatter, \Throwable $exception) + private function formatRecordWithExceptionInContext(JsonFormatter $formatter, \Throwable $exception): string { - $message = $formatter->format([ - 'level_name' => 'CRITICAL', - 'channel' => 'core', - 'context' => ['exception' => $exception], - 'datetime' => null, - 'extra' => [], - 'message' => 'foobar', - ]); + $message = $formatter->format($this->getRecord( + Level::Critical, + 'foobar', + channel: 'core', + context: ['exception' => $exception], + datetime: new \DateTimeImmutable('2022-02-22 00:00:00'), + )); return $message; } /** * @param \Exception|\Throwable $exception - * - * @return string */ - private function formatExceptionFilePathWithLine($exception) + private function formatExceptionFilePathWithLine($exception): string { $options = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; $path = substr(json_encode($exception->getFile(), $options), 1, -1); @@ -202,12 +228,8 @@ private function formatExceptionFilePathWithLine($exception) /** * @param \Exception|\Throwable $exception - * - * @param null|string $previous - * - * @return string */ - private function formatException($exception, $previous = null) + private function formatException($exception, ?string $previous = null): string { $formattedException = '{"class":"' . get_class($exception) . @@ -225,13 +247,11 @@ public function testNormalizeHandleLargeArraysWithExactly1000Items() $formatter = new NormalizerFormatter(); $largeArray = range(1, 1000); - $res = $formatter->format(array( - 'level_name' => 'CRITICAL', - 'channel' => 'test', - 'message' => 'bar', - 'context' => array($largeArray), - 'datetime' => new \DateTime, - 'extra' => array(), + $res = $formatter->format($this->getRecord( + Level::Critical, + 'bar', + channel: 'test', + context: [$largeArray], )); $this->assertCount(1000, $res['context'][0]); @@ -243,16 +263,85 @@ public function testNormalizeHandleLargeArrays() $formatter = new NormalizerFormatter(); $largeArray = range(1, 2000); - $res = $formatter->format(array( - 'level_name' => 'CRITICAL', - 'channel' => 'test', - 'message' => 'bar', - 'context' => array($largeArray), - 'datetime' => new \DateTime, - 'extra' => array(), + $res = $formatter->format($this->getRecord( + Level::Critical, + 'bar', + channel: 'test', + context: [$largeArray], )); $this->assertCount(1001, $res['context'][0]); $this->assertEquals('Over 1000 items (2000 total), aborting normalization', $res['context'][0]['...']); } + + public function testEmptyContextAndExtraFieldsCanBeIgnored() + { + $formatter = new JsonFormatter(JsonFormatter::BATCH_MODE_JSON, true, true); + + $record = $formatter->format($this->getRecord( + Level::Debug, + 'Testing', + channel: 'test', + datetime: new \DateTimeImmutable('2022-02-22 00:00:00'), + )); + + $this->assertSame( + '{"message":"Testing","level":100,"level_name":"DEBUG","channel":"test","datetime":"2022-02-22T00:00:00+00:00"}'."\n", + $record + ); + } + + public function testFormatObjects() + { + $formatter = new JsonFormatter(); + + $record = $formatter->format($this->getRecord( + Level::Debug, + 'Testing', + channel: 'test', + datetime: new \DateTimeImmutable('2022-02-22 00:00:00'), + context: [ + 'public' => new TestJsonNormPublic, + 'private' => new TestJsonNormPrivate, + 'withToStringAndJson' => new TestJsonNormWithToStringAndJson, + 'withToString' => new TestJsonNormWithToString, + ], + )); + + $this->assertSame( + '{"message":"Testing","context":{"public":{"foo":"fooValue"},"private":{},"withToStringAndJson":["json serialized"],"withToString":"stringified"},"level":100,"level_name":"DEBUG","channel":"test","datetime":"2022-02-22T00:00:00+00:00","extra":{}}'."\n", + $record + ); + } +} + +class TestJsonNormPublic +{ + public $foo = 'fooValue'; +} + +class TestJsonNormPrivate +{ + private $foo = 'fooValue'; +} + +class TestJsonNormWithToStringAndJson implements JsonSerializable +{ + public function jsonSerialize() + { + return ['json serialized']; + } + + public function __toString() + { + return 'SHOULD NOT SHOW UP'; + } +} + +class TestJsonNormWithToString +{ + public function __toString() + { + return 'stringified'; + } } diff --git a/tests/Monolog/Formatter/LineFormatterTest.php b/tests/Monolog/Formatter/LineFormatterTest.php index e19cde1e4..02c38a40c 100644 --- a/tests/Monolog/Formatter/LineFormatterTest.php +++ b/tests/Monolog/Formatter/LineFormatterTest.php @@ -11,111 +11,101 @@ namespace Monolog\Formatter; +use Monolog\Test\TestCase; +use Monolog\Level; + /** * @covers Monolog\Formatter\LineFormatter */ -class LineFormatterTest extends \PHPUnit\Framework\TestCase +class LineFormatterTest extends TestCase { public function testDefFormatWithString() { $formatter = new LineFormatter(null, 'Y-m-d'); - $message = $formatter->format([ - 'level_name' => 'WARNING', - 'channel' => 'log', - 'context' => [], - 'message' => 'foo', - 'datetime' => new \DateTimeImmutable, - 'extra' => [], - ]); + $message = $formatter->format($this->getRecord( + Level::Warning, + 'foo', + channel: 'log', + )); $this->assertEquals('['.date('Y-m-d').'] log.WARNING: foo [] []'."\n", $message); } public function testDefFormatWithArrayContext() { $formatter = new LineFormatter(null, 'Y-m-d'); - $message = $formatter->format([ - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'message' => 'foo', - 'datetime' => new \DateTimeImmutable, - 'extra' => [], - 'context' => [ + $message = $formatter->format($this->getRecord( + Level::Error, + 'foo', + channel: 'meh', + context: [ 'foo' => 'bar', 'baz' => 'qux', 'bool' => false, 'null' => null, ], - ]); + )); $this->assertEquals('['.date('Y-m-d').'] meh.ERROR: foo {"foo":"bar","baz":"qux","bool":false,"null":null} []'."\n", $message); } public function testDefFormatExtras() { $formatter = new LineFormatter(null, 'Y-m-d'); - $message = $formatter->format([ - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => [], - 'datetime' => new \DateTimeImmutable, - 'extra' => ['ip' => '127.0.0.1'], - 'message' => 'log', - ]); + $message = $formatter->format($this->getRecord( + Level::Error, + 'log', + channel: 'meh', + extra: ['ip' => '127.0.0.1'], + )); $this->assertEquals('['.date('Y-m-d').'] meh.ERROR: log [] {"ip":"127.0.0.1"}'."\n", $message); } public function testFormatExtras() { $formatter = new LineFormatter("[%datetime%] %channel%.%level_name%: %message% %context% %extra.file% %extra%\n", 'Y-m-d'); - $message = $formatter->format([ - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => [], - 'datetime' => new \DateTimeImmutable, - 'extra' => ['ip' => '127.0.0.1', 'file' => 'test'], - 'message' => 'log', - ]); + $message = $formatter->format($this->getRecord( + Level::Error, + 'log', + channel: 'meh', + extra: ['ip' => '127.0.0.1', 'file' => 'test'], + )); $this->assertEquals('['.date('Y-m-d').'] meh.ERROR: log [] test {"ip":"127.0.0.1"}'."\n", $message); } public function testContextAndExtraOptionallyNotShownIfEmpty() { $formatter = new LineFormatter(null, 'Y-m-d', false, true); - $message = $formatter->format([ - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => [], - 'datetime' => new \DateTimeImmutable, - 'extra' => [], - 'message' => 'log', - ]); + $message = $formatter->format($this->getRecord( + Level::Error, + 'log', + channel: 'meh', + )); $this->assertEquals('['.date('Y-m-d').'] meh.ERROR: log '."\n", $message); } public function testContextAndExtraReplacement() { $formatter = new LineFormatter('%context.foo% => %extra.foo%'); - $message = $formatter->format([ - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => ['foo' => 'bar'], - 'datetime' => new \DateTimeImmutable, - 'extra' => ['foo' => 'xbar'], - 'message' => 'log', - ]); + $message = $formatter->format($this->getRecord( + Level::Error, + 'log', + channel: 'meh', + context: ['foo' => 'bar'], + extra: ['foo' => 'xbar'], + )); + $this->assertEquals('bar => xbar', $message); } public function testDefFormatWithObject() { $formatter = new LineFormatter(null, 'Y-m-d'); - $message = $formatter->format([ - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => [], - 'datetime' => new \DateTimeImmutable, - 'extra' => ['foo' => new TestFoo, 'bar' => new TestBar, 'baz' => [], 'res' => fopen('php://memory', 'rb')], - 'message' => 'foobar', - ]); + $message = $formatter->format($this->getRecord( + Level::Error, + 'foobar', + channel: 'meh', + context: [], + extra: ['foo' => new TestFoo, 'bar' => new TestBar, 'baz' => [], 'res' => fopen('php://memory', 'rb')], + )); $this->assertEquals('['.date('Y-m-d').'] meh.ERROR: foobar [] {"foo":{"Monolog\\\\Formatter\\\\TestFoo":{"foo":"fooValue"}},"bar":{"Monolog\\\\Formatter\\\\TestBar":"bar"},"baz":[],"res":"[resource(stream)]"}'."\n", $message); } @@ -123,76 +113,148 @@ public function testDefFormatWithObject() public function testDefFormatWithException() { $formatter = new LineFormatter(null, 'Y-m-d'); - $message = $formatter->format([ - 'level_name' => 'CRITICAL', - 'channel' => 'core', - 'context' => ['exception' => new \RuntimeException('Foo')], - 'datetime' => new \DateTimeImmutable, - 'extra' => [], - 'message' => 'foobar', - ]); + $message = $formatter->format($this->getRecord( + Level::Critical, + 'foobar', + channel: 'core', + context: ['exception' => new \RuntimeException('Foo')], + )); $path = str_replace('\\/', '/', json_encode(__FILE__)); - $this->assertEquals('['.date('Y-m-d').'] core.CRITICAL: foobar {"exception":"[object] (RuntimeException(code: 0): Foo at '.substr($path, 1, -1).':'.(__LINE__ - 8).')"} []'."\n", $message); + $this->assertEquals('['.date('Y-m-d').'] core.CRITICAL: foobar {"exception":"[object] (RuntimeException(code: 0): Foo at '.substr($path, 1, -1).':'.(__LINE__ - 5).')"} []'."\n", $message); } public function testDefFormatWithExceptionAndStacktrace() { $formatter = new LineFormatter(null, 'Y-m-d'); $formatter->includeStacktraces(); - $message = $formatter->format([ - 'level_name' => 'CRITICAL', - 'channel' => 'core', - 'context' => ['exception' => new \RuntimeException('Foo')], - 'datetime' => new \DateTimeImmutable, - 'extra' => [], - 'message' => 'foobar', - ]); + $message = $formatter->format($this->getRecord( + Level::Critical, + 'foobar', + channel: 'core', + context: ['exception' => new \RuntimeException('Foo')], + )); $path = str_replace('\\/', '/', json_encode(__FILE__)); - $this->assertRegexp('{^\['.date('Y-m-d').'] core\.CRITICAL: foobar \{"exception":"\[object] \(RuntimeException\(code: 0\): Foo at '.preg_quote(substr($path, 1, -1)).':'.(__LINE__ - 8).'\)\n\[stacktrace]\n#0}', $message); + $this->assertMatchesRegularExpression('{^\['.date('Y-m-d').'] core\.CRITICAL: foobar \{"exception":"\[object] \(RuntimeException\(code: 0\): Foo at '.preg_quote(substr($path, 1, -1)).':'.(__LINE__ - 5).'\)\n\[stacktrace]\n#0}', $message); + } + + public function testInlineLineBreaksRespectsEscapedBackslashes() + { + $formatter = new LineFormatter(null, 'Y-m-d'); + $formatter->allowInlineLineBreaks(); + + self::assertSame('{"test":"foo'."\n".'bar\\\\name-with-n"}', $formatter->stringify(["test" => "foo\nbar\\name-with-n"])); + } + + public function testDefFormatWithExceptionAndStacktraceParserFull() + { + $formatter = new LineFormatter(null, 'Y-m-d'); + $formatter->includeStacktraces(true, function ($line) { + return $line; + }); + + $message = $formatter->format($this->getRecord(Level::Critical, context: ['exception' => new \RuntimeException('Foo')])); + + $trace = explode('[stacktrace]', $message, 2)[1]; + + $this->assertStringContainsString('TestCase.php', $trace); + $this->assertStringContainsString('TestResult.php', $trace); + } + + public function testDefFormatWithExceptionAndStacktraceParserCustom() + { + $formatter = new LineFormatter(null, 'Y-m-d'); + $formatter->includeStacktraces(true, function ($line) { + if (strpos($line, 'TestCase.php') === false) { + return $line; + } + }); + + $message = $formatter->format($this->getRecord(Level::Critical, context: ['exception' => new \RuntimeException('Foo')])); + + $trace = explode('[stacktrace]', $message, 2)[1]; + + $this->assertStringNotContainsString('TestCase.php', $trace); + $this->assertStringContainsString('TestResult.php', $trace); + } + + public function testDefFormatWithExceptionAndStacktraceParserEmpty() + { + $formatter = new LineFormatter(null, 'Y-m-d'); + $formatter->includeStacktraces(true, function ($line) { + return null; + }); + + $message = $formatter->format($this->getRecord(Level::Critical, context: ['exception' => new \RuntimeException('Foo')])); + + $trace = explode('[stacktrace]', $message, 2)[1]; + + $this->assertStringNotContainsString('#', $trace); } public function testDefFormatWithPreviousException() { $formatter = new LineFormatter(null, 'Y-m-d'); $previous = new \LogicException('Wut?'); - $message = $formatter->format([ - 'level_name' => 'CRITICAL', - 'channel' => 'core', - 'context' => ['exception' => new \RuntimeException('Foo', 0, $previous)], - 'datetime' => new \DateTimeImmutable, - 'extra' => [], - 'message' => 'foobar', - ]); + $message = $formatter->format($this->getRecord( + Level::Critical, + 'foobar', + channel: 'core', + context: ['exception' => new \RuntimeException('Foo', 0, $previous)], + )); + + $path = str_replace('\\/', '/', json_encode(__FILE__)); + + $this->assertEquals('['.date('Y-m-d').'] core.CRITICAL: foobar {"exception":"[object] (RuntimeException(code: 0): Foo at '.substr($path, 1, -1).':'.(__LINE__ - 5).')\n[previous exception] [object] (LogicException(code: 0): Wut? at '.substr($path, 1, -1).':'.(__LINE__ - 10).')"} []'."\n", $message); + } + + public function testDefFormatWithSoapFaultException() + { + if (!class_exists('SoapFault')) { + $this->markTestSkipped('Requires the soap extension'); + } + + $formatter = new LineFormatter(null, 'Y-m-d'); + $message = $formatter->format($this->getRecord( + Level::Critical, + 'foobar', + channel: 'core', + context: ['exception' => new \SoapFault('foo', 'bar', 'hello', 'world')], + )); $path = str_replace('\\/', '/', json_encode(__FILE__)); - $this->assertEquals('['.date('Y-m-d').'] core.CRITICAL: foobar {"exception":"[object] (RuntimeException(code: 0): Foo at '.substr($path, 1, -1).':'.(__LINE__ - 8).')\n[previous exception] [object] (LogicException(code: 0): Wut? at '.substr($path, 1, -1).':'.(__LINE__ - 12).')"} []'."\n", $message); + $this->assertEquals('['.date('Y-m-d').'] core.CRITICAL: foobar {"exception":"[object] (SoapFault(code: 0 faultcode: foo faultactor: hello detail: world): bar at '.substr($path, 1, -1).':'.(__LINE__ - 5).')"} []'."\n", $message); + + $message = $formatter->format($this->getRecord( + Level::Critical, + 'foobar', + channel: 'core', + context: ['exception' => new \SoapFault('foo', 'bar', 'hello', (object) ['bar' => (object) ['biz' => 'baz'], 'foo' => 'world'])], + )); + + $path = str_replace('\\/', '/', json_encode(__FILE__)); + + $this->assertEquals('['.date('Y-m-d').'] core.CRITICAL: foobar {"exception":"[object] (SoapFault(code: 0 faultcode: foo faultactor: hello detail: {\"bar\":{\"biz\":\"baz\"},\"foo\":\"world\"}): bar at '.substr($path, 1, -1).':'.(__LINE__ - 5).')"} []'."\n", $message); } public function testBatchFormat() { $formatter = new LineFormatter(null, 'Y-m-d'); $message = $formatter->formatBatch([ - [ - 'level_name' => 'CRITICAL', - 'channel' => 'test', - 'message' => 'bar', - 'context' => [], - 'datetime' => new \DateTimeImmutable, - 'extra' => [], - ], - [ - 'level_name' => 'WARNING', - 'channel' => 'log', - 'message' => 'foo', - 'context' => [], - 'datetime' => new \DateTimeImmutable, - 'extra' => [], - ], + $this->getRecord( + Level::Critical, + 'bar', + channel: 'test', + ), + $this->getRecord( + Level::Warning, + 'foo', + channel: 'log', + ), ]); $this->assertEquals('['.date('Y-m-d').'] test.CRITICAL: bar [] []'."\n".'['.date('Y-m-d').'] log.WARNING: foo [] []'."\n", $message); } @@ -200,35 +262,23 @@ public function testBatchFormat() public function testFormatShouldStripInlineLineBreaks() { $formatter = new LineFormatter(null, 'Y-m-d'); - $message = $formatter->format( - [ - 'message' => "foo\nbar", - 'context' => [], - 'extra' => [], - ] - ); + $message = $formatter->format($this->getRecord(message: "foo\nbar")); - $this->assertRegExp('/foo bar/', $message); + $this->assertMatchesRegularExpression('/foo bar/', $message); } public function testFormatShouldNotStripInlineLineBreaksWhenFlagIsSet() { $formatter = new LineFormatter(null, 'Y-m-d', true); - $message = $formatter->format( - [ - 'message' => "foo\nbar", - 'context' => [], - 'extra' => [], - ] - ); + $message = $formatter->format($this->getRecord(message: "foo\nbar")); - $this->assertRegExp('/foo\nbar/', $message); + $this->assertMatchesRegularExpression('/foo\nbar/', $message); } } class TestFoo { - public $foo = 'fooValue'; + public string $foo = 'fooValue'; } class TestBar diff --git a/tests/Monolog/Formatter/LogglyFormatterTest.php b/tests/Monolog/Formatter/LogglyFormatterTest.php index 2eff4ac5d..de0aeb856 100644 --- a/tests/Monolog/Formatter/LogglyFormatterTest.php +++ b/tests/Monolog/Formatter/LogglyFormatterTest.php @@ -36,6 +36,6 @@ public function testFormat() $formatted_decoded = json_decode($formatter->format($record), true); $this->assertArrayNotHasKey("datetime", $formatted_decoded); $this->assertArrayHasKey("timestamp", $formatted_decoded); - $this->assertEquals($record["datetime"]->format('Y-m-d\TH:i:s.uO'), $formatted_decoded["timestamp"]); + $this->assertEquals($record->datetime->format('Y-m-d\TH:i:s.uO'), $formatted_decoded["timestamp"]); } } diff --git a/tests/Monolog/Formatter/LogmaticFormatterTest.php b/tests/Monolog/Formatter/LogmaticFormatterTest.php index d27670fab..00d6536fd 100644 --- a/tests/Monolog/Formatter/LogmaticFormatterTest.php +++ b/tests/Monolog/Formatter/LogmaticFormatterTest.php @@ -25,7 +25,7 @@ public function testFormat() { $formatter = new LogmaticFormatter(); $formatter->setHostname('testHostname'); - $formatter->setAppname('testAppname'); + $formatter->setAppName('testAppname'); $record = $this->getRecord(); $formatted_decoded = json_decode($formatter->format($record), true); $this->assertArrayHasKey('hostname', $formatted_decoded); diff --git a/tests/Monolog/Formatter/LogstashFormatterTest.php b/tests/Monolog/Formatter/LogstashFormatterTest.php index 5d0374bc9..ffb0ee3bc 100644 --- a/tests/Monolog/Formatter/LogstashFormatterTest.php +++ b/tests/Monolog/Formatter/LogstashFormatterTest.php @@ -11,32 +11,23 @@ namespace Monolog\Formatter; -use Monolog\Logger; +use Monolog\Level; +use Monolog\Test\TestCase; -class LogstashFormatterTest extends \PHPUnit\Framework\TestCase +class LogstashFormatterTest extends TestCase { - public function tearDown() - { - \PHPUnit\Framework\Error\Warning::$enabled = true; - - return parent::tearDown(); - } - /** * @covers Monolog\Formatter\LogstashFormatter::format */ public function testDefaultFormatterV1() { $formatter = new LogstashFormatter('test', 'hostname'); - $record = [ - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => [], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => [], - 'message' => 'log', - ]; + $record = $this->getRecord( + Level::Error, + 'log', + channel: 'meh', + datetime: new \DateTimeImmutable("@0"), + ); $message = json_decode($formatter->format($record), true); @@ -44,8 +35,8 @@ public function testDefaultFormatterV1() $this->assertEquals("1", $message['@version']); $this->assertEquals('log', $message['message']); $this->assertEquals('meh', $message['channel']); - $this->assertEquals('ERROR', $message['level']); - $this->assertEquals(Logger::ERROR, $message['monolog_level']); + $this->assertEquals(Level::Error->getName(), $message['level']); + $this->assertEquals(Level::Error->value, $message['monolog_level']); $this->assertEquals('test', $message['type']); $this->assertEquals('hostname', $message['host']); @@ -62,15 +53,14 @@ public function testDefaultFormatterV1() public function testFormatWithFileAndLineV1() { $formatter = new LogstashFormatter('test'); - $record = [ - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => ['from' => 'logger'], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => ['file' => 'test', 'line' => 14], - 'message' => 'log', - ]; + $record = $this->getRecord( + Level::Error, + 'log', + channel: 'meh', + context: ['from' => 'logger'], + datetime: new \DateTimeImmutable("@0"), + extra: ['file' => 'test', 'line' => 14], + ); $message = json_decode($formatter->format($record), true); @@ -84,15 +74,14 @@ public function testFormatWithFileAndLineV1() public function testFormatWithContextV1() { $formatter = new LogstashFormatter('test'); - $record = [ - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => ['from' => 'logger'], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => ['key' => 'pair'], - 'message' => 'log', - ]; + $record = $this->getRecord( + Level::Error, + 'log', + channel: 'meh', + context: ['from' => 'logger'], + datetime: new \DateTimeImmutable("@0"), + extra: ['key' => 'pair'], + ); $message = json_decode($formatter->format($record), true); @@ -115,15 +104,14 @@ public function testFormatWithContextV1() public function testFormatWithExtraV1() { $formatter = new LogstashFormatter('test'); - $record = [ - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => ['from' => 'logger'], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => ['key' => 'pair'], - 'message' => 'log', - ]; + $record = $this->getRecord( + Level::Error, + 'log', + channel: 'meh', + context: ['from' => 'logger'], + datetime: new \DateTimeImmutable("@0"), + extra: ['key' => 'pair'], + ); $message = json_decode($formatter->format($record), true); @@ -143,15 +131,14 @@ public function testFormatWithExtraV1() public function testFormatWithApplicationNameV1() { $formatter = new LogstashFormatter('app', 'test'); - $record = [ - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => ['from' => 'logger'], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => ['key' => 'pair'], - 'message' => 'log', - ]; + $record = $this->getRecord( + Level::Error, + 'log', + channel: 'meh', + context: ['from' => 'logger'], + datetime: new \DateTimeImmutable("@0"), + extra: ['key' => 'pair'], + ); $message = json_decode($formatter->format($record), true); @@ -162,17 +149,15 @@ public function testFormatWithApplicationNameV1() public function testFormatWithLatin9Data() { $formatter = new LogstashFormatter('test', 'hostname'); - $record = [ - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - 'channel' => '¯\_(ツ)_/¯', - 'context' => [], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => [ + $record = $this->getRecord( + Level::Error, + 'log', + channel: '¯\_(ツ)_/¯', + datetime: new \DateTimeImmutable("@0"), + extra: [ 'user_agent' => "\xD6WN; FBCR/OrangeEspa\xF1a; Vers\xE3o/4.0; F\xE4rist", ], - 'message' => 'log', - ]; + ); $message = json_decode($formatter->format($record), true); @@ -182,6 +167,6 @@ public function testFormatWithLatin9Data() $this->assertEquals('ERROR', $message['level']); $this->assertEquals('test', $message['type']); $this->assertEquals('hostname', $message['host']); - $this->assertEquals('ÖWN; FBCR/OrangeEspaña; Versão/4.0; Färist', $message['extra']['user_agent']); + $this->assertEquals('�WN; FBCR/OrangeEspa�a; Vers�o/4.0; F�rist', $message['extra']['user_agent']); } } diff --git a/tests/Monolog/Formatter/MongoDBFormatterTest.php b/tests/Monolog/Formatter/MongoDBFormatterTest.php index 8759c0163..9a712e543 100644 --- a/tests/Monolog/Formatter/MongoDBFormatterTest.php +++ b/tests/Monolog/Formatter/MongoDBFormatterTest.php @@ -11,14 +11,18 @@ namespace Monolog\Formatter; -use Monolog\Logger; +use MongoDB\BSON\ObjectId; +use MongoDB\BSON\Regex; +use MongoDB\BSON\UTCDateTime; +use Monolog\Level; +use Monolog\Test\TestCase; /** * @author Florian Plattner */ -class MongoDBFormatterTest extends \PHPUnit\Framework\TestCase +class MongoDBFormatterTest extends TestCase { - public function setUp() + public function setUp(): void { if (!class_exists('MongoDB\BSON\UTCDateTime')) { $this->markTestSkipped('ext-mongodb not installed'); @@ -49,22 +53,19 @@ public function testConstruct($traceDepth, $traceAsString, $expectedTraceDepth, $reflTrace->setAccessible(true); $this->assertEquals($expectedTraceAsString, $reflTrace->getValue($formatter)); - $reflDepth = new\ReflectionProperty($formatter, 'maxNestingLevel'); + $reflDepth = new \ReflectionProperty($formatter, 'maxNestingLevel'); $reflDepth->setAccessible(true); $this->assertEquals($expectedTraceDepth, $reflDepth->getValue($formatter)); } public function testSimpleFormat() { - $record = [ - 'message' => 'some log message', - 'context' => [], - 'level' => Logger::WARNING, - 'level_name' => Logger::getLevelName(Logger::WARNING), - 'channel' => 'test', - 'datetime' => new \DateTimeImmutable('2016-01-21T21:11:30.123456+00:00'), - 'extra' => [], - ]; + $record = $this->getRecord( + message: 'some log message', + level: Level::Warning, + channel: 'test', + datetime: new \DateTimeImmutable('2016-01-21T21:11:30.123456+00:00'), + ); $formatter = new MongoDBFormatter(); $formattedRecord = $formatter->format($record); @@ -72,8 +73,8 @@ public function testSimpleFormat() $this->assertCount(7, $formattedRecord); $this->assertEquals('some log message', $formattedRecord['message']); $this->assertEquals([], $formattedRecord['context']); - $this->assertEquals(Logger::WARNING, $formattedRecord['level']); - $this->assertEquals(Logger::getLevelName(Logger::WARNING), $formattedRecord['level_name']); + $this->assertEquals(Level::Warning->value, $formattedRecord['level']); + $this->assertEquals(Level::Warning->getName(), $formattedRecord['level_name']); $this->assertEquals('test', $formattedRecord['channel']); $this->assertInstanceOf('MongoDB\BSON\UTCDateTime', $formattedRecord['datetime']); $this->assertEquals('1453410690123', $formattedRecord['datetime']->__toString()); @@ -86,21 +87,19 @@ public function testRecursiveFormat() $someObject->foo = 'something'; $someObject->bar = 'stuff'; - $record = [ - 'message' => 'some log message', - 'context' => [ + $record = $this->getRecord( + message: 'some log message', + context: [ 'stuff' => new \DateTimeImmutable('1969-01-21T21:11:30.213000+00:00'), 'some_object' => $someObject, 'context_string' => 'some string', 'context_int' => 123456, 'except' => new \Exception('exception message', 987), ], - 'level' => Logger::WARNING, - 'level_name' => Logger::getLevelName(Logger::WARNING), - 'channel' => 'test', - 'datetime' => new \DateTimeImmutable('2016-01-21T21:11:30.213000+00:00'), - 'extra' => [], - ]; + level: Level::Warning, + channel: 'test', + datetime: new \DateTimeImmutable('2016-01-21T21:11:30.213000+00:00'), + ); $formatter = new MongoDBFormatter(); $formattedRecord = $formatter->format($record); @@ -122,17 +121,17 @@ public function testRecursiveFormat() $this->assertCount(5, $formattedRecord['context']['except']); $this->assertEquals('exception message', $formattedRecord['context']['except']['message']); $this->assertEquals(987, $formattedRecord['context']['except']['code']); - $this->assertInternalType('string', $formattedRecord['context']['except']['file']); - $this->assertInternalType('integer', $formattedRecord['context']['except']['code']); - $this->assertInternalType('string', $formattedRecord['context']['except']['trace']); + $this->assertIsString($formattedRecord['context']['except']['file']); + $this->assertIsInt($formattedRecord['context']['except']['code']); + $this->assertIsString($formattedRecord['context']['except']['trace']); $this->assertEquals('Exception', $formattedRecord['context']['except']['class']); } public function testFormatDepthArray() { - $record = [ - 'message' => 'some log message', - 'context' => [ + $record = $this->getRecord( + message: 'some log message', + context: [ 'nest2' => [ 'property' => 'anything', 'nest3' => [ @@ -141,12 +140,10 @@ public function testFormatDepthArray() ], ], ], - 'level' => Logger::WARNING, - 'level_name' => Logger::getLevelName(Logger::WARNING), - 'channel' => 'test', - 'datetime' => new \DateTimeImmutable('2016-01-21T21:11:30.123456+00:00'), - 'extra' => [], - ]; + level: Level::Warning, + channel: 'test', + datetime: new \DateTimeImmutable('2016-01-21T21:11:30.123456+00:00'), + ); $formatter = new MongoDBFormatter(2); $formattedResult = $formatter->format($record); @@ -164,9 +161,9 @@ public function testFormatDepthArray() public function testFormatDepthArrayInfiniteNesting() { - $record = [ - 'message' => 'some log message', - 'context' => [ + $record = $this->getRecord( + message: 'some log message', + context: [ 'nest2' => [ 'property' => 'something', 'nest3' => [ @@ -177,12 +174,10 @@ public function testFormatDepthArrayInfiniteNesting() ], ], ], - 'level' => Logger::WARNING, - 'level_name' => Logger::getLevelName(Logger::WARNING), - 'channel' => 'test', - 'datetime' => new \DateTimeImmutable('2016-01-21T21:11:30.123456+00:00'), - 'extra' => [], - ]; + level: Level::Warning, + channel: 'test', + datetime: new \DateTimeImmutable('2016-01-21T21:11:30.123456+00:00'), + ); $formatter = new MongoDBFormatter(0); $formattedResult = $formatter->format($record); @@ -211,17 +206,15 @@ public function testFormatDepthObjects() $someObject->nest3->property = 'nothing'; $someObject->nest3->nest4 = 'invisible'; - $record = [ - 'message' => 'some log message', - 'context' => [ + $record = $this->getRecord( + message: 'some log message', + context: [ 'nest2' => $someObject, ], - 'level' => Logger::WARNING, - 'level_name' => Logger::getLevelName(Logger::WARNING), - 'channel' => 'test', - 'datetime' => new \DateTimeImmutable('2016-01-21T21:11:30.123456+00:00'), - 'extra' => [], - ]; + level: Level::Warning, + channel: 'test', + datetime: new \DateTimeImmutable('2016-01-21T21:11:30.123456+00:00'), + ); $formatter = new MongoDBFormatter(2, true); $formattedResult = $formatter->format($record); @@ -240,17 +233,15 @@ public function testFormatDepthObjects() public function testFormatDepthException() { - $record = [ - 'message' => 'some log message', - 'context' => [ + $record = $this->getRecord( + message: 'some log message', + context: [ 'nest2' => new \Exception('exception message', 987), ], - 'level' => Logger::WARNING, - 'level_name' => Logger::getLevelName(Logger::WARNING), - 'channel' => 'test', - 'datetime' => new \DateTimeImmutable('2016-01-21T21:11:30.123456+00:00'), - 'extra' => [], - ]; + level: Level::Warning, + channel: 'test', + datetime: new \DateTimeImmutable('2016-01-21T21:11:30.123456+00:00'), + ); $formatter = new MongoDBFormatter(2, false); $formattedRecord = $formatter->format($record); @@ -259,4 +250,28 @@ public function testFormatDepthException() $this->assertEquals(987, $formattedRecord['context']['nest2']['code']); $this->assertEquals('[...]', $formattedRecord['context']['nest2']['trace']); } + + public function testBsonTypes() + { + $record = $this->getRecord( + message: 'some log message', + context: [ + 'objectid' => new ObjectId(), + 'nest' => [ + 'timestamp' => new UTCDateTime(), + 'regex' => new Regex('pattern'), + ], + ], + level: Level::Warning, + channel: 'test', + datetime: new \DateTimeImmutable('2016-01-21T21:11:30.123456+00:00'), + ); + + $formatter = new MongoDBFormatter(); + $formattedRecord = $formatter->format($record); + + $this->assertInstanceOf(ObjectId::class, $formattedRecord['context']['objectid']); + $this->assertInstanceOf(UTCDateTime::class, $formattedRecord['context']['nest']['timestamp']); + $this->assertInstanceOf(Regex::class, $formattedRecord['context']['nest']['regex']); + } } diff --git a/tests/Monolog/Formatter/NormalizerFormatterTest.php b/tests/Monolog/Formatter/NormalizerFormatterTest.php index 01bf36d2b..f6668027b 100644 --- a/tests/Monolog/Formatter/NormalizerFormatterTest.php +++ b/tests/Monolog/Formatter/NormalizerFormatterTest.php @@ -11,38 +11,34 @@ namespace Monolog\Formatter; +use Monolog\Test\TestCase; +use Monolog\Level; + /** * @covers Monolog\Formatter\NormalizerFormatter */ -class NormalizerFormatterTest extends \PHPUnit\Framework\TestCase +class NormalizerFormatterTest extends TestCase { - public function tearDown() - { - \PHPUnit\Framework\Error\Warning::$enabled = true; - - return parent::tearDown(); - } - public function testFormat() { $formatter = new NormalizerFormatter('Y-m-d'); - $formatted = $formatter->format([ - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'message' => 'foo', - 'datetime' => new \DateTimeImmutable, - 'extra' => ['foo' => new TestFooNorm, 'bar' => new TestBarNorm, 'baz' => [], 'res' => fopen('php://memory', 'rb')], - 'context' => [ + $formatted = $formatter->format($this->getRecord( + Level::Error, + 'foo', + channel: 'meh', + extra: ['foo' => new TestFooNorm, 'bar' => new TestBarNorm, 'baz' => [], 'res' => fopen('php://memory', 'rb')], + context: [ 'foo' => 'bar', 'baz' => 'qux', 'inf' => INF, '-inf' => -INF, 'nan' => acos(4), ], - ]); + )); $this->assertEquals([ - 'level_name' => 'ERROR', + 'level_name' => Level::Error->getName(), + 'level' => Level::Error->value, 'channel' => 'meh', 'message' => 'foo', 'datetime' => date('Y-m-d'), @@ -67,7 +63,7 @@ public function testFormatExceptions() $formatter = new NormalizerFormatter('Y-m-d'); $e = new \LogicException('bar'); $e2 = new \RuntimeException('foo', 0, $e); - $formatted = $formatter->format([ + $formatted = $formatter->normalizeValue([ 'exception' => $e2, ]); @@ -93,7 +89,7 @@ public function testFormatSoapFaultException() $formatter = new NormalizerFormatter('Y-m-d'); $e = new \SoapFault('foo', 'bar', 'hello', 'world'); - $formatted = $formatter->format([ + $formatted = $formatter->normalizeValue([ 'exception' => $e, ]); @@ -110,6 +106,26 @@ public function testFormatSoapFaultException() 'detail' => 'world', ], ], $formatted); + + $formatter = new NormalizerFormatter('Y-m-d'); + $e = new \SoapFault('foo', 'bar', 'hello', (object) ['bar' => (object) ['biz' => 'baz'], 'foo' => 'world']); + $formatted = $formatter->normalizeValue([ + 'exception' => $e, + ]); + + unset($formatted['exception']['trace']); + + $this->assertEquals([ + 'exception' => [ + 'class' => 'SoapFault', + 'message' => 'bar', + 'code' => 0, + 'file' => $e->getFile().':'.$e->getLine(), + 'faultcode' => 'foo', + 'faultactor' => 'hello', + 'detail' => '{"bar":{"biz":"baz"},"foo":"world"}', + ], + ], $formatted); } public function testFormatToStringExceptionHandle() @@ -117,35 +133,22 @@ public function testFormatToStringExceptionHandle() $formatter = new NormalizerFormatter('Y-m-d'); $this->expectException('RuntimeException'); $this->expectExceptionMessage('Could not convert to string'); - $formatter->format([ + $formatter->format($this->getRecord(context: [ 'myObject' => new TestToStringError(), - ]); + ])); } public function testBatchFormat() { $formatter = new NormalizerFormatter('Y-m-d'); $formatted = $formatter->formatBatch([ - [ - 'level_name' => 'CRITICAL', - 'channel' => 'test', - 'message' => 'bar', - 'context' => [], - 'datetime' => new \DateTimeImmutable, - 'extra' => [], - ], - [ - 'level_name' => 'WARNING', - 'channel' => 'log', - 'message' => 'foo', - 'context' => [], - 'datetime' => new \DateTimeImmutable, - 'extra' => [], - ], + $this->getRecord(Level::Critical, 'bar', channel: 'test'), + $this->getRecord(Level::Warning, 'foo', channel: 'log'), ]); $this->assertEquals([ [ - 'level_name' => 'CRITICAL', + 'level_name' => Level::Critical->getName(), + 'level' => Level::Critical->value, 'channel' => 'test', 'message' => 'bar', 'context' => [], @@ -153,7 +156,8 @@ public function testBatchFormat() 'extra' => [], ], [ - 'level_name' => 'WARNING', + 'level_name' => Level::Warning->getName(), + 'level' => Level::Warning->value, 'channel' => 'log', 'message' => 'foo', 'context' => [], @@ -182,6 +186,8 @@ public function testIgnoresRecursiveObjectReferences() restore_error_handler(); $that->fail("$message should not be raised"); } + + return true; }); $formatter = new NormalizerFormatter(); @@ -191,7 +197,7 @@ public function testIgnoresRecursiveObjectReferences() restore_error_handler(); - $this->assertEquals(@json_encode([$foo, $bar]), $res); + $this->assertEquals('[{"bar":{"foo":null}},{"foo":{"bar":null}}]', $res); } public function testCanNormalizeReferences() @@ -200,12 +206,12 @@ public function testCanNormalizeReferences() $x = ['foo' => 'bar']; $y = ['x' => &$x]; $x['y'] = &$y; - $formatter->format($y); + $formatter->normalizeValue($y); } - public function testIgnoresInvalidTypes() + public function testToJsonIgnoresInvalidTypes() { - // set up the recursion + // set up the invalid data $resource = fopen(__FILE__, 'r'); // set an error handler to assert that the error is not raised anymore @@ -215,6 +221,8 @@ public function testIgnoresInvalidTypes() restore_error_handler(); $that->fail("$message should not be raised"); } + + return true; }); $formatter = new NormalizerFormatter(); @@ -224,7 +232,7 @@ public function testIgnoresInvalidTypes() restore_error_handler(); - $this->assertEquals(@json_encode([$resource]), $res); + $this->assertEquals('[null]', $res); } public function testNormalizeHandleLargeArraysWithExactly1000Items() @@ -232,13 +240,11 @@ public function testNormalizeHandleLargeArraysWithExactly1000Items() $formatter = new NormalizerFormatter(); $largeArray = range(1, 1000); - $res = $formatter->format(array( - 'level_name' => 'CRITICAL', - 'channel' => 'test', - 'message' => 'bar', - 'context' => array($largeArray), - 'datetime' => new \DateTime, - 'extra' => array(), + $res = $formatter->format($this->getRecord( + Level::Critical, + 'bar', + channel: 'test', + context: [$largeArray], )); $this->assertCount(1000, $res['context'][0]); @@ -250,23 +256,18 @@ public function testNormalizeHandleLargeArrays() $formatter = new NormalizerFormatter(); $largeArray = range(1, 2000); - $res = $formatter->format(array( - 'level_name' => 'CRITICAL', - 'channel' => 'test', - 'message' => 'bar', - 'context' => array($largeArray), - 'datetime' => new \DateTime, - 'extra' => array(), + $res = $formatter->format($this->getRecord( + Level::Critical, + 'bar', + channel: 'test', + context: [$largeArray], )); $this->assertCount(1001, $res['context'][0]); $this->assertEquals('Over 1000 items (2000 total), aborting normalization', $res['context'][0]['...']); } - /** - * @expectedException RuntimeException - */ - public function testThrowsOnInvalidEncoding() + public function testIgnoresInvalidEncoding() { $formatter = new NormalizerFormatter(); $reflMethod = new \ReflectionMethod($formatter, 'toJson'); @@ -275,7 +276,8 @@ public function testThrowsOnInvalidEncoding() // send an invalid unicode sequence as a object that can't be cleaned $record = new \stdClass; $record->message = "\xB1\x31"; - $reflMethod->invoke($formatter, $record); + + $this->assertsame('{"message":"�1"}', $reflMethod->invoke($formatter, $record)); } public function testConvertsInvalidEncodingAsLatin9() @@ -286,7 +288,7 @@ public function testConvertsInvalidEncodingAsLatin9() $res = $reflMethod->invoke($formatter, ['message' => "\xA4\xA6\xA8\xB4\xB8\xBC\xBD\xBE"]); - $this->assertSame('{"message":"€ŠšŽžŒœŸ"}', $res); + $this->assertSame('{"message":"��������"}', $res); } public function testMaxNormalizeDepth() @@ -311,12 +313,12 @@ public function testMaxNormalizeItemCountWith0ItemsMax() $message = $this->formatRecordWithExceptionInContext($formatter, $throwable); $this->assertEquals( - ["..." => "Over 0 items (6 total), aborting normalization"], + ["..." => "Over 0 items (7 total), aborting normalization"], $message ); } - public function testMaxNormalizeItemCountWith3ItemsMax() + public function testMaxNormalizeItemCountWith2ItemsMax() { $formatter = new NormalizerFormatter(); $formatter->setMaxNormalizeDepth(9); @@ -325,72 +327,22 @@ public function testMaxNormalizeItemCountWith3ItemsMax() $message = $this->formatRecordWithExceptionInContext($formatter, $throwable); + unset($message['context']['exception']['trace']); + unset($message['context']['exception']['file']); $this->assertEquals( - ["level_name" => "CRITICAL", "channel" => "core", "..." => "Over 2 items (6 total), aborting normalization"], + [ + "message" => "foobar", + "context" => ['exception' => [ + 'class' => 'Error', + 'message' => 'Foo', + 'code' => 0, + ]], + "..." => "Over 2 items (7 total), aborting normalization", + ], $message ); } - /** - * @param mixed $in Input - * @param mixed $expect Expected output - * @covers Monolog\Formatter\NormalizerFormatter::detectAndCleanUtf8 - * @dataProvider providesDetectAndCleanUtf8 - */ - public function testDetectAndCleanUtf8($in, $expect) - { - $formatter = new NormalizerFormatter(); - $formatter->detectAndCleanUtf8($in); - $this->assertSame($expect, $in); - } - - public function providesDetectAndCleanUtf8() - { - $obj = new \stdClass; - - return [ - 'null' => [null, null], - 'int' => [123, 123], - 'float' => [123.45, 123.45], - 'bool false' => [false, false], - 'bool true' => [true, true], - 'ascii string' => ['abcdef', 'abcdef'], - 'latin9 string' => ["\xB1\x31\xA4\xA6\xA8\xB4\xB8\xBC\xBD\xBE\xFF", '±1€ŠšŽžŒœŸÿ'], - 'unicode string' => ['¤¦¨´¸¼½¾€ŠšŽžŒœŸ', '¤¦¨´¸¼½¾€ŠšŽžŒœŸ'], - 'empty array' => [[], []], - 'array' => [['abcdef'], ['abcdef']], - 'object' => [$obj, $obj], - ]; - } - - /** - * @param int $code - * @param string $msg - * @dataProvider providesHandleJsonErrorFailure - */ - public function testHandleJsonErrorFailure($code, $msg) - { - $formatter = new NormalizerFormatter(); - $reflMethod = new \ReflectionMethod($formatter, 'handleJsonError'); - $reflMethod->setAccessible(true); - - $this->expectException('RuntimeException'); - $this->expectExceptionMessage($msg); - $reflMethod->invoke($formatter, $code, 'faked'); - } - - public function providesHandleJsonErrorFailure() - { - return [ - 'depth' => [JSON_ERROR_DEPTH, 'Maximum stack depth exceeded'], - 'state' => [JSON_ERROR_STATE_MISMATCH, 'Underflow or the modes mismatch'], - 'ctrl' => [JSON_ERROR_CTRL_CHAR, 'Unexpected control character found'], - 'default' => [-1, 'Unknown error'], - ]; - } - - // This happens i.e. in React promises or Guzzle streams where stream wrappers are registered - // and no file or line are included in the trace because it's treated as internal function public function testExceptionTraceWithArgs() { try { @@ -409,31 +361,25 @@ public function testExceptionTraceWithArgs() } $formatter = new NormalizerFormatter(); - $record = ['context' => ['exception' => $e]]; + $record = $this->getRecord(context: ['exception' => $e]); $result = $formatter->format($record); + // See https://github.com/php/php-src/issues/8810 fixed in PHP 8.2 + $offset = PHP_VERSION_ID >= 80200 ? 13 : 11; $this->assertSame( - '{"function":"Monolog\\\\Formatter\\\\{closure}","class":"Monolog\\\\Formatter\\\\NormalizerFormatterTest","type":"->","args":["[object] (Monolog\\\\Formatter\\\\TestFooNorm)","[resource(stream)]"]}', + __FILE__.':'.(__LINE__ - $offset), $result['context']['exception']['trace'][0] ); } - /** - * @param NormalizerFormatter $formatter - * @param \Throwable $exception - * - * @return string - */ - private function formatRecordWithExceptionInContext(NormalizerFormatter $formatter, \Throwable $exception) + private function formatRecordWithExceptionInContext(NormalizerFormatter $formatter, \Throwable $exception): array { - $message = $formatter->format([ - 'level_name' => 'CRITICAL', - 'channel' => 'core', - 'context' => ['exception' => $exception], - 'datetime' => null, - 'extra' => [], - 'message' => 'foobar', - ]); + $message = $formatter->format($this->getRecord( + Level::Critical, + 'foobar', + channel: 'core', + context: ['exception' => $exception], + )); return $message; } @@ -442,16 +388,16 @@ public function testExceptionTraceDoesNotLeakCallUserFuncArgs() { try { $arg = new TestInfoLeak; - call_user_func(array($this, 'throwHelper'), $arg, $dt = new \DateTime()); + call_user_func([$this, 'throwHelper'], $arg, $dt = new \DateTime()); } catch (\Exception $e) { } $formatter = new NormalizerFormatter(); - $record = array('context' => array('exception' => $e)); + $record = $this->getRecord(context: ['exception' => $e]); $result = $formatter->format($record); $this->assertSame( - '{"function":"throwHelper","class":"Monolog\\\\Formatter\\\\NormalizerFormatterTest","type":"->","args":["[object] (Monolog\\\\Formatter\\\\TestInfoLeak)","'.$dt->format('Y-m-d\TH:i:sP').'"]}', + __FILE__ .':'.(__LINE__-9), $result['context']['exception']['trace'][0] ); } diff --git a/tests/Monolog/Formatter/ScalarFormatterTest.php b/tests/Monolog/Formatter/ScalarFormatterTest.php index 9af4937cc..5f36fca7a 100644 --- a/tests/Monolog/Formatter/ScalarFormatterTest.php +++ b/tests/Monolog/Formatter/ScalarFormatterTest.php @@ -12,16 +12,24 @@ namespace Monolog\Formatter; use Monolog\DateTimeImmutable; +use Monolog\Test\TestCase; -class ScalarFormatterTest extends \PHPUnit\Framework\TestCase +class ScalarFormatterTest extends TestCase { - private $formatter; + private ScalarFormatter $formatter; - public function setUp() + public function setUp(): void { $this->formatter = new ScalarFormatter(); } + public function tearDown(): void + { + parent::tearDown(); + + unset($this->formatter); + } + public function buildTrace(\Exception $e) { $data = []; @@ -29,8 +37,6 @@ public function buildTrace(\Exception $e) foreach ($trace as $frame) { if (isset($frame['file'])) { $data[] = $frame['file'].':'.$frame['line']; - } else { - $data[] = json_encode($frame); } } @@ -45,7 +51,7 @@ public function encodeJson($data) public function testFormat() { $exception = new \Exception('foo'); - $formatted = $this->formatter->format([ + $formatted = $this->formatter->format($this->getRecord(context: [ 'foo' => 'string', 'bar' => 1, 'baz' => false, @@ -53,56 +59,50 @@ public function testFormat() 'bat' => ['foo' => 'bar'], 'bap' => $dt = new DateTimeImmutable(true), 'ban' => $exception, - ]); + ])); - $this->assertSame([ + $this->assertSame($this->encodeJson([ 'foo' => 'string', 'bar' => 1, 'baz' => false, - 'bam' => $this->encodeJson([1, 2, 3]), - 'bat' => $this->encodeJson(['foo' => 'bar']), + 'bam' => [1, 2, 3], + 'bat' => ['foo' => 'bar'], 'bap' => (string) $dt, - 'ban' => $this->encodeJson([ + 'ban' => [ 'class' => get_class($exception), 'message' => $exception->getMessage(), 'code' => $exception->getCode(), 'file' => $exception->getFile() . ':' . $exception->getLine(), 'trace' => $this->buildTrace($exception), - ]), - ], $formatted); + ], + ]), $formatted['context']); } public function testFormatWithErrorContext() { $context = ['file' => 'foo', 'line' => 1]; - $formatted = $this->formatter->format([ - 'context' => $context, - ]); + $formatted = $this->formatter->format($this->getRecord( + context: $context, + )); - $this->assertSame([ - 'context' => $this->encodeJson($context), - ], $formatted); + $this->assertSame($this->encodeJson($context), $formatted['context']); } public function testFormatWithExceptionContext() { $exception = new \Exception('foo'); - $formatted = $this->formatter->format([ - 'context' => [ - 'exception' => $exception, + $formatted = $this->formatter->format($this->getRecord(context: [ + 'exception' => $exception, + ])); + + $this->assertSame($this->encodeJson([ + 'exception' => [ + 'class' => get_class($exception), + 'message' => $exception->getMessage(), + 'code' => $exception->getCode(), + 'file' => $exception->getFile() . ':' . $exception->getLine(), + 'trace' => $this->buildTrace($exception), ], - ]); - - $this->assertSame([ - 'context' => $this->encodeJson([ - 'exception' => [ - 'class' => get_class($exception), - 'message' => $exception->getMessage(), - 'code' => $exception->getCode(), - 'file' => $exception->getFile() . ':' . $exception->getLine(), - 'trace' => $this->buildTrace($exception), - ], - ]), - ], $formatted); + ]), $formatted['context']); } } diff --git a/tests/Monolog/Formatter/SyslogFormatterTest.php b/tests/Monolog/Formatter/SyslogFormatterTest.php new file mode 100644 index 000000000..fe8933ea2 --- /dev/null +++ b/tests/Monolog/Formatter/SyslogFormatterTest.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Formatter; + +use DateTimeImmutable; +use Monolog\Level; +use Monolog\LogRecord; +use PHPUnit\Framework\TestCase; + +class SyslogFormatterTest extends TestCase +{ + /** + * @dataProvider formatDataProvider + * + * @param string $expected + * @param DateTimeImmutable $dateTime + * @param string $channel + * @param Level $level + * @param string $message + * @param string|null $appName + * @param mixed[] $context + * @param mixed[] $extra + * @return void + */ + public function testFormat( + string $expected, + DateTimeImmutable $dateTime, + string $channel, + Level $level, + string $message, + string $appName = null, + array $context = [], + array $extra = [] + ): void { + if ($appName !== null) { + $formatter = new SyslogFormatter($appName); + } else { + $formatter = new SyslogFormatter(); + } + + $record = new LogRecord( + datetime: $dateTime, + channel: $channel, + level: $level, + message: $message, + context: $context, + extra: $extra + ); + + $message = $formatter->format($record); + + $this->assertEquals($expected, $message); + } + + /** + * @return mixed[] + */ + public function formatDataProvider(): array + { + return [ + 'error' => [ + 'expected' => "<11>1 1970-01-01T00:00:00.000000+00:00 " . gethostname() . " - " . getmypid() ." meh - ERROR: log \n", + 'dateTime' => new DateTimeImmutable("@0"), + 'channel' => 'meh', + 'level' => Level::Error, + 'message' => 'log', + ], + 'info' => [ + 'expected' => "<11>1 1970-01-01T00:00:00.000000+00:00 " . gethostname() . " - " . getmypid() ." meh - ERROR: log \n", + 'dateTime' => new DateTimeImmutable("@0"), + 'channel' => 'meh', + 'level' => Level::Error, + 'message' => 'log', + ], + 'with app name' => [ + 'expected' => "<11>1 1970-01-01T00:00:00.000000+00:00 " . gethostname() . " my-app " . getmypid() ." meh - ERROR: log \n", + 'dateTime' => new DateTimeImmutable("@0"), + 'channel' => 'meh', + 'level' => Level::Error, + 'message' => 'log', + 'appName' => 'my-app', + ], + 'with context' => [ + 'expected' => "<11>1 1970-01-01T00:00:00.000000+00:00 " . gethostname() . " - " . getmypid() ." meh - ERROR: log {\"additional-context\":\"test\"} \n", + 'dateTime' => new DateTimeImmutable("@0"), + 'channel' => 'meh', + 'level' => Level::Error, + 'message' => 'log', + 'appName' => null, + 'context' => ['additional-context' => 'test'], + ], + 'with extra' => [ + 'expected' => "<11>1 1970-01-01T00:00:00.000000+00:00 " . gethostname() . " - " . getmypid() ." meh - ERROR: log {\"userId\":1}\n", + 'dateTime' => new DateTimeImmutable("@0"), + 'channel' => 'meh', + 'level' => Level::Error, + 'message' => 'log', + 'appName' => null, + 'context' => [], + 'extra' => ['userId' => 1], + ], + ]; + } +} diff --git a/tests/Monolog/Formatter/WildfireFormatterTest.php b/tests/Monolog/Formatter/WildfireFormatterTest.php index 137494752..06a0fa70f 100644 --- a/tests/Monolog/Formatter/WildfireFormatterTest.php +++ b/tests/Monolog/Formatter/WildfireFormatterTest.php @@ -11,9 +11,10 @@ namespace Monolog\Formatter; -use Monolog\Logger; +use Monolog\Level; +use Monolog\Test\TestCase; -class WildfireFormatterTest extends \PHPUnit\Framework\TestCase +class WildfireFormatterTest extends TestCase { /** * @covers Monolog\Formatter\WildfireFormatter::format @@ -21,15 +22,13 @@ class WildfireFormatterTest extends \PHPUnit\Framework\TestCase public function testDefaultFormat() { $wildfire = new WildfireFormatter(); - $record = [ - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => ['from' => 'logger'], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => ['ip' => '127.0.0.1'], - 'message' => 'log', - ]; + $record = $this->getRecord( + Level::Error, + 'log', + channel: 'meh', + context: ['from' => 'logger'], + extra: ['ip' => '127.0.0.1'], + ); $message = $wildfire->format($record); @@ -46,15 +45,13 @@ public function testDefaultFormat() public function testFormatWithFileAndLine() { $wildfire = new WildfireFormatter(); - $record = [ - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => ['from' => 'logger'], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => ['ip' => '127.0.0.1', 'file' => 'test', 'line' => 14], - 'message' => 'log', - ]; + $record = $this->getRecord( + Level::Error, + 'log', + channel: 'meh', + context: ['from' => 'logger'], + extra: ['ip' => '127.0.0.1', 'file' => 'test', 'line' => 14], + ); $message = $wildfire->format($record); @@ -71,15 +68,11 @@ public function testFormatWithFileAndLine() public function testFormatWithoutContext() { $wildfire = new WildfireFormatter(); - $record = [ - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => [], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => [], - 'message' => 'log', - ]; + $record = $this->getRecord( + Level::Error, + 'log', + channel: 'meh', + ); $message = $wildfire->format($record); @@ -91,20 +84,17 @@ public function testFormatWithoutContext() /** * @covers Monolog\Formatter\WildfireFormatter::formatBatch - * @expectedException BadMethodCallException */ public function testBatchFormatThrowException() { + $this->expectException(\BadMethodCallException::class); + $wildfire = new WildfireFormatter(); - $record = [ - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => [], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => [], - 'message' => 'log', - ]; + $record = $this->getRecord( + Level::Error, + 'log', + channel: 'meh', + ); $wildfire->formatBatch([$record]); } @@ -115,22 +105,19 @@ public function testBatchFormatThrowException() public function testTableFormat() { $wildfire = new WildfireFormatter(); - $record = [ - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - 'channel' => 'table-channel', - 'context' => [ - WildfireFormatter::TABLE => [ + $record = $this->getRecord( + Level::Error, + 'table-message', + channel: 'table-channel', + context: [ + 'table' => [ ['col1', 'col2', 'col3'], ['val1', 'val2', 'val3'], ['foo1', 'foo2', 'foo3'], ['bar1', 'bar2', 'bar3'], ], ], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => [], - 'message' => 'table-message', - ]; + ); $message = $wildfire->format($record); diff --git a/tests/Monolog/Handler/AbstractHandlerTest.php b/tests/Monolog/Handler/AbstractHandlerTest.php index b7451a73e..58de1dd9f 100644 --- a/tests/Monolog/Handler/AbstractHandlerTest.php +++ b/tests/Monolog/Handler/AbstractHandlerTest.php @@ -11,8 +11,8 @@ namespace Monolog\Handler; +use Monolog\Level; use Monolog\Test\TestCase; -use Monolog\Logger; class AbstractHandlerTest extends TestCase { @@ -25,13 +25,13 @@ class AbstractHandlerTest extends TestCase */ public function testConstructAndGetSet() { - $handler = $this->getMockForAbstractClass('Monolog\Handler\AbstractHandler', [Logger::WARNING, false]); - $this->assertEquals(Logger::WARNING, $handler->getLevel()); + $handler = $this->getMockForAbstractClass('Monolog\Handler\AbstractHandler', [Level::Warning, false]); + $this->assertEquals(Level::Warning, $handler->getLevel()); $this->assertEquals(false, $handler->getBubble()); - $handler->setLevel(Logger::ERROR); + $handler->setLevel(Level::Error); $handler->setBubble(true); - $this->assertEquals(Logger::ERROR, $handler->getLevel()); + $this->assertEquals(Level::Error, $handler->getLevel()); $this->assertEquals(true, $handler->getBubble()); } @@ -51,9 +51,9 @@ public function testHandleBatch() */ public function testIsHandling() { - $handler = $this->getMockForAbstractClass('Monolog\Handler\AbstractHandler', [Logger::WARNING, false]); + $handler = $this->getMockForAbstractClass('Monolog\Handler\AbstractHandler', [Level::Warning, false]); $this->assertTrue($handler->isHandling($this->getRecord())); - $this->assertFalse($handler->isHandling($this->getRecord(Logger::DEBUG))); + $this->assertFalse($handler->isHandling($this->getRecord(Level::Debug))); } /** @@ -62,8 +62,8 @@ public function testIsHandling() public function testHandlesPsrStyleLevels() { $handler = $this->getMockForAbstractClass('Monolog\Handler\AbstractHandler', ['warning', false]); - $this->assertFalse($handler->isHandling($this->getRecord(Logger::DEBUG))); + $this->assertFalse($handler->isHandling($this->getRecord(Level::Debug))); $handler->setLevel('debug'); - $this->assertTrue($handler->isHandling($this->getRecord(Logger::DEBUG))); + $this->assertTrue($handler->isHandling($this->getRecord(Level::Debug))); } } diff --git a/tests/Monolog/Handler/AbstractProcessingHandlerTest.php b/tests/Monolog/Handler/AbstractProcessingHandlerTest.php index 58d092023..6fc3c38b8 100644 --- a/tests/Monolog/Handler/AbstractProcessingHandlerTest.php +++ b/tests/Monolog/Handler/AbstractProcessingHandlerTest.php @@ -12,7 +12,7 @@ namespace Monolog\Handler; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; use Monolog\Processor\WebProcessor; use Monolog\Formatter\LineFormatter; @@ -24,7 +24,7 @@ class AbstractProcessingHandlerTest extends TestCase */ public function testConstructAndGetSet() { - $handler = $this->getMockForAbstractClass('Monolog\Handler\AbstractProcessingHandler', [Logger::WARNING, false]); + $handler = $this->getMockForAbstractClass('Monolog\Handler\AbstractProcessingHandler', [Level::Warning, false]); $handler->setFormatter($formatter = new LineFormatter); $this->assertSame($formatter, $handler->getFormatter()); } @@ -34,8 +34,8 @@ public function testConstructAndGetSet() */ public function testHandleLowerLevelMessage() { - $handler = $this->getMockForAbstractClass('Monolog\Handler\AbstractProcessingHandler', [Logger::WARNING, true]); - $this->assertFalse($handler->handle($this->getRecord(Logger::DEBUG))); + $handler = $this->getMockForAbstractClass('Monolog\Handler\AbstractProcessingHandler', [Level::Warning, true]); + $this->assertFalse($handler->handle($this->getRecord(Level::Debug))); } /** @@ -43,7 +43,7 @@ public function testHandleLowerLevelMessage() */ public function testHandleBubbling() { - $handler = $this->getMockForAbstractClass('Monolog\Handler\AbstractProcessingHandler', [Logger::DEBUG, true]); + $handler = $this->getMockForAbstractClass('Monolog\Handler\AbstractProcessingHandler', [Level::Debug, true]); $this->assertFalse($handler->handle($this->getRecord())); } @@ -52,7 +52,7 @@ public function testHandleBubbling() */ public function testHandleNotBubbling() { - $handler = $this->getMockForAbstractClass('Monolog\Handler\AbstractProcessingHandler', [Logger::DEBUG, false]); + $handler = $this->getMockForAbstractClass('Monolog\Handler\AbstractProcessingHandler', [Level::Debug, false]); $this->assertTrue($handler->handle($this->getRecord())); } @@ -61,9 +61,9 @@ public function testHandleNotBubbling() */ public function testHandleIsFalseWhenNotHandled() { - $handler = $this->getMockForAbstractClass('Monolog\Handler\AbstractProcessingHandler', [Logger::WARNING, false]); + $handler = $this->getMockForAbstractClass('Monolog\Handler\AbstractProcessingHandler', [Level::Warning, false]); $this->assertTrue($handler->handle($this->getRecord())); - $this->assertFalse($handler->handle($this->getRecord(Logger::DEBUG))); + $this->assertFalse($handler->handle($this->getRecord(Level::Debug))); } /** @@ -93,7 +93,6 @@ public function testProcessRecord() /** * @covers Monolog\Handler\ProcessableHandlerTrait::pushProcessor * @covers Monolog\Handler\ProcessableHandlerTrait::popProcessor - * @expectedException LogicException */ public function testPushPopProcessor() { @@ -106,17 +105,21 @@ public function testPushPopProcessor() $this->assertEquals($processor2, $logger->popProcessor()); $this->assertEquals($processor1, $logger->popProcessor()); + + $this->expectException(\LogicException::class); + $logger->popProcessor(); } /** * @covers Monolog\Handler\ProcessableHandlerTrait::pushProcessor - * @expectedException TypeError */ public function testPushProcessorWithNonCallable() { $handler = $this->getMockForAbstractClass('Monolog\Handler\AbstractProcessingHandler'); + $this->expectException(\TypeError::class); + $handler->pushProcessor(new \stdClass()); } diff --git a/tests/Monolog/Handler/AmqpHandlerTest.php b/tests/Monolog/Handler/AmqpHandlerTest.php index 680de2905..54cb90ef3 100644 --- a/tests/Monolog/Handler/AmqpHandlerTest.php +++ b/tests/Monolog/Handler/AmqpHandlerTest.php @@ -12,9 +12,8 @@ namespace Monolog\Handler; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; use PhpAmqpLib\Message\AMQPMessage; -use PhpAmqpLib\Connection\AMQPConnection; /** * @covers Monolog\Handler\RotatingFileHandler @@ -34,7 +33,7 @@ public function testHandleAmqpExt() $messages = []; $exchange = $this->getMockBuilder('AMQPExchange') - ->setMethods(['publish', 'setName']) + ->onlyMethods(['publish', 'setName']) ->disableOriginalConstructor() ->getMock(); @@ -47,7 +46,7 @@ public function testHandleAmqpExt() $handler = new AmqpHandler($exchange); - $record = $this->getRecord(Logger::WARNING, 'test', ['data' => new \stdClass, 'foo' => 34]); + $record = $this->getRecord(Level::Warning, 'test', ['data' => new \stdClass, 'foo' => 34]); $expected = [ [ @@ -79,14 +78,19 @@ public function testHandleAmqpExt() public function testHandlePhpAmqpLib() { - if (!class_exists('PhpAmqpLib\Connection\AMQPConnection')) { + if (!class_exists('PhpAmqpLib\Channel\AMQPChannel')) { $this->markTestSkipped("php-amqplib not installed"); } $messages = []; + $methodsToMock = ['basic_publish']; + if (method_exists('PhpAmqpLib\Channel\AMQPChannel', '__destruct')) { + $methodsToMock[] = '__destruct'; + } + $exchange = $this->getMockBuilder('PhpAmqpLib\Channel\AMQPChannel') - ->setMethods(['basic_publish', '__destruct']) + ->onlyMethods($methodsToMock) ->disableOriginalConstructor() ->getMock(); @@ -99,7 +103,7 @@ public function testHandlePhpAmqpLib() $handler = new AmqpHandler($exchange, 'log'); - $record = $this->getRecord(Logger::WARNING, 'test', ['data' => new \stdClass, 'foo' => 34]); + $record = $this->getRecord(Level::Warning, 'test', ['data' => new \stdClass, 'foo' => 34]); $expected = [ [ diff --git a/tests/Monolog/Handler/BrowserConsoleHandlerTest.php b/tests/Monolog/Handler/BrowserConsoleHandlerTest.php index 048ee1c9a..a2e9451d7 100644 --- a/tests/Monolog/Handler/BrowserConsoleHandlerTest.php +++ b/tests/Monolog/Handler/BrowserConsoleHandlerTest.php @@ -12,16 +12,16 @@ namespace Monolog\Handler; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; /** * @covers Monolog\Handler\BrowserConsoleHandlerTest */ class BrowserConsoleHandlerTest extends TestCase { - protected function setUp() + protected function setUp(): void { - BrowserConsoleHandler::reset(); + BrowserConsoleHandler::resetStatic(); } protected function generateScript() @@ -37,11 +37,27 @@ public function testStyling() $handler = new BrowserConsoleHandler(); $handler->setFormatter($this->getIdentityFormatter()); - $handler->handle($this->getRecord(Logger::DEBUG, 'foo[[bar]]{color: red}')); + $handler->handle($this->getRecord(Level::Debug, 'foo[[bar]]{color: red}')); $expected = <<assertEquals($expected, $this->generateScript()); + } + + public function testStylingMultiple() + { + $handler = new BrowserConsoleHandler(); + $handler->setFormatter($this->getIdentityFormatter()); + + $handler->handle($this->getRecord(Level::Debug, 'foo[[bar]]{color: red}[[baz]]{color: blue}')); + + $expected = <<setFormatter($this->getIdentityFormatter()); - $handler->handle($this->getRecord(Logger::DEBUG, "[foo] [[\"bar\n[baz]\"]]{color: red}")); + $handler->handle($this->getRecord(Level::Debug, "[foo] [[\"bar\n[baz]\"]]{color: red}")); $expected = <<setFormatter($this->getIdentityFormatter()); - $handler->handle($this->getRecord(Logger::DEBUG, '[[foo]]{macro: autolabel}')); - $handler->handle($this->getRecord(Logger::DEBUG, '[[bar]]{macro: autolabel}')); - $handler->handle($this->getRecord(Logger::DEBUG, '[[foo]]{macro: autolabel}')); + $handler->handle($this->getRecord(Level::Debug, '[[foo]]{macro: autolabel}')); + $handler->handle($this->getRecord(Level::Debug, '[[bar]]{macro: autolabel}')); + $handler->handle($this->getRecord(Level::Debug, '[[foo]]{macro: autolabel}')); $expected = <<setFormatter($this->getIdentityFormatter()); - $handler->handle($this->getRecord(Logger::DEBUG, 'test', ['foo' => 'bar'])); + $handler->handle($this->getRecord(Level::Debug, 'test', ['foo' => 'bar', 0 => 'oop'])); $expected = <<setFormatter($this->getIdentityFormatter()); - $handler1->handle($this->getRecord(Logger::DEBUG, 'test1')); - $handler2->handle($this->getRecord(Logger::DEBUG, 'test2')); - $handler1->handle($this->getRecord(Logger::DEBUG, 'test3')); - $handler2->handle($this->getRecord(Logger::DEBUG, 'test4')); + $handler1->handle($this->getRecord(Level::Debug, 'test1')); + $handler2->handle($this->getRecord(Level::Debug, 'test2')); + $handler1->handle($this->getRecord(Level::Debug, 'test3')); + $handler2->handle($this->getRecord(Level::Debug, 'test4')); $expected = <<handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::INFO)); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Info)); $this->assertFalse($test->hasDebugRecords()); $this->assertFalse($test->hasInfoRecords()); $handler->close(); $this->assertTrue($test->hasInfoRecords()); - $this->assertTrue(count($test->getRecords()) === 2); + $this->assertCount(2, $test->getRecords()); } /** @@ -44,8 +44,8 @@ public function testPropagatesRecordsAtEndOfRequest() { $test = new TestHandler(); $handler = new BufferHandler($test); - $handler->handle($this->getRecord(Logger::WARNING)); - $handler->handle($this->getRecord(Logger::DEBUG)); + $handler->handle($this->getRecord(Level::Warning)); + $handler->handle($this->getRecord(Level::Debug)); $this->shutdownCheckHandler = $test; register_shutdown_function([$this, 'checkPropagation']); } @@ -65,10 +65,10 @@ public function testHandleBufferLimit() { $test = new TestHandler(); $handler = new BufferHandler($test, 2); - $handler->handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::INFO)); - $handler->handle($this->getRecord(Logger::WARNING)); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Info)); + $handler->handle($this->getRecord(Level::Warning)); $handler->close(); $this->assertTrue($test->hasWarningRecords()); $this->assertTrue($test->hasInfoRecords()); @@ -81,22 +81,22 @@ public function testHandleBufferLimit() public function testHandleBufferLimitWithFlushOnOverflow() { $test = new TestHandler(); - $handler = new BufferHandler($test, 3, Logger::DEBUG, true, true); + $handler = new BufferHandler($test, 3, Level::Debug, true, true); // send two records - $handler->handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::DEBUG)); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Debug)); $this->assertFalse($test->hasDebugRecords()); $this->assertCount(0, $test->getRecords()); // overflow - $handler->handle($this->getRecord(Logger::INFO)); + $handler->handle($this->getRecord(Level::Info)); $this->assertTrue($test->hasDebugRecords()); $this->assertCount(3, $test->getRecords()); // should buffer again - $handler->handle($this->getRecord(Logger::WARNING)); + $handler->handle($this->getRecord(Level::Warning)); $this->assertCount(3, $test->getRecords()); $handler->close(); @@ -111,11 +111,11 @@ public function testHandleBufferLimitWithFlushOnOverflow() public function testHandleLevel() { $test = new TestHandler(); - $handler = new BufferHandler($test, 0, Logger::INFO); - $handler->handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::INFO)); - $handler->handle($this->getRecord(Logger::WARNING)); - $handler->handle($this->getRecord(Logger::DEBUG)); + $handler = new BufferHandler($test, 0, Level::Info); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Info)); + $handler->handle($this->getRecord(Level::Warning)); + $handler->handle($this->getRecord(Level::Debug)); $handler->close(); $this->assertTrue($test->hasWarningRecords()); $this->assertTrue($test->hasInfoRecords()); @@ -129,8 +129,8 @@ public function testFlush() { $test = new TestHandler(); $handler = new BufferHandler($test, 0); - $handler->handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::INFO)); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Info)); $handler->flush(); $this->assertTrue($test->hasInfoRecords()); $this->assertTrue($test->hasDebugRecords()); @@ -145,11 +145,11 @@ public function testHandleUsesProcessors() $test = new TestHandler(); $handler = new BufferHandler($test); $handler->pushProcessor(function ($record) { - $record['extra']['foo'] = true; + $record->extra['foo'] = true; return $record; }); - $handler->handle($this->getRecord(Logger::WARNING)); + $handler->handle($this->getRecord(Level::Warning)); $handler->flush(); $this->assertTrue($test->hasWarningRecords()); $records = $test->getRecords(); diff --git a/tests/Monolog/Handler/ChromePHPHandlerTest.php b/tests/Monolog/Handler/ChromePHPHandlerTest.php index 7168ce49d..4b2f29537 100644 --- a/tests/Monolog/Handler/ChromePHPHandlerTest.php +++ b/tests/Monolog/Handler/ChromePHPHandlerTest.php @@ -12,16 +12,16 @@ namespace Monolog\Handler; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; /** * @covers Monolog\Handler\ChromePHPHandler */ class ChromePHPHandlerTest extends TestCase { - protected function setUp() + protected function setUp(): void { - TestChromePHPHandler::reset(); + TestChromePHPHandler::resetStatic(); $_SERVER['HTTP_USER_AGENT'] = 'Monolog Test; Chrome/1.0'; } @@ -34,19 +34,19 @@ public function testHeaders($agent) $handler = new TestChromePHPHandler(); $handler->setFormatter($this->getIdentityFormatter()); - $handler->handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::WARNING)); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Warning)); $expected = [ - 'X-ChromeLogger-Data' => base64_encode(utf8_encode(json_encode([ - 'version' => ChromePHPHandler::VERSION, + 'X-ChromeLogger-Data' => base64_encode(json_encode([ + 'version' => '4.0', 'columns' => ['label', 'log', 'backtrace', 'type'], 'rows' => [ 'test', 'test', ], 'request_uri' => '', - ]))), + ])), ]; $this->assertEquals($expected, $handler->getHeaders()); @@ -54,26 +54,26 @@ public function testHeaders($agent) public static function agentsProvider() { - return array( - array('Monolog Test; Chrome/1.0'), - array('Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:52.0) Gecko/20100101 Firefox/52.0'), - array('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/56.0.2924.76 Chrome/56.0.2924.76 Safari/537.36'), - array('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome Safari/537.36'), - ); + return [ + ['Monolog Test; Chrome/1.0'], + ['Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:52.0) Gecko/20100101 Firefox/52.0'], + ['Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/56.0.2924.76 Chrome/56.0.2924.76 Safari/537.36'], + ['Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome Safari/537.36'], + ]; } public function testHeadersOverflow() { $handler = new TestChromePHPHandler(); - $handler->handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::WARNING, str_repeat('a', 150 * 1024))); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Warning, str_repeat('a', 2 * 1024))); // overflow chrome headers limit - $handler->handle($this->getRecord(Logger::WARNING, str_repeat('a', 100 * 1024))); + $handler->handle($this->getRecord(Level::Warning, str_repeat('b', 2 * 1024))); $expected = [ - 'X-ChromeLogger-Data' => base64_encode(utf8_encode(json_encode([ - 'version' => ChromePHPHandler::VERSION, + 'X-ChromeLogger-Data' => base64_encode(json_encode([ + 'version' => '4.0', 'columns' => ['label', 'log', 'backtrace', 'type'], 'rows' => [ [ @@ -84,7 +84,7 @@ public function testHeadersOverflow() ], [ 'test', - str_repeat('a', 150 * 1024), + str_repeat('a', 2 * 1024), 'unknown', 'warn', ], @@ -96,7 +96,7 @@ public function testHeadersOverflow() ], ], 'request_uri' => '', - ]))), + ])), ]; $this->assertEquals($expected, $handler->getHeaders()); @@ -106,17 +106,17 @@ public function testConcurrentHandlers() { $handler = new TestChromePHPHandler(); $handler->setFormatter($this->getIdentityFormatter()); - $handler->handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::WARNING)); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Warning)); $handler2 = new TestChromePHPHandler(); $handler2->setFormatter($this->getIdentityFormatter()); - $handler2->handle($this->getRecord(Logger::DEBUG)); - $handler2->handle($this->getRecord(Logger::WARNING)); + $handler2->handle($this->getRecord(Level::Debug)); + $handler2->handle($this->getRecord(Level::Warning)); $expected = [ - 'X-ChromeLogger-Data' => base64_encode(utf8_encode(json_encode([ - 'version' => ChromePHPHandler::VERSION, + 'X-ChromeLogger-Data' => base64_encode(json_encode([ + 'version' => '4.0', 'columns' => ['label', 'log', 'backtrace', 'type'], 'rows' => [ 'test', @@ -125,7 +125,7 @@ public function testConcurrentHandlers() 'test', ], 'request_uri' => '', - ]))), + ])), ]; $this->assertEquals($expected, $handler2->getHeaders()); @@ -134,9 +134,9 @@ public function testConcurrentHandlers() class TestChromePHPHandler extends ChromePHPHandler { - protected $headers = []; + protected array $headers = []; - public static function reset() + public static function resetStatic(): void { self::$initialized = false; self::$overflowed = false; @@ -144,12 +144,12 @@ public static function reset() self::$json['rows'] = []; } - protected function sendHeader($header, $content) + protected function sendHeader(string $header, string $content): void { $this->headers[$header] = $content; } - public function getHeaders() + public function getHeaders(): array { return $this->headers; } diff --git a/tests/Monolog/Handler/CouchDBHandlerTest.php b/tests/Monolog/Handler/CouchDBHandlerTest.php index f89a130b5..289bcf5ab 100644 --- a/tests/Monolog/Handler/CouchDBHandlerTest.php +++ b/tests/Monolog/Handler/CouchDBHandlerTest.php @@ -12,13 +12,13 @@ namespace Monolog\Handler; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; class CouchDBHandlerTest extends TestCase { public function testHandle() { - $record = $this->getRecord(Logger::WARNING, 'test', ['data' => new \stdClass, 'foo' => 34]); + $record = $this->getRecord(Level::Warning, 'test', ['data' => new \stdClass, 'foo' => 34]); $handler = new CouchDBHandler(); diff --git a/tests/Monolog/Handler/DeduplicationHandlerTest.php b/tests/Monolog/Handler/DeduplicationHandlerTest.php index 491fd85c8..f617e019f 100644 --- a/tests/Monolog/Handler/DeduplicationHandlerTest.php +++ b/tests/Monolog/Handler/DeduplicationHandlerTest.php @@ -11,8 +11,8 @@ namespace Monolog\Handler; +use Monolog\Level; use Monolog\Test\TestCase; -use Monolog\Logger; class DeduplicationHandlerTest extends TestCase { @@ -23,10 +23,10 @@ public function testFlushPassthruIfAllRecordsUnderTrigger() { $test = new TestHandler(); @unlink(sys_get_temp_dir().'/monolog_dedup.log'); - $handler = new DeduplicationHandler($test, sys_get_temp_dir().'/monolog_dedup.log', 0); + $handler = new DeduplicationHandler($test, sys_get_temp_dir().'/monolog_dedup.log', Level::Debug); - $handler->handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::INFO)); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Info)); $handler->flush(); @@ -43,10 +43,10 @@ public function testFlushPassthruIfEmptyLog() { $test = new TestHandler(); @unlink(sys_get_temp_dir().'/monolog_dedup.log'); - $handler = new DeduplicationHandler($test, sys_get_temp_dir().'/monolog_dedup.log', 0); + $handler = new DeduplicationHandler($test, sys_get_temp_dir().'/monolog_dedup.log', Level::Debug); - $handler->handle($this->getRecord(Logger::ERROR, 'Foo:bar')); - $handler->handle($this->getRecord(Logger::CRITICAL, "Foo\nbar")); + $handler->handle($this->getRecord(Level::Error, 'Foo:bar')); + $handler->handle($this->getRecord(Level::Critical, "Foo\nbar")); $handler->flush(); @@ -64,10 +64,10 @@ public function testFlushPassthruIfEmptyLog() public function testFlushSkipsIfLogExists() { $test = new TestHandler(); - $handler = new DeduplicationHandler($test, sys_get_temp_dir().'/monolog_dedup.log', 0); + $handler = new DeduplicationHandler($test, sys_get_temp_dir().'/monolog_dedup.log', Level::Debug); - $handler->handle($this->getRecord(Logger::ERROR, 'Foo:bar')); - $handler->handle($this->getRecord(Logger::CRITICAL, "Foo\nbar")); + $handler->handle($this->getRecord(Level::Error, 'Foo:bar')); + $handler->handle($this->getRecord(Level::Critical, "Foo\nbar")); $handler->flush(); @@ -85,13 +85,11 @@ public function testFlushSkipsIfLogExists() public function testFlushPassthruIfLogTooOld() { $test = new TestHandler(); - $handler = new DeduplicationHandler($test, sys_get_temp_dir().'/monolog_dedup.log', 0); + $handler = new DeduplicationHandler($test, sys_get_temp_dir().'/monolog_dedup.log', Level::Debug); - $record = $this->getRecord(Logger::ERROR); - $record['datetime'] = $record['datetime']->modify('+62seconds'); + $record = $this->getRecord(Level::Error, datetime: new \DateTimeImmutable('+62seconds')); $handler->handle($record); - $record = $this->getRecord(Logger::CRITICAL); - $record['datetime'] = $record['datetime']->modify('+62seconds'); + $record = $this->getRecord(Level::Critical, datetime: new \DateTimeImmutable('+62seconds')); $handler->handle($record); $handler->flush(); @@ -111,23 +109,20 @@ public function testGcOldLogs() { $test = new TestHandler(); @unlink(sys_get_temp_dir().'/monolog_dedup.log'); - $handler = new DeduplicationHandler($test, sys_get_temp_dir().'/monolog_dedup.log', 0); + $handler = new DeduplicationHandler($test, sys_get_temp_dir().'/monolog_dedup.log', Level::Debug); // handle two records from yesterday, and one recent - $record = $this->getRecord(Logger::ERROR); - $record['datetime'] = $record['datetime']->modify('-1day -10seconds'); + $record = $this->getRecord(Level::Error, datetime: new \DateTimeImmutable('-1day -10seconds')); $handler->handle($record); - $record2 = $this->getRecord(Logger::CRITICAL); - $record2['datetime'] = $record2['datetime']->modify('-1day -10seconds'); + $record2 = $this->getRecord(Level::Critical, datetime: new \DateTimeImmutable('-1day -10seconds')); $handler->handle($record2); - $record3 = $this->getRecord(Logger::CRITICAL); - $record3['datetime'] = $record3['datetime']->modify('-30seconds'); + $record3 = $this->getRecord(Level::Critical, datetime: new \DateTimeImmutable('-30seconds')); $handler->handle($record3); // log is written as none of them are duplicate $handler->flush(); $this->assertSame( - $record['datetime']->getTimestamp() . ":ERROR:test\n" . + $record->datetime->getTimestamp() . ":ERROR:test\n" . $record2['datetime']->getTimestamp() . ":CRITICAL:test\n" . $record3['datetime']->getTimestamp() . ":CRITICAL:test\n", file_get_contents(sys_get_temp_dir() . '/monolog_dedup.log') @@ -142,14 +137,14 @@ public function testGcOldLogs() $this->assertFalse($test->hasCriticalRecords()); // log new records, duplicate log gets GC'd at the end of this flush call - $handler->handle($record = $this->getRecord(Logger::ERROR)); - $handler->handle($record2 = $this->getRecord(Logger::CRITICAL)); + $handler->handle($record = $this->getRecord(Level::Error)); + $handler->handle($record2 = $this->getRecord(Level::Critical)); $handler->flush(); // log should now contain the new errors and the previous one that was recent enough $this->assertSame( $record3['datetime']->getTimestamp() . ":CRITICAL:test\n" . - $record['datetime']->getTimestamp() . ":ERROR:test\n" . + $record->datetime->getTimestamp() . ":ERROR:test\n" . $record2['datetime']->getTimestamp() . ":CRITICAL:test\n", file_get_contents(sys_get_temp_dir() . '/monolog_dedup.log') ); @@ -158,7 +153,7 @@ public function testGcOldLogs() $this->assertFalse($test->hasWarningRecords()); } - public static function tearDownAfterClass() + public static function tearDownAfterClass(): void { @unlink(sys_get_temp_dir().'/monolog_dedup.log'); } diff --git a/tests/Monolog/Handler/DoctrineCouchDBHandlerTest.php b/tests/Monolog/Handler/DoctrineCouchDBHandlerTest.php index f72f32370..1f6f2ffb8 100644 --- a/tests/Monolog/Handler/DoctrineCouchDBHandlerTest.php +++ b/tests/Monolog/Handler/DoctrineCouchDBHandlerTest.php @@ -12,11 +12,11 @@ namespace Monolog\Handler; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; class DoctrineCouchDBHandlerTest extends TestCase { - protected function setup() + protected function setUp(): void { if (!class_exists('Doctrine\CouchDB\CouchDBClient')) { $this->markTestSkipped('The "doctrine/couchdb" package is not installed'); @@ -26,19 +26,19 @@ protected function setup() public function testHandle() { $client = $this->getMockBuilder('Doctrine\\CouchDB\\CouchDBClient') - ->setMethods(['postDocument']) + ->onlyMethods(['postDocument']) ->disableOriginalConstructor() ->getMock(); - $record = $this->getRecord(Logger::WARNING, 'test', ['data' => new \stdClass, 'foo' => 34]); + $record = $this->getRecord(Level::Warning, 'test', ['data' => new \stdClass, 'foo' => 34]); $expected = [ 'message' => 'test', 'context' => ['data' => ['stdClass' => []], 'foo' => 34], - 'level' => Logger::WARNING, + 'level' => Level::Warning->value, 'level_name' => 'WARNING', 'channel' => 'test', - 'datetime' => (string) $record['datetime'], + 'datetime' => (string) $record->datetime, 'extra' => [], ]; diff --git a/tests/Monolog/Handler/DynamoDbHandlerTest.php b/tests/Monolog/Handler/DynamoDbHandlerTest.php index 9d61356f6..33cf62c39 100644 --- a/tests/Monolog/Handler/DynamoDbHandlerTest.php +++ b/tests/Monolog/Handler/DynamoDbHandlerTest.php @@ -11,32 +11,47 @@ namespace Monolog\Handler; +use Aws\DynamoDb\DynamoDbClient; use Monolog\Test\TestCase; +use PHPUnit\Framework\MockObject\MockObject; class DynamoDbHandlerTest extends TestCase { - private $client; + private DynamoDbClient&MockObject $client; - public function setUp() + private bool $isV3; + + public function setUp(): void { - if (!class_exists('Aws\DynamoDb\DynamoDbClient')) { + if (!class_exists(DynamoDbClient::class)) { $this->markTestSkipped('aws/aws-sdk-php not installed'); } - $this->client = $this->getMockBuilder('Aws\DynamoDb\DynamoDbClient') - ->setMethods(['formatAttributes', '__call']) - ->disableOriginalConstructor() - ->getMock(); - } + $this->isV3 = defined('Aws\Sdk::VERSION') && version_compare(\Aws\Sdk::VERSION, '3.0', '>='); - public function testConstruct() - { - $this->assertInstanceOf('Monolog\Handler\DynamoDbHandler', new DynamoDbHandler($this->client, 'foo')); + $implementedMethods = ['__call']; + $absentMethods = []; + if (method_exists(DynamoDbClient::class, 'formatAttributes')) { + $implementedMethods[] = 'formatAttributes'; + } else { + $absentMethods[] = 'formatAttributes'; + } + + $clientMockBuilder = $this->getMockBuilder(DynamoDbClient::class) + ->onlyMethods($implementedMethods) + ->disableOriginalConstructor(); + if ($absentMethods) { + $clientMockBuilder->addMethods($absentMethods); + } + + $this->client = $clientMockBuilder->getMock(); } - public function testInterface() + public function tearDown(): void { - $this->assertInstanceOf('Monolog\Handler\HandlerInterface', new DynamoDbHandler($this->client, 'foo')); + parent::tearDown(); + + unset($this->client); } public function testGetFormatter() @@ -53,9 +68,8 @@ public function testHandle() $handler = new DynamoDbHandler($this->client, 'foo'); $handler->setFormatter($formatter); - $isV3 = defined('Aws\Sdk::VERSION') && version_compare(\Aws\Sdk::VERSION, '3.0', '>='); - if ($isV3) { - $expFormatted = array('foo' => array('N' => 1), 'bar' => array('N' => 2)); + if ($this->isV3) { + $expFormatted = ['foo' => ['N' => 1], 'bar' => ['N' => 2]]; } else { $expFormatted = $formatted; } @@ -66,7 +80,7 @@ public function testHandle() ->with($record) ->will($this->returnValue($formatted)); $this->client - ->expects($isV3 ? $this->never() : $this->once()) + ->expects($this->isV3 ? $this->never() : $this->once()) ->method('formatAttributes') ->with($this->isType('array')) ->will($this->returnValue($formatted)); diff --git a/tests/Monolog/Handler/ElasticSearchHandlerTest.php b/tests/Monolog/Handler/ElasticaHandlerTest.php similarity index 58% rename from tests/Monolog/Handler/ElasticSearchHandlerTest.php rename to tests/Monolog/Handler/ElasticaHandlerTest.php index 7c92555a7..6cc705d45 100644 --- a/tests/Monolog/Handler/ElasticSearchHandlerTest.php +++ b/tests/Monolog/Handler/ElasticaHandlerTest.php @@ -14,27 +14,30 @@ use Monolog\Formatter\ElasticaFormatter; use Monolog\Formatter\NormalizerFormatter; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; use Elastica\Client; use Elastica\Request; use Elastica\Response; -class ElasticSearchHandlerTest extends TestCase +/** + * @group Elastica + */ +class ElasticaHandlerTest extends TestCase { /** * @var Client mock */ - protected $client; + protected Client $client; /** * @var array Default handler options */ - protected $options = [ + protected array $options = [ 'index' => 'my_index', 'type' => 'doc_type', ]; - public function setUp() + public function setUp(): void { // Elastica lib required if (!class_exists("Elastica\Client")) { @@ -43,29 +46,28 @@ public function setUp() // base mock Elastica Client object $this->client = $this->getMockBuilder('Elastica\Client') - ->setMethods(['addDocuments']) + ->onlyMethods(['addDocuments']) ->disableOriginalConstructor() ->getMock(); } + public function tearDown(): void + { + parent::tearDown(); + + unset($this->client); + } + /** - * @covers Monolog\Handler\ElasticSearchHandler::write - * @covers Monolog\Handler\ElasticSearchHandler::handleBatch - * @covers Monolog\Handler\ElasticSearchHandler::bulkSend - * @covers Monolog\Handler\ElasticSearchHandler::getDefaultFormatter + * @covers Monolog\Handler\ElasticaHandler::write + * @covers Monolog\Handler\ElasticaHandler::handleBatch + * @covers Monolog\Handler\ElasticaHandler::bulkSend + * @covers Monolog\Handler\ElasticaHandler::getDefaultFormatter */ public function testHandle() { // log message - $msg = [ - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => ['foo' => 7, 'bar', 'class' => new \stdClass], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => [], - 'message' => 'log', - ]; + $msg = $this->getRecord(Level::Error, 'log', context: ['foo' => 7, 'bar', 'class' => new \stdClass], datetime: new \DateTimeImmutable("@0")); // format expected result $formatter = new ElasticaFormatter($this->options['index'], $this->options['type']); @@ -77,17 +79,17 @@ public function testHandle() ->with($expected); // perform tests - $handler = new ElasticSearchHandler($this->client, $this->options); + $handler = new ElasticaHandler($this->client, $this->options); $handler->handle($msg); $handler->handleBatch([$msg]); } /** - * @covers Monolog\Handler\ElasticSearchHandler::setFormatter + * @covers Monolog\Handler\ElasticaHandler::setFormatter */ public function testSetFormatter() { - $handler = new ElasticSearchHandler($this->client); + $handler = new ElasticaHandler($this->client); $formatter = new ElasticaFormatter('index_new', 'type_new'); $handler->setFormatter($formatter); $this->assertInstanceOf('Monolog\Formatter\ElasticaFormatter', $handler->getFormatter()); @@ -96,20 +98,22 @@ public function testSetFormatter() } /** - * @covers Monolog\Handler\ElasticSearchHandler::setFormatter - * @expectedException InvalidArgumentException - * @expectedExceptionMessage ElasticSearchHandler is only compatible with ElasticaFormatter + * @covers Monolog\Handler\ElasticaHandler::setFormatter */ public function testSetFormatterInvalid() { - $handler = new ElasticSearchHandler($this->client); + $handler = new ElasticaHandler($this->client); $formatter = new NormalizerFormatter(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('ElasticaHandler is only compatible with ElasticaFormatter'); + $handler->setFormatter($formatter); } /** - * @covers Monolog\Handler\ElasticSearchHandler::__construct - * @covers Monolog\Handler\ElasticSearchHandler::getOptions + * @covers Monolog\Handler\ElasticaHandler::__construct + * @covers Monolog\Handler\ElasticaHandler::getOptions */ public function testOptions() { @@ -118,12 +122,12 @@ public function testOptions() 'type' => $this->options['type'], 'ignore_error' => false, ]; - $handler = new ElasticSearchHandler($this->client, $this->options); + $handler = new ElasticaHandler($this->client, $this->options); $this->assertEquals($expected, $handler->getOptions()); } /** - * @covers Monolog\Handler\ElasticSearchHandler::bulkSend + * @covers Monolog\Handler\ElasticaHandler::bulkSend * @dataProvider providerTestConnectionErrors */ public function testConnectionErrors($ignore, $expectedError) @@ -131,7 +135,7 @@ public function testConnectionErrors($ignore, $expectedError) $clientOpts = ['host' => '127.0.0.1', 'port' => 1]; $client = new Client($clientOpts); $handlerOpts = ['ignore_error' => $ignore]; - $handler = new ElasticSearchHandler($client, $handlerOpts); + $handler = new ElasticaHandler($client, $handlerOpts); if ($expectedError) { $this->expectException($expectedError[0]); @@ -142,10 +146,7 @@ public function testConnectionErrors($ignore, $expectedError) } } - /** - * @return array - */ - public function providerTestConnectionErrors() + public function providerTestConnectionErrors(): array { return [ [false, ['RuntimeException', 'Error sending messages to Elasticsearch']], @@ -154,26 +155,18 @@ public function providerTestConnectionErrors() } /** - * Integration test using localhost Elastic Search server + * Integration test using localhost Elastic Search server version 7+ * - * @covers Monolog\Handler\ElasticSearchHandler::__construct - * @covers Monolog\Handler\ElasticSearchHandler::handleBatch - * @covers Monolog\Handler\ElasticSearchHandler::bulkSend - * @covers Monolog\Handler\ElasticSearchHandler::getDefaultFormatter + * @covers Monolog\Handler\ElasticaHandler::__construct + * @covers Monolog\Handler\ElasticaHandler::handleBatch + * @covers Monolog\Handler\ElasticaHandler::bulkSend + * @covers Monolog\Handler\ElasticaHandler::getDefaultFormatter */ - public function testHandleIntegration() + public function testHandleIntegrationNewESVersion() { - $msg = [ - 'level' => Logger::ERROR, - 'level_name' => 'ERROR', - 'channel' => 'meh', - 'context' => ['foo' => 7, 'bar', 'class' => new \stdClass], - 'datetime' => new \DateTimeImmutable("@0"), - 'extra' => [], - 'message' => 'log', - ]; + $msg = $this->getRecord(Level::Error, 'log', context: ['foo' => 7, 'bar', 'class' => new \stdClass], datetime: new \DateTimeImmutable("@0")); - $expected = $msg; + $expected = (array) $msg; $expected['datetime'] = $msg['datetime']->format(\DateTime::ISO8601); $expected['context'] = [ 'class' => '[object] (stdClass: {})', @@ -181,8 +174,10 @@ public function testHandleIntegration() 0 => 'bar', ]; - $client = new Client(); - $handler = new ElasticSearchHandler($client, $this->options); + $clientOpts = ['url' => 'http://elastic:changeme@127.0.0.1:9200']; + $client = new Client($clientOpts); + + $handler = new ElasticaHandler($client, $this->options); try { $handler->handleBatch([$msg]); @@ -198,7 +193,7 @@ public function testHandleIntegration() $document = $this->getDocSourceFromElastic( $client, $this->options['index'], - $this->options['type'], + null, $documentId ); $this->assertEquals($expected, $document); @@ -209,28 +204,34 @@ public function testHandleIntegration() /** * Return last created document id from ES response - * @param Response $response Elastica Response object - * @return string|null + * @param Response $response Elastica Response object */ - protected function getCreatedDocId(Response $response) + protected function getCreatedDocId(Response $response): ?string { $data = $response->getData(); - if (!empty($data['items'][0]['create']['_id'])) { - return $data['items'][0]['create']['_id']; + + if (!empty($data['items'][0]['index']['_id'])) { + return $data['items'][0]['index']['_id']; } + + var_dump('Unexpected response: ', $data); + + return null; } /** * Retrieve document by id from Elasticsearch - * @param Client $client Elastica client - * @param string $index - * @param string $type - * @param string $documentId - * @return array + * @param Client $client Elastica client + * @param ?string $type */ - protected function getDocSourceFromElastic(Client $client, $index, $type, $documentId) + protected function getDocSourceFromElastic(Client $client, string $index, $type, string $documentId): array { - $resp = $client->request("/{$index}/{$type}/{$documentId}", Request::GET); + if ($type === null) { + $path = "/{$index}/_doc/{$documentId}"; + } else { + $path = "/{$index}/{$type}/{$documentId}"; + } + $resp = $client->request($path, Request::GET); $data = $resp->getData(); if (!empty($data['_source'])) { return $data['_source']; diff --git a/tests/Monolog/Handler/ElasticsearchHandlerTest.php b/tests/Monolog/Handler/ElasticsearchHandlerTest.php new file mode 100644 index 000000000..e8e466dfa --- /dev/null +++ b/tests/Monolog/Handler/ElasticsearchHandlerTest.php @@ -0,0 +1,259 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Handler; + +use Monolog\Formatter\ElasticsearchFormatter; +use Monolog\Formatter\NormalizerFormatter; +use Monolog\Test\TestCase; +use Monolog\Level; +use Elasticsearch\Client; +use Elastic\Elasticsearch\Client as Client8; +use Elasticsearch\ClientBuilder; +use Elastic\Elasticsearch\ClientBuilder as ClientBuilder8; + +/** + * @group Elasticsearch + */ +class ElasticsearchHandlerTest extends TestCase +{ + protected Client|Client8 $client; + + /** + * @var array Default handler options + */ + protected array $options = [ + 'index' => 'my_index', + 'type' => 'doc_type', + ]; + + public function setUp(): void + { + $hosts = ['http://elastic:changeme@127.0.0.1:9200']; + $this->client = $this->getClientBuilder() + ->setHosts($hosts) + ->build(); + + try { + $this->client->info(); + } catch (\Throwable $e) { + $this->markTestSkipped('Could not connect to Elasticsearch on 127.0.0.1:9200'); + } + } + + public function tearDown(): void + { + parent::tearDown(); + + unset($this->client); + } + + /** + * @covers Monolog\Handler\ElasticsearchHandler::setFormatter + */ + public function testSetFormatter() + { + $handler = new ElasticsearchHandler($this->client); + $formatter = new ElasticsearchFormatter('index_new', 'type_new'); + $handler->setFormatter($formatter); + $this->assertInstanceOf('Monolog\Formatter\ElasticsearchFormatter', $handler->getFormatter()); + $this->assertEquals('index_new', $handler->getFormatter()->getIndex()); + $this->assertEquals('type_new', $handler->getFormatter()->getType()); + } + + /** + * @covers Monolog\Handler\ElasticsearchHandler::setFormatter + */ + public function testSetFormatterInvalid() + { + $handler = new ElasticsearchHandler($this->client); + $formatter = new NormalizerFormatter(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('ElasticsearchHandler is only compatible with ElasticsearchFormatter'); + + $handler->setFormatter($formatter); + } + + /** + * @covers Monolog\Handler\ElasticsearchHandler::__construct + * @covers Monolog\Handler\ElasticsearchHandler::getOptions + */ + public function testOptions() + { + $expected = [ + 'index' => $this->options['index'], + 'type' => $this->options['type'], + 'ignore_error' => false, + ]; + + if ($this->client instanceof Client8 || $this->client::VERSION[0] === '7') { + $expected['type'] = '_doc'; + } + + $handler = new ElasticsearchHandler($this->client, $this->options); + $this->assertEquals($expected, $handler->getOptions()); + } + + /** + * @covers Monolog\Handler\ElasticsearchHandler::bulkSend + * @dataProvider providerTestConnectionErrors + */ + public function testConnectionErrors($ignore, $expectedError) + { + $hosts = ['http://127.0.0.1:1']; + $client = $this->getClientBuilder() + ->setHosts($hosts) + ->build(); + + $handlerOpts = ['ignore_error' => $ignore]; + $handler = new ElasticsearchHandler($client, $handlerOpts); + + if ($expectedError) { + $this->expectException($expectedError[0]); + $this->expectExceptionMessage($expectedError[1]); + $handler->handle($this->getRecord()); + } else { + $this->assertFalse($handler->handle($this->getRecord())); + } + } + + public function providerTestConnectionErrors(): array + { + return [ + [false, ['RuntimeException', 'Error sending messages to Elasticsearch']], + [true, false], + ]; + } + + /** + * Integration test using localhost Elasticsearch server + * + * @covers Monolog\Handler\ElasticsearchHandler::__construct + * @covers Monolog\Handler\ElasticsearchHandler::handleBatch + * @covers Monolog\Handler\ElasticsearchHandler::bulkSend + * @covers Monolog\Handler\ElasticsearchHandler::getDefaultFormatter + */ + public function testHandleBatchIntegration() + { + $msg = $this->getRecord(Level::Error, 'log', context: ['foo' => 7, 'bar', 'class' => new \stdClass], datetime: new \DateTimeImmutable("@0")); + + $expected = $msg->toArray(); + $expected['datetime'] = $msg['datetime']->format(\DateTime::ISO8601); + $expected['context'] = [ + 'class' => ["stdClass" => []], + 'foo' => 7, + 0 => 'bar', + ]; + + $hosts = ['http://elastic:changeme@127.0.0.1:9200']; + $client = $this->getClientBuilder() + ->setHosts($hosts) + ->build(); + $handler = new ElasticsearchHandler($client, $this->options); + $handler->handleBatch([$msg]); + + // check document id from ES server response + if ($client instanceof Client8) { + $messageBody = $client->getTransport()->getLastResponse()->getBody(); + + $info = json_decode((string) $messageBody, true); + $this->assertNotNull($info, 'Decoding failed'); + + $documentId = $this->getCreatedDocIdV8($info); + $this->assertNotEmpty($documentId, 'No elastic document id received'); + } else { + $documentId = $this->getCreatedDocId($client->transport->getLastConnection()->getLastRequestInfo()); + $this->assertNotEmpty($documentId, 'No elastic document id received'); + } + + // retrieve document source from ES and validate + $document = $this->getDocSourceFromElastic( + $client, + $this->options['index'], + $this->options['type'], + $documentId + ); + + $this->assertEquals($expected, $document); + + // remove test index from ES + $client->indices()->delete(['index' => $this->options['index']]); + } + + /** + * Return last created document id from ES response + * + * @param array $info Elasticsearch last request info + */ + protected function getCreatedDocId(array $info): ?string + { + $data = json_decode($info['response']['body'], true); + + if (!empty($data['items'][0]['index']['_id'])) { + return $data['items'][0]['index']['_id']; + } + + return null; + } + + /** + * Return last created document id from ES response + * + * @param array $data Elasticsearch last request info + * @return string|null + */ + protected function getCreatedDocIdV8(array $data) + { + if (!empty($data['items'][0]['index']['_id'])) { + return $data['items'][0]['index']['_id']; + } + + return null; + } + + /** + * Retrieve document by id from Elasticsearch + * + * @return array + */ + protected function getDocSourceFromElastic(Client|Client8 $client, string $index, string $type, string $documentId): array + { + $params = [ + 'index' => $index, + 'id' => $documentId, + ]; + + if (!$client instanceof Client8 && $client::VERSION[0] !== '7') { + $params['type'] = $type; + } + + $data = $client->get($params); + + if (!empty($data['_source'])) { + return $data['_source']; + } + + return []; + } + + /** + * @return ClientBuilder|ClientBuilder8 + */ + private function getClientBuilder() + { + if (class_exists(ClientBuilder8::class)) { + return ClientBuilder8::create(); + } + + return ClientBuilder::create(); + } +} diff --git a/tests/Monolog/Handler/ErrorLogHandlerTest.php b/tests/Monolog/Handler/ErrorLogHandlerTest.php index 16d8e47d9..37bf58c56 100644 --- a/tests/Monolog/Handler/ErrorLogHandlerTest.php +++ b/tests/Monolog/Handler/ErrorLogHandlerTest.php @@ -12,7 +12,7 @@ namespace Monolog\Handler; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; use Monolog\Formatter\LineFormatter; function error_log() @@ -22,37 +22,38 @@ function error_log() class ErrorLogHandlerTest extends TestCase { - protected function setUp() + protected function setUp(): void { $GLOBALS['error_log'] = []; } /** * @covers Monolog\Handler\ErrorLogHandler::__construct - * @expectedException InvalidArgumentException - * @expectedExceptionMessage The given message type "42" is not supported */ public function testShouldNotAcceptAnInvalidTypeOnConstructor() { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The given message type "42" is not supported'); + new ErrorLogHandler(42); } /** * @covers Monolog\Handler\ErrorLogHandler::write */ - public function testShouldLogMessagesUsingErrorLogFuncion() + public function testShouldLogMessagesUsingErrorLogFunction() { $type = ErrorLogHandler::OPERATING_SYSTEM; $handler = new ErrorLogHandler($type); $handler->setFormatter(new LineFormatter('%channel%.%level_name%: %message% %context% %extra%', null, true)); - $handler->handle($this->getRecord(Logger::ERROR, "Foo\nBar\r\n\r\nBaz")); + $handler->handle($this->getRecord(Level::Error, "Foo\nBar\r\n\r\nBaz")); $this->assertSame("test.ERROR: Foo\nBar\r\n\r\nBaz [] []", $GLOBALS['error_log'][0][0]); $this->assertSame($GLOBALS['error_log'][0][1], $type); - $handler = new ErrorLogHandler($type, Logger::DEBUG, true, true); + $handler = new ErrorLogHandler($type, Level::Debug, true, true); $handler->setFormatter(new LineFormatter(null, null, true)); - $handler->handle($this->getRecord(Logger::ERROR, "Foo\nBar\r\n\r\nBaz")); + $handler->handle($this->getRecord(Level::Error, "Foo\nBar\r\n\r\nBaz")); $this->assertStringMatchesFormat('[%s] test.ERROR: Foo', $GLOBALS['error_log'][1][0]); $this->assertSame($GLOBALS['error_log'][1][1], $type); diff --git a/tests/Monolog/Handler/MockRavenClient.php b/tests/Monolog/Handler/ExceptionTestHandler.php similarity index 51% rename from tests/Monolog/Handler/MockRavenClient.php rename to tests/Monolog/Handler/ExceptionTestHandler.php index d344d3427..bd4031f53 100644 --- a/tests/Monolog/Handler/MockRavenClient.php +++ b/tests/Monolog/Handler/ExceptionTestHandler.php @@ -11,17 +11,18 @@ namespace Monolog\Handler; -use Raven_Client; +use Exception; +use Monolog\LogRecord; -class MockRavenClient extends Raven_Client +class ExceptionTestHandler extends TestHandler { - public function capture($data, $stack, $vars = null) + /** + * @inheritDoc + */ + public function handle(LogRecord $record): bool { - $data = array_merge($this->get_user_data(), $data); - $this->lastData = $data; - $this->lastStack = $stack; - } + throw new Exception("ExceptionTestHandler::handle"); - public $lastData; - public $lastStack; + parent::handle($record); + } } diff --git a/tests/Monolog/Handler/FallbackGroupHandlerTest.php b/tests/Monolog/Handler/FallbackGroupHandlerTest.php new file mode 100644 index 000000000..b364c0ff3 --- /dev/null +++ b/tests/Monolog/Handler/FallbackGroupHandlerTest.php @@ -0,0 +1,141 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Handler; + +use Monolog\Level; +use Monolog\Test\TestCase; + +class FallbackGroupHandlerTest extends TestCase +{ + /** + * @covers Monolog\Handler\FallbackGroupHandler::__construct + * @covers Monolog\Handler\FallbackGroupHandler::handle + */ + public function testHandle() + { + $testHandlerOne = new TestHandler(); + $testHandlerTwo = new TestHandler(); + $testHandlers = [$testHandlerOne, $testHandlerTwo]; + $handler = new FallbackGroupHandler($testHandlers); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Info)); + + $this->assertCount(2, $testHandlerOne->getRecords()); + $this->assertCount(0, $testHandlerTwo->getRecords()); + } + + /** + * @covers Monolog\Handler\FallbackGroupHandler::__construct + * @covers Monolog\Handler\FallbackGroupHandler::handle + */ + public function testHandleExceptionThrown() + { + $testHandlerOne = new ExceptionTestHandler(); + $testHandlerTwo = new TestHandler(); + $testHandlers = [$testHandlerOne, $testHandlerTwo]; + $handler = new FallbackGroupHandler($testHandlers); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Info)); + + $this->assertCount(0, $testHandlerOne->getRecords()); + $this->assertCount(2, $testHandlerTwo->getRecords()); + } + + /** + * @covers Monolog\Handler\FallbackGroupHandler::handleBatch + */ + public function testHandleBatch() + { + $testHandlerOne = new TestHandler(); + $testHandlerTwo = new TestHandler(); + $testHandlers = [$testHandlerOne, $testHandlerTwo]; + $handler = new FallbackGroupHandler($testHandlers); + $handler->handleBatch([$this->getRecord(Level::Debug), $this->getRecord(Level::Info)]); + $this->assertCount(2, $testHandlerOne->getRecords()); + $this->assertCount(0, $testHandlerTwo->getRecords()); + } + + /** + * @covers Monolog\Handler\FallbackGroupHandler::handleBatch + */ + public function testHandleBatchExceptionThrown() + { + $testHandlerOne = new ExceptionTestHandler(); + $testHandlerTwo = new TestHandler(); + $testHandlers = [$testHandlerOne, $testHandlerTwo]; + $handler = new FallbackGroupHandler($testHandlers); + $handler->handleBatch([$this->getRecord(Level::Debug), $this->getRecord(Level::Info)]); + $this->assertCount(0, $testHandlerOne->getRecords()); + $this->assertCount(2, $testHandlerTwo->getRecords()); + } + + /** + * @covers Monolog\Handler\FallbackGroupHandler::isHandling + */ + public function testIsHandling() + { + $testHandlers = [new TestHandler(Level::Error), new TestHandler(Level::Warning)]; + $handler = new FallbackGroupHandler($testHandlers); + $this->assertTrue($handler->isHandling($this->getRecord(Level::Error))); + $this->assertTrue($handler->isHandling($this->getRecord(Level::Warning))); + $this->assertFalse($handler->isHandling($this->getRecord(Level::Debug))); + } + + /** + * @covers Monolog\Handler\FallbackGroupHandler::handle + */ + public function testHandleUsesProcessors() + { + $test = new TestHandler(); + $handler = new FallbackGroupHandler([$test]); + $handler->pushProcessor(function ($record) { + $record->extra['foo'] = true; + + return $record; + }); + $handler->handle($this->getRecord(Level::Warning)); + $this->assertTrue($test->hasWarningRecords()); + $records = $test->getRecords(); + $this->assertTrue($records[0]['extra']['foo']); + } + + /** + * @covers Monolog\Handler\FallbackGroupHandler::handleBatch + */ + public function testHandleBatchUsesProcessors() + { + $testHandlerOne = new ExceptionTestHandler(); + $testHandlerTwo = new TestHandler(); + $testHandlers = [$testHandlerOne, $testHandlerTwo]; + $handler = new FallbackGroupHandler($testHandlers); + $handler->pushProcessor(function ($record) { + $record->extra['foo'] = true; + + return $record; + }); + $handler->pushProcessor(function ($record) { + $record->extra['foo2'] = true; + + return $record; + }); + $handler->handleBatch([$this->getRecord(Level::Debug), $this->getRecord(Level::Info)]); + $this->assertEmpty($testHandlerOne->getRecords()); + $this->assertTrue($testHandlerTwo->hasDebugRecords()); + $this->assertTrue($testHandlerTwo->hasInfoRecords()); + $this->assertCount(2, $testHandlerTwo->getRecords()); + $records = $testHandlerTwo->getRecords(); + $this->assertTrue($records[0]['extra']['foo']); + $this->assertTrue($records[1]['extra']['foo']); + $this->assertTrue($records[0]['extra']['foo2']); + $this->assertTrue($records[1]['extra']['foo2']); + } +} diff --git a/tests/Monolog/Handler/FilterHandlerTest.php b/tests/Monolog/Handler/FilterHandlerTest.php index b0072de03..799ee169b 100644 --- a/tests/Monolog/Handler/FilterHandlerTest.php +++ b/tests/Monolog/Handler/FilterHandlerTest.php @@ -11,7 +11,7 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; use Monolog\Test\TestCase; class FilterHandlerTest extends TestCase @@ -22,15 +22,15 @@ class FilterHandlerTest extends TestCase public function testIsHandling() { $test = new TestHandler(); - $handler = new FilterHandler($test, Logger::INFO, Logger::NOTICE); - $this->assertFalse($handler->isHandling($this->getRecord(Logger::DEBUG))); - $this->assertTrue($handler->isHandling($this->getRecord(Logger::INFO))); - $this->assertTrue($handler->isHandling($this->getRecord(Logger::NOTICE))); - $this->assertFalse($handler->isHandling($this->getRecord(Logger::WARNING))); - $this->assertFalse($handler->isHandling($this->getRecord(Logger::ERROR))); - $this->assertFalse($handler->isHandling($this->getRecord(Logger::CRITICAL))); - $this->assertFalse($handler->isHandling($this->getRecord(Logger::ALERT))); - $this->assertFalse($handler->isHandling($this->getRecord(Logger::EMERGENCY))); + $handler = new FilterHandler($test, Level::Info, Level::Notice); + $this->assertFalse($handler->isHandling($this->getRecord(Level::Debug))); + $this->assertTrue($handler->isHandling($this->getRecord(Level::Info))); + $this->assertTrue($handler->isHandling($this->getRecord(Level::Notice))); + $this->assertFalse($handler->isHandling($this->getRecord(Level::Warning))); + $this->assertFalse($handler->isHandling($this->getRecord(Level::Error))); + $this->assertFalse($handler->isHandling($this->getRecord(Level::Critical))); + $this->assertFalse($handler->isHandling($this->getRecord(Level::Alert))); + $this->assertFalse($handler->isHandling($this->getRecord(Level::Emergency))); } /** @@ -41,39 +41,39 @@ public function testIsHandling() public function testHandleProcessOnlyNeededLevels() { $test = new TestHandler(); - $handler = new FilterHandler($test, Logger::INFO, Logger::NOTICE); + $handler = new FilterHandler($test, Level::Info, Level::Notice); - $handler->handle($this->getRecord(Logger::DEBUG)); + $handler->handle($this->getRecord(Level::Debug)); $this->assertFalse($test->hasDebugRecords()); - $handler->handle($this->getRecord(Logger::INFO)); + $handler->handle($this->getRecord(Level::Info)); $this->assertTrue($test->hasInfoRecords()); - $handler->handle($this->getRecord(Logger::NOTICE)); + $handler->handle($this->getRecord(Level::Notice)); $this->assertTrue($test->hasNoticeRecords()); - $handler->handle($this->getRecord(Logger::WARNING)); + $handler->handle($this->getRecord(Level::Warning)); $this->assertFalse($test->hasWarningRecords()); - $handler->handle($this->getRecord(Logger::ERROR)); + $handler->handle($this->getRecord(Level::Error)); $this->assertFalse($test->hasErrorRecords()); - $handler->handle($this->getRecord(Logger::CRITICAL)); + $handler->handle($this->getRecord(Level::Critical)); $this->assertFalse($test->hasCriticalRecords()); - $handler->handle($this->getRecord(Logger::ALERT)); + $handler->handle($this->getRecord(Level::Alert)); $this->assertFalse($test->hasAlertRecords()); - $handler->handle($this->getRecord(Logger::EMERGENCY)); + $handler->handle($this->getRecord(Level::Emergency)); $this->assertFalse($test->hasEmergencyRecords()); $test = new TestHandler(); - $handler = new FilterHandler($test, [Logger::INFO, Logger::ERROR]); + $handler = new FilterHandler($test, [Level::Info, Level::Error]); - $handler->handle($this->getRecord(Logger::DEBUG)); + $handler->handle($this->getRecord(Level::Debug)); $this->assertFalse($test->hasDebugRecords()); - $handler->handle($this->getRecord(Logger::INFO)); + $handler->handle($this->getRecord(Level::Info)); $this->assertTrue($test->hasInfoRecords()); - $handler->handle($this->getRecord(Logger::NOTICE)); + $handler->handle($this->getRecord(Level::Notice)); $this->assertFalse($test->hasNoticeRecords()); - $handler->handle($this->getRecord(Logger::ERROR)); + $handler->handle($this->getRecord(Level::Error)); $this->assertTrue($test->hasErrorRecords()); - $handler->handle($this->getRecord(Logger::CRITICAL)); + $handler->handle($this->getRecord(Level::Critical)); $this->assertFalse($test->hasCriticalRecords()); } @@ -86,15 +86,16 @@ public function testAcceptedLevelApi() $test = new TestHandler(); $handler = new FilterHandler($test); - $levels = [Logger::INFO, Logger::ERROR]; + $levels = [Level::Info, Level::Error]; + $levelsExpect = [Level::Info, Level::Error]; $handler->setAcceptedLevels($levels); - $this->assertSame($levels, $handler->getAcceptedLevels()); + $this->assertSame($levelsExpect, $handler->getAcceptedLevels()); $handler->setAcceptedLevels(['info', 'error']); - $this->assertSame($levels, $handler->getAcceptedLevels()); + $this->assertSame($levelsExpect, $handler->getAcceptedLevels()); - $levels = [Logger::CRITICAL, Logger::ALERT, Logger::EMERGENCY]; - $handler->setAcceptedLevels(Logger::CRITICAL, Logger::EMERGENCY); + $levels = [Level::Critical, Level::Alert, Level::Emergency]; + $handler->setAcceptedLevels(Level::Critical, Level::Emergency); $this->assertSame($levels, $handler->getAcceptedLevels()); $handler->setAcceptedLevels('critical', 'emergency'); @@ -107,15 +108,15 @@ public function testAcceptedLevelApi() public function testHandleUsesProcessors() { $test = new TestHandler(); - $handler = new FilterHandler($test, Logger::DEBUG, Logger::EMERGENCY); + $handler = new FilterHandler($test, Level::Debug, Level::Emergency); $handler->pushProcessor( function ($record) { - $record['extra']['foo'] = true; + $record->extra['foo'] = true; return $record; } ); - $handler->handle($this->getRecord(Logger::WARNING)); + $handler->handle($this->getRecord(Level::Warning)); $this->assertTrue($test->hasWarningRecords()); $records = $test->getRecords(); $this->assertTrue($records[0]['extra']['foo']); @@ -128,13 +129,13 @@ public function testHandleRespectsBubble() { $test = new TestHandler(); - $handler = new FilterHandler($test, Logger::INFO, Logger::NOTICE, false); - $this->assertTrue($handler->handle($this->getRecord(Logger::INFO))); - $this->assertFalse($handler->handle($this->getRecord(Logger::WARNING))); + $handler = new FilterHandler($test, Level::Info, Level::Notice, false); + $this->assertTrue($handler->handle($this->getRecord(Level::Info))); + $this->assertFalse($handler->handle($this->getRecord(Level::Warning))); - $handler = new FilterHandler($test, Logger::INFO, Logger::NOTICE, true); - $this->assertFalse($handler->handle($this->getRecord(Logger::INFO))); - $this->assertFalse($handler->handle($this->getRecord(Logger::WARNING))); + $handler = new FilterHandler($test, Level::Info, Level::Notice, true); + $this->assertFalse($handler->handle($this->getRecord(Level::Info))); + $this->assertFalse($handler->handle($this->getRecord(Level::Warning))); } /** @@ -147,19 +148,18 @@ public function testHandleWithCallback() function ($record, $handler) use ($test) { return $test; }, - Logger::INFO, - Logger::NOTICE, + Level::Info, + Level::Notice, false ); - $handler->handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::INFO)); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Info)); $this->assertFalse($test->hasDebugRecords()); $this->assertTrue($test->hasInfoRecords()); } /** * @covers Monolog\Handler\FilterHandler::handle - * @expectedException \RuntimeException */ public function testHandleWithBadCallbackThrowsException() { @@ -168,6 +168,40 @@ function ($record, $handler) { return 'foo'; } ); - $handler->handle($this->getRecord(Logger::WARNING)); + + $this->expectException(\RuntimeException::class); + + $handler->handle($this->getRecord(Level::Warning)); + } + + public function testHandleEmptyBatch() + { + $test = new TestHandler(); + $handler = new FilterHandler($test); + $handler->handleBatch([]); + $this->assertSame([], $test->getRecords()); + } + + /** + * @covers Monolog\Handler\FilterHandler::handle + * @covers Monolog\Handler\FilterHandler::reset + */ + public function testResetTestHandler() + { + $test = new TestHandler(); + $handler = new FilterHandler($test, [Level::Info, Level::Error]); + + $handler->handle($this->getRecord(Level::Info)); + $this->assertTrue($test->hasInfoRecords()); + + $handler->handle($this->getRecord(Level::Error)); + $this->assertTrue($test->hasErrorRecords()); + + $handler->reset(); + + $this->assertFalse($test->hasInfoRecords()); + $this->assertFalse($test->hasInfoRecords()); + + $this->assertSame([], $test->getRecords()); } } diff --git a/tests/Monolog/Handler/FingersCrossedHandlerTest.php b/tests/Monolog/Handler/FingersCrossedHandlerTest.php index 5b25a3656..702a5f081 100644 --- a/tests/Monolog/Handler/FingersCrossedHandlerTest.php +++ b/tests/Monolog/Handler/FingersCrossedHandlerTest.php @@ -11,8 +11,8 @@ namespace Monolog\Handler; +use Monolog\Level; use Monolog\Test\TestCase; -use Monolog\Logger; use Monolog\Handler\FingersCrossed\ErrorLevelActivationStrategy; use Monolog\Handler\FingersCrossed\ChannelLevelActivationStrategy; use Psr\Log\LogLevel; @@ -28,14 +28,14 @@ public function testHandleBuffers() { $test = new TestHandler(); $handler = new FingersCrossedHandler($test); - $handler->handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::INFO)); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Info)); $this->assertFalse($test->hasDebugRecords()); $this->assertFalse($test->hasInfoRecords()); - $handler->handle($this->getRecord(Logger::WARNING)); + $handler->handle($this->getRecord(Level::Warning)); $handler->close(); $this->assertTrue($test->hasInfoRecords()); - $this->assertTrue(count($test->getRecords()) === 3); + $this->assertCount(3, $test->getRecords()); } /** @@ -46,8 +46,8 @@ public function testHandleStopsBufferingAfterTrigger() { $test = new TestHandler(); $handler = new FingersCrossedHandler($test); - $handler->handle($this->getRecord(Logger::WARNING)); - $handler->handle($this->getRecord(Logger::DEBUG)); + $handler->handle($this->getRecord(Level::Warning)); + $handler->handle($this->getRecord(Level::Debug)); $handler->close(); $this->assertTrue($test->hasWarningRecords()); $this->assertTrue($test->hasDebugRecords()); @@ -58,14 +58,15 @@ public function testHandleStopsBufferingAfterTrigger() * @covers Monolog\Handler\FingersCrossedHandler::activate * @covers Monolog\Handler\FingersCrossedHandler::reset */ - public function testHandleRestartBufferingAfterReset() + public function testHandleResetBufferingAfterReset() { $test = new TestHandler(); + $test->setSkipReset(true); $handler = new FingersCrossedHandler($test); - $handler->handle($this->getRecord(Logger::WARNING)); - $handler->handle($this->getRecord(Logger::DEBUG)); + $handler->handle($this->getRecord(Level::Warning)); + $handler->handle($this->getRecord(Level::Debug)); $handler->reset(); - $handler->handle($this->getRecord(Logger::INFO)); + $handler->handle($this->getRecord(Level::Info)); $handler->close(); $this->assertTrue($test->hasWarningRecords()); $this->assertTrue($test->hasDebugRecords()); @@ -76,13 +77,13 @@ public function testHandleRestartBufferingAfterReset() * @covers Monolog\Handler\FingersCrossedHandler::handle * @covers Monolog\Handler\FingersCrossedHandler::activate */ - public function testHandleRestartBufferingAfterBeingTriggeredWhenStopBufferingIsDisabled() + public function testHandleResetBufferingAfterBeingTriggeredWhenStopBufferingIsDisabled() { $test = new TestHandler(); - $handler = new FingersCrossedHandler($test, Logger::WARNING, 0, false, false); - $handler->handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::WARNING)); - $handler->handle($this->getRecord(Logger::INFO)); + $handler = new FingersCrossedHandler($test, Level::Warning, 0, false, false); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Warning)); + $handler->handle($this->getRecord(Level::Info)); $handler->close(); $this->assertTrue($test->hasWarningRecords()); $this->assertTrue($test->hasDebugRecords()); @@ -96,11 +97,11 @@ public function testHandleRestartBufferingAfterBeingTriggeredWhenStopBufferingIs public function testHandleBufferLimit() { $test = new TestHandler(); - $handler = new FingersCrossedHandler($test, Logger::WARNING, 2); - $handler->handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::INFO)); - $handler->handle($this->getRecord(Logger::WARNING)); + $handler = new FingersCrossedHandler($test, Level::Warning, 2); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Info)); + $handler->handle($this->getRecord(Level::Warning)); $this->assertTrue($test->hasWarningRecords()); $this->assertTrue($test->hasInfoRecords()); $this->assertFalse($test->hasDebugRecords()); @@ -116,26 +117,28 @@ public function testHandleWithCallback() $handler = new FingersCrossedHandler(function ($record, $handler) use ($test) { return $test; }); - $handler->handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::INFO)); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Info)); $this->assertFalse($test->hasDebugRecords()); $this->assertFalse($test->hasInfoRecords()); - $handler->handle($this->getRecord(Logger::WARNING)); + $handler->handle($this->getRecord(Level::Warning)); $this->assertTrue($test->hasInfoRecords()); - $this->assertTrue(count($test->getRecords()) === 3); + $this->assertCount(3, $test->getRecords()); } /** * @covers Monolog\Handler\FingersCrossedHandler::handle * @covers Monolog\Handler\FingersCrossedHandler::activate - * @expectedException RuntimeException */ public function testHandleWithBadCallbackThrowsException() { $handler = new FingersCrossedHandler(function ($record, $handler) { return 'foo'; }); - $handler->handle($this->getRecord(Logger::WARNING)); + + $this->expectException(\RuntimeException::class); + + $handler->handle($this->getRecord(Level::Warning)); } /** @@ -144,8 +147,8 @@ public function testHandleWithBadCallbackThrowsException() public function testIsHandlingAlways() { $test = new TestHandler(); - $handler = new FingersCrossedHandler($test, Logger::ERROR); - $this->assertTrue($handler->isHandling($this->getRecord(Logger::DEBUG))); + $handler = new FingersCrossedHandler($test, Level::Error); + $this->assertTrue($handler->isHandling($this->getRecord(Level::Debug))); } /** @@ -156,10 +159,10 @@ public function testIsHandlingAlways() public function testErrorLevelActivationStrategy() { $test = new TestHandler(); - $handler = new FingersCrossedHandler($test, new ErrorLevelActivationStrategy(Logger::WARNING)); - $handler->handle($this->getRecord(Logger::DEBUG)); + $handler = new FingersCrossedHandler($test, new ErrorLevelActivationStrategy(Level::Warning)); + $handler->handle($this->getRecord(Level::Debug)); $this->assertFalse($test->hasDebugRecords()); - $handler->handle($this->getRecord(Logger::WARNING)); + $handler->handle($this->getRecord(Level::Warning)); $this->assertTrue($test->hasDebugRecords()); $this->assertTrue($test->hasWarningRecords()); } @@ -173,9 +176,9 @@ public function testErrorLevelActivationStrategyWithPsrLevel() { $test = new TestHandler(); $handler = new FingersCrossedHandler($test, new ErrorLevelActivationStrategy('warning')); - $handler->handle($this->getRecord(Logger::DEBUG)); + $handler->handle($this->getRecord(Level::Debug)); $this->assertFalse($test->hasDebugRecords()); - $handler->handle($this->getRecord(Logger::WARNING)); + $handler->handle($this->getRecord(Level::Warning)); $this->assertTrue($test->hasDebugRecords()); $this->assertTrue($test->hasWarningRecords()); } @@ -188,11 +191,11 @@ public function testOverrideActivationStrategy() { $test = new TestHandler(); $handler = new FingersCrossedHandler($test, new ErrorLevelActivationStrategy('warning')); - $handler->handle($this->getRecord(Logger::DEBUG)); + $handler->handle($this->getRecord(Level::Debug)); $this->assertFalse($test->hasDebugRecords()); $handler->activate(); $this->assertTrue($test->hasDebugRecords()); - $handler->handle($this->getRecord(Logger::INFO)); + $handler->handle($this->getRecord(Level::Info)); $this->assertTrue($test->hasInfoRecords()); } @@ -203,11 +206,10 @@ public function testOverrideActivationStrategy() public function testChannelLevelActivationStrategy() { $test = new TestHandler(); - $handler = new FingersCrossedHandler($test, new ChannelLevelActivationStrategy(Logger::ERROR, ['othertest' => Logger::DEBUG])); - $handler->handle($this->getRecord(Logger::WARNING)); + $handler = new FingersCrossedHandler($test, new ChannelLevelActivationStrategy(Level::Error, ['othertest' => Level::Debug])); + $handler->handle($this->getRecord(Level::Warning)); $this->assertFalse($test->hasWarningRecords()); - $record = $this->getRecord(Logger::DEBUG); - $record['channel'] = 'othertest'; + $record = $this->getRecord(Level::Debug, channel: 'othertest'); $handler->handle($record); $this->assertTrue($test->hasDebugRecords()); $this->assertTrue($test->hasWarningRecords()); @@ -221,10 +223,9 @@ public function testChannelLevelActivationStrategyWithPsrLevels() { $test = new TestHandler(); $handler = new FingersCrossedHandler($test, new ChannelLevelActivationStrategy('error', ['othertest' => 'debug'])); - $handler->handle($this->getRecord(Logger::WARNING)); + $handler->handle($this->getRecord(Level::Warning)); $this->assertFalse($test->hasWarningRecords()); - $record = $this->getRecord(Logger::DEBUG); - $record['channel'] = 'othertest'; + $record = $this->getRecord(Level::Debug, channel: 'othertest'); $handler->handle($record); $this->assertTrue($test->hasDebugRecords()); $this->assertTrue($test->hasWarningRecords()); @@ -237,13 +238,13 @@ public function testChannelLevelActivationStrategyWithPsrLevels() public function testHandleUsesProcessors() { $test = new TestHandler(); - $handler = new FingersCrossedHandler($test, Logger::INFO); + $handler = new FingersCrossedHandler($test, Level::Info); $handler->pushProcessor(function ($record) { - $record['extra']['foo'] = true; + $record->extra['foo'] = true; return $record; }); - $handler->handle($this->getRecord(Logger::WARNING)); + $handler->handle($this->getRecord(Level::Warning)); $this->assertTrue($test->hasWarningRecords()); $records = $test->getRecords(); $this->assertTrue($records[0]['extra']['foo']); @@ -255,9 +256,9 @@ public function testHandleUsesProcessors() public function testPassthruOnClose() { $test = new TestHandler(); - $handler = new FingersCrossedHandler($test, new ErrorLevelActivationStrategy(Logger::WARNING), 0, true, true, Logger::INFO); - $handler->handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::INFO)); + $handler = new FingersCrossedHandler($test, new ErrorLevelActivationStrategy(Level::Warning), 0, true, true, Level::Info); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Info)); $handler->close(); $this->assertFalse($test->hasDebugRecords()); $this->assertTrue($test->hasInfoRecords()); @@ -269,9 +270,9 @@ public function testPassthruOnClose() public function testPsrLevelPassthruOnClose() { $test = new TestHandler(); - $handler = new FingersCrossedHandler($test, new ErrorLevelActivationStrategy(Logger::WARNING), 0, true, true, LogLevel::INFO); - $handler->handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::INFO)); + $handler = new FingersCrossedHandler($test, new ErrorLevelActivationStrategy(Level::Warning), 0, true, true, LogLevel::INFO); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Info)); $handler->close(); $this->assertFalse($test->hasDebugRecords()); $this->assertTrue($test->hasInfoRecords()); diff --git a/tests/Monolog/Handler/FirePHPHandlerTest.php b/tests/Monolog/Handler/FirePHPHandlerTest.php index 07df2fe8a..40fb67821 100644 --- a/tests/Monolog/Handler/FirePHPHandlerTest.php +++ b/tests/Monolog/Handler/FirePHPHandlerTest.php @@ -12,16 +12,16 @@ namespace Monolog\Handler; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; /** * @covers Monolog\Handler\FirePHPHandler */ class FirePHPHandlerTest extends TestCase { - public function setUp() + public function setUp(): void { - TestFirePHPHandler::reset(); + TestFirePHPHandler::resetStatic(); $_SERVER['HTTP_USER_AGENT'] = 'Monolog Test; FirePHP/1.0'; } @@ -29,8 +29,8 @@ public function testHeaders() { $handler = new TestFirePHPHandler; $handler->setFormatter($this->getIdentityFormatter()); - $handler->handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::WARNING)); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Warning)); $expected = [ 'X-Wf-Protocol-1' => 'http://meta.wildfirehq.org/Protocol/JsonStream/0.2', @@ -47,13 +47,13 @@ public function testConcurrentHandlers() { $handler = new TestFirePHPHandler; $handler->setFormatter($this->getIdentityFormatter()); - $handler->handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::WARNING)); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Warning)); $handler2 = new TestFirePHPHandler; $handler2->setFormatter($this->getIdentityFormatter()); - $handler2->handle($this->getRecord(Logger::DEBUG)); - $handler2->handle($this->getRecord(Logger::WARNING)); + $handler2->handle($this->getRecord(Level::Debug)); + $handler2->handle($this->getRecord(Level::Warning)); $expected = [ 'X-Wf-Protocol-1' => 'http://meta.wildfirehq.org/Protocol/JsonStream/0.2', @@ -75,21 +75,21 @@ public function testConcurrentHandlers() class TestFirePHPHandler extends FirePHPHandler { - protected $headers = []; + protected array $headers = []; - public static function reset() + public static function resetStatic(): void { self::$initialized = false; self::$sendHeaders = true; self::$messageIndex = 1; } - protected function sendHeader($header, $content) + protected function sendHeader(string $header, string $content): void { $this->headers[$header] = $content; } - public function getHeaders() + public function getHeaders(): array { return $this->headers; } diff --git a/tests/Monolog/Handler/FleepHookHandlerTest.php b/tests/Monolog/Handler/FleepHookHandlerTest.php index 11b5a6541..75cd32be6 100644 --- a/tests/Monolog/Handler/FleepHookHandlerTest.php +++ b/tests/Monolog/Handler/FleepHookHandlerTest.php @@ -12,7 +12,7 @@ namespace Monolog\Handler; use Monolog\Formatter\LineFormatter; -use Monolog\Logger; +use Monolog\Level; use Monolog\Test\TestCase; /** @@ -25,12 +25,9 @@ class FleepHookHandlerTest extends TestCase */ const TOKEN = '123abc'; - /** - * @var FleepHookHandler - */ - private $handler; + private FleepHookHandler $handler; - public function setUp() + public function setUp(): void { parent::setUp(); @@ -47,7 +44,7 @@ public function setUp() */ public function testConstructorSetsExpectedDefaults() { - $this->assertEquals(Logger::DEBUG, $this->handler->getLevel()); + $this->assertEquals(Level::Debug, $this->handler->getLevel()); $this->assertEquals(true, $this->handler->getBubble()); } @@ -56,15 +53,7 @@ public function testConstructorSetsExpectedDefaults() */ public function testHandlerUsesLineFormatterWhichIgnoresEmptyArrays() { - $record = [ - 'message' => 'msg', - 'context' => [], - 'level' => Logger::DEBUG, - 'level_name' => Logger::getLevelName(Logger::DEBUG), - 'channel' => 'channel', - 'datetime' => new \DateTimeImmutable(), - 'extra' => [], - ]; + $record = $this->getRecord(Level::Debug, 'msg'); $expectedFormatter = new LineFormatter(null, null, true, true); $expected = $expectedFormatter->format($record); @@ -80,6 +69,6 @@ public function testHandlerUsesLineFormatterWhichIgnoresEmptyArrays() */ public function testConnectionStringisConstructedCorrectly() { - $this->assertEquals('ssl://' . FleepHookHandler::FLEEP_HOST . ':443', $this->handler->getConnectionString()); + $this->assertEquals('ssl://fleep.io:443', $this->handler->getConnectionString()); } } diff --git a/tests/Monolog/Handler/FlowdockHandlerTest.php b/tests/Monolog/Handler/FlowdockHandlerTest.php index 23f8b06cd..0623ef1ff 100644 --- a/tests/Monolog/Handler/FlowdockHandlerTest.php +++ b/tests/Monolog/Handler/FlowdockHandlerTest.php @@ -13,7 +13,7 @@ use Monolog\Formatter\FlowdockFormatter; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; /** * @author Dominik Liebler @@ -26,26 +26,30 @@ class FlowdockHandlerTest extends TestCase */ private $res; - /** - * @var FlowdockHandler - */ - private $handler; + private FlowdockHandler $handler; - public function setUp() + public function setUp(): void { if (!extension_loaded('openssl')) { $this->markTestSkipped('This test requires openssl to run'); } } + public function tearDown(): void + { + parent::tearDown(); + + unset($this->res); + } + public function testWriteHeader() { $this->createHandler(); - $this->handler->handle($this->getRecord(Logger::CRITICAL, 'test1')); + $this->handler->handle($this->getRecord(Level::Critical, 'test1')); fseek($this->res, 0); $content = fread($this->res, 1024); - $this->assertRegexp('/POST \/v1\/messages\/team_inbox\/.* HTTP\/1.1\\r\\nHost: api.flowdock.com\\r\\nContent-Type: application\/json\\r\\nContent-Length: \d{2,4}\\r\\n\\r\\n/', $content); + $this->assertMatchesRegularExpression('/POST \/v1\/messages\/team_inbox\/.* HTTP\/1.1\\r\\nHost: api.flowdock.com\\r\\nContent-Type: application\/json\\r\\nContent-Length: \d{2,4}\\r\\n\\r\\n/', $content); return $content; } @@ -55,17 +59,17 @@ public function testWriteHeader() */ public function testWriteContent($content) { - $this->assertRegexp('/"source":"test_source"/', $content); - $this->assertRegexp('/"from_address":"source@test\.com"/', $content); + $this->assertMatchesRegularExpression('/"source":"test_source"/', $content); + $this->assertMatchesRegularExpression('/"from_address":"source@test\.com"/', $content); } private function createHandler($token = 'myToken') { - $constructorArgs = [$token, Logger::DEBUG]; + $constructorArgs = [$token, Level::Debug]; $this->res = fopen('php://memory', 'a'); $this->handler = $this->getMockBuilder('Monolog\Handler\FlowdockHandler') ->setConstructorArgs($constructorArgs) - ->setMethods(['fsockopen', 'streamSetTimeout', 'closeSocket']) + ->onlyMethods(['fsockopen', 'streamSetTimeout', 'closeSocket']) ->getMock(); $reflectionProperty = new \ReflectionProperty('Monolog\Handler\SocketHandler', 'connectionString'); diff --git a/tests/Monolog/Handler/GelfHandlerTest.php b/tests/Monolog/Handler/GelfHandlerTest.php index 12e5f8b17..e96f11660 100644 --- a/tests/Monolog/Handler/GelfHandlerTest.php +++ b/tests/Monolog/Handler/GelfHandlerTest.php @@ -13,12 +13,12 @@ use Gelf\Message; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; use Monolog\Formatter\GelfMessageFormatter; class GelfHandlerTest extends TestCase { - public function setUp() + public function setUp(): void { if (!class_exists('Gelf\Publisher') || !class_exists('Gelf\Message')) { $this->markTestSkipped("graylog2/gelf-php not installed"); @@ -44,20 +44,20 @@ protected function getHandler($messagePublisher) protected function getMessagePublisher() { return $this->getMockBuilder('Gelf\Publisher') - ->setMethods(['publish']) + ->onlyMethods(['publish']) ->disableOriginalConstructor() ->getMock(); } public function testDebug() { - $record = $this->getRecord(Logger::DEBUG, "A test debug message"); + $record = $this->getRecord(Level::Debug, "A test debug message"); $expectedMessage = new Message(); $expectedMessage ->setLevel(7) - ->setFacility("test") - ->setShortMessage($record['message']) - ->setTimestamp($record['datetime']) + ->setAdditional('facility', 'test') + ->setShortMessage($record->message) + ->setTimestamp($record->datetime) ; $messagePublisher = $this->getMessagePublisher(); @@ -72,13 +72,13 @@ public function testDebug() public function testWarning() { - $record = $this->getRecord(Logger::WARNING, "A test warning message"); + $record = $this->getRecord(Level::Warning, "A test warning message"); $expectedMessage = new Message(); $expectedMessage ->setLevel(4) - ->setFacility("test") - ->setShortMessage($record['message']) - ->setTimestamp($record['datetime']) + ->setAdditional('facility', 'test') + ->setShortMessage($record->message) + ->setTimestamp($record->datetime) ; $messagePublisher = $this->getMessagePublisher(); @@ -93,17 +93,20 @@ public function testWarning() public function testInjectedGelfMessageFormatter() { - $record = $this->getRecord(Logger::WARNING, "A test warning message"); - $record['extra']['blarg'] = 'yep'; - $record['context']['from'] = 'logger'; + $record = $this->getRecord( + Level::Warning, + "A test warning message", + extra: ['blarg' => 'yep'], + context: ['from' => 'logger'], + ); $expectedMessage = new Message(); $expectedMessage ->setLevel(4) - ->setFacility("test") + ->setAdditional('facility', 'test') ->setHost("mysystem") - ->setShortMessage($record['message']) - ->setTimestamp($record['datetime']) + ->setShortMessage($record->message) + ->setTimestamp($record->datetime) ->setAdditional("EXTblarg", 'yep') ->setAdditional("CTXfrom", 'logger') ; diff --git a/tests/Monolog/Handler/GroupHandlerTest.php b/tests/Monolog/Handler/GroupHandlerTest.php index d0ffdf012..0c734dee0 100644 --- a/tests/Monolog/Handler/GroupHandlerTest.php +++ b/tests/Monolog/Handler/GroupHandlerTest.php @@ -12,16 +12,17 @@ namespace Monolog\Handler; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; class GroupHandlerTest extends TestCase { /** * @covers Monolog\Handler\GroupHandler::__construct - * @expectedException InvalidArgumentException */ public function testConstructorOnlyTakesHandler() { + $this->expectException(\InvalidArgumentException::class); + new GroupHandler([new TestHandler(), "foo"]); } @@ -33,12 +34,12 @@ public function testHandle() { $testHandlers = [new TestHandler(), new TestHandler()]; $handler = new GroupHandler($testHandlers); - $handler->handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::INFO)); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Info)); foreach ($testHandlers as $test) { $this->assertTrue($test->hasDebugRecords()); $this->assertTrue($test->hasInfoRecords()); - $this->assertTrue(count($test->getRecords()) === 2); + $this->assertCount(2, $test->getRecords()); } } @@ -49,11 +50,11 @@ public function testHandleBatch() { $testHandlers = [new TestHandler(), new TestHandler()]; $handler = new GroupHandler($testHandlers); - $handler->handleBatch([$this->getRecord(Logger::DEBUG), $this->getRecord(Logger::INFO)]); + $handler->handleBatch([$this->getRecord(Level::Debug), $this->getRecord(Level::Info)]); foreach ($testHandlers as $test) { $this->assertTrue($test->hasDebugRecords()); $this->assertTrue($test->hasInfoRecords()); - $this->assertTrue(count($test->getRecords()) === 2); + $this->assertCount(2, $test->getRecords()); } } @@ -62,11 +63,11 @@ public function testHandleBatch() */ public function testIsHandling() { - $testHandlers = [new TestHandler(Logger::ERROR), new TestHandler(Logger::WARNING)]; + $testHandlers = [new TestHandler(Level::Error), new TestHandler(Level::Warning)]; $handler = new GroupHandler($testHandlers); - $this->assertTrue($handler->isHandling($this->getRecord(Logger::ERROR))); - $this->assertTrue($handler->isHandling($this->getRecord(Logger::WARNING))); - $this->assertFalse($handler->isHandling($this->getRecord(Logger::DEBUG))); + $this->assertTrue($handler->isHandling($this->getRecord(Level::Error))); + $this->assertTrue($handler->isHandling($this->getRecord(Level::Warning))); + $this->assertFalse($handler->isHandling($this->getRecord(Level::Debug))); } /** @@ -77,11 +78,11 @@ public function testHandleUsesProcessors() $test = new TestHandler(); $handler = new GroupHandler([$test]); $handler->pushProcessor(function ($record) { - $record['extra']['foo'] = true; + $record->extra['foo'] = true; return $record; }); - $handler->handle($this->getRecord(Logger::WARNING)); + $handler->handle($this->getRecord(Level::Warning)); $this->assertTrue($test->hasWarningRecords()); $records = $test->getRecords(); $this->assertTrue($records[0]['extra']['foo']); @@ -95,18 +96,25 @@ public function testHandleBatchUsesProcessors() $testHandlers = [new TestHandler(), new TestHandler()]; $handler = new GroupHandler($testHandlers); $handler->pushProcessor(function ($record) { - $record['extra']['foo'] = true; + $record->extra['foo'] = true; + + return $record; + }); + $handler->pushProcessor(function ($record) { + $record->extra['foo2'] = true; return $record; }); - $handler->handleBatch([$this->getRecord(Logger::DEBUG), $this->getRecord(Logger::INFO)]); + $handler->handleBatch([$this->getRecord(Level::Debug), $this->getRecord(Level::Info)]); foreach ($testHandlers as $test) { $this->assertTrue($test->hasDebugRecords()); $this->assertTrue($test->hasInfoRecords()); - $this->assertTrue(count($test->getRecords()) === 2); + $this->assertCount(2, $test->getRecords()); $records = $test->getRecords(); $this->assertTrue($records[0]['extra']['foo']); $this->assertTrue($records[1]['extra']['foo']); + $this->assertTrue($records[0]['extra']['foo2']); + $this->assertTrue($records[1]['extra']['foo2']); } } } diff --git a/tests/Monolog/Handler/HandlerWrapperTest.php b/tests/Monolog/Handler/HandlerWrapperTest.php index bedc17508..7212081ea 100644 --- a/tests/Monolog/Handler/HandlerWrapperTest.php +++ b/tests/Monolog/Handler/HandlerWrapperTest.php @@ -12,30 +12,32 @@ namespace Monolog\Handler; use Monolog\Test\TestCase; +use PHPUnit\Framework\MockObject\MockObject; /** * @author Alexey Karapetov */ class HandlerWrapperTest extends TestCase { - /** - * @var HandlerWrapper - */ - private $wrapper; + private HandlerWrapper $wrapper; - private $handler; + private HandlerInterface&MockObject $handler; - public function setUp() + public function setUp(): void { parent::setUp(); - $this->handler = $this->createMock('Monolog\\Handler\\HandlerInterface'); + $this->handler = $this->createMock(HandlerInterface::class); $this->wrapper = new HandlerWrapper($this->handler); } - /** - * @return array - */ - public function trueFalseDataProvider() + public function tearDown(): void + { + parent::tearDown(); + + unset($this->wrapper); + } + + public function trueFalseDataProvider(): array { return [ [true], @@ -82,9 +84,8 @@ public function testHandleBatch($result) $records = $this->getMultipleRecords(); $this->handler->expects($this->once()) ->method('handleBatch') - ->with($records) - ->willReturn($result); + ->with($records); - $this->assertEquals($result, $this->wrapper->handleBatch($records)); + $this->wrapper->handleBatch($records); } } diff --git a/tests/Monolog/Handler/HipChatHandlerTest.php b/tests/Monolog/Handler/HipChatHandlerTest.php deleted file mode 100644 index 4df4de478..000000000 --- a/tests/Monolog/Handler/HipChatHandlerTest.php +++ /dev/null @@ -1,234 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Monolog\Handler; - -use Monolog\Test\TestCase; -use Monolog\Logger; - -/** - * @author Rafael Dohms - * @see https://www.hipchat.com/docs/api - */ -class HipChatHandlerTest extends TestCase -{ - private $res; - /** @var HipChatHandler */ - private $handler; - - public function testWriteV2() - { - $this->createHandler('myToken', 'room1', 'Monolog', false, 'hipchat.foo.bar', 'v2'); - $this->handler->handle($this->getRecord(Logger::CRITICAL, 'test1')); - fseek($this->res, 0); - $content = fread($this->res, 1024); - - $this->assertRegexp('{POST /v2/room/room1/notification\?auth_token=.* HTTP/1.1\\r\\nHost: hipchat.foo.bar\\r\\nContent-Type: application/x-www-form-urlencoded\\r\\nContent-Length: \d{2,4}\\r\\n\\r\\n}', $content); - - return $content; - } - - public function testWriteV2Notify() - { - $this->createHandler('myToken', 'room1', 'Monolog', true, 'hipchat.foo.bar', 'v2'); - $this->handler->handle($this->getRecord(Logger::CRITICAL, 'test1')); - fseek($this->res, 0); - $content = fread($this->res, 1024); - - $this->assertRegexp('{POST /v2/room/room1/notification\?auth_token=.* HTTP/1.1\\r\\nHost: hipchat.foo.bar\\r\\nContent-Type: application/x-www-form-urlencoded\\r\\nContent-Length: \d{2,4}\\r\\n\\r\\n}', $content); - - return $content; - } - - public function testRoomSpaces() - { - $this->createHandler('myToken', 'room name', 'Monolog', false, 'hipchat.foo.bar', 'v2'); - $this->handler->handle($this->getRecord(Logger::CRITICAL, 'test1')); - fseek($this->res, 0); - $content = fread($this->res, 1024); - - $this->assertRegexp('{POST /v2/room/room%20name/notification\?auth_token=.* HTTP/1.1\\r\\nHost: hipchat.foo.bar\\r\\nContent-Type: application/x-www-form-urlencoded\\r\\nContent-Length: \d{2,4}\\r\\n\\r\\n}', $content); - - return $content; - } - - /** - * @depends testWriteHeader - */ - public function testWriteContent($content) - { - $this->assertRegexp('/notify=0&message=test1&message_format=text&color=red&room_id=room1&from=Monolog$/', $content); - } - - /** - * @depends testWriteCustomHostHeader - */ - public function testWriteContentNotify($content) - { - $this->assertRegexp('/notify=1&message=test1&message_format=text&color=red&room_id=room1&from=Monolog$/', $content); - } - - /** - * @depends testWriteV2 - */ - public function testWriteContentV2($content) - { - $this->assertRegexp('/notify=false&message=test1&message_format=text&color=red&from=Monolog$/', $content); - } - - /** - * @depends testWriteV2Notify - */ - public function testWriteContentV2Notify($content) - { - $this->assertRegexp('/notify=true&message=test1&message_format=text&color=red&from=Monolog$/', $content); - } - - public function testWriteContentV2WithoutName() - { - $this->createHandler('myToken', 'room1', null, false, 'hipchat.foo.bar', 'v2'); - $this->handler->handle($this->getRecord(Logger::CRITICAL, 'test1')); - fseek($this->res, 0); - $content = fread($this->res, 1024); - - $this->assertRegexp('/notify=false&message=test1&message_format=text&color=red$/', $content); - - return $content; - } - - public function testWriteWithComplexMessage() - { - $this->createHandler(); - $this->handler->handle($this->getRecord(Logger::CRITICAL, 'Backup of database "example" finished in 16 minutes.')); - fseek($this->res, 0); - $content = fread($this->res, 1024); - - $this->assertRegexp('/message=Backup\+of\+database\+%22example%22\+finished\+in\+16\+minutes\./', $content); - } - - public function testWriteTruncatesLongMessage() - { - $this->createHandler(); - $this->handler->handle($this->getRecord(Logger::CRITICAL, str_repeat('abcde', 2000))); - fseek($this->res, 0); - $content = fread($this->res, 12000); - - $this->assertRegexp('/message='.str_repeat('abcde', 1900).'\+%5Btruncated%5D/', $content); - } - - /** - * @dataProvider provideLevelColors - */ - public function testWriteWithErrorLevelsAndColors($level, $expectedColor) - { - $this->createHandler(); - $this->handler->handle($this->getRecord($level, 'Backup of database "example" finished in 16 minutes.')); - fseek($this->res, 0); - $content = fread($this->res, 1024); - - $this->assertRegexp('/color='.$expectedColor.'/', $content); - } - - public function provideLevelColors() - { - return [ - [Logger::DEBUG, 'gray'], - [Logger::INFO, 'green'], - [Logger::WARNING, 'yellow'], - [Logger::ERROR, 'red'], - [Logger::CRITICAL, 'red'], - [Logger::ALERT, 'red'], - [Logger::EMERGENCY,'red'], - [Logger::NOTICE, 'green'], - ]; - } - - /** - * @dataProvider provideBatchRecords - */ - public function testHandleBatch($records, $expectedColor) - { - $this->createHandler(); - - $this->handler->handleBatch($records); - - fseek($this->res, 0); - $content = fread($this->res, 1024); - - $this->assertRegexp('/color='.$expectedColor.'/', $content); - } - - public function provideBatchRecords() - { - return [ - [ - [ - ['level' => Logger::WARNING, 'message' => 'Oh bugger!', 'level_name' => 'warning', 'datetime' => new \DateTimeImmutable()], - ['level' => Logger::NOTICE, 'message' => 'Something noticeable happened.', 'level_name' => 'notice', 'datetime' => new \DateTimeImmutable()], - ['level' => Logger::CRITICAL, 'message' => 'Everything is broken!', 'level_name' => 'critical', 'datetime' => new \DateTimeImmutable()], - ], - 'red', - ], - [ - [ - ['level' => Logger::WARNING, 'message' => 'Oh bugger!', 'level_name' => 'warning', 'datetime' => new \DateTimeImmutable()], - ['level' => Logger::NOTICE, 'message' => 'Something noticeable happened.', 'level_name' => 'notice', 'datetime' => new \DateTimeImmutable()], - ], - 'yellow', - ], - [ - [ - ['level' => Logger::DEBUG, 'message' => 'Just debugging.', 'level_name' => 'debug', 'datetime' => new \DateTimeImmutable()], - ['level' => Logger::NOTICE, 'message' => 'Something noticeable happened.', 'level_name' => 'notice', 'datetime' => new \DateTimeImmutable()], - ], - 'green', - ], - [ - [ - ['level' => Logger::DEBUG, 'message' => 'Just debugging.', 'level_name' => 'debug', 'datetime' => new \DateTimeImmutable()], - ], - 'gray', - ], - ]; - } - - private function createHandler($token = 'myToken', $room = 'room1', $name = 'Monolog', $notify = false, $host = 'api.hipchat.com', $version = 'v1') - { - $constructorArgs = [$token, $room, $name, $notify, Logger::DEBUG, true, true, 'text', $host, $version]; - $this->res = fopen('php://memory', 'a'); - $this->handler = $this->getMockBuilder('Monolog\Handler\HipChatHandler') - ->setConstructorArgs($constructorArgs) - ->setMethods(['fsockopen', 'streamSetTimeout', 'closeSocket']) - ->getMock(); - - $reflectionProperty = new \ReflectionProperty('Monolog\Handler\SocketHandler', 'connectionString'); - $reflectionProperty->setAccessible(true); - $reflectionProperty->setValue($this->handler, 'localhost:1234'); - - $this->handler->expects($this->any()) - ->method('fsockopen') - ->will($this->returnValue($this->res)); - $this->handler->expects($this->any()) - ->method('streamSetTimeout') - ->will($this->returnValue(true)); - $this->handler->expects($this->any()) - ->method('closeSocket') - ->will($this->returnValue(true)); - - $this->handler->setFormatter($this->getIdentityFormatter()); - } - - public function testCreateWithTooLongNameV2() - { - // creating a handler with too long of a name but using the v2 api doesn't matter. - $hipChatHandler = new HipChatHandler('token', 'room', 'SixteenCharsHere', false, Logger::CRITICAL, true, true, 'test', 'api.hipchat.com', 'v2'); - } -} diff --git a/tests/Monolog/Handler/InsightOpsHandlerTest.php b/tests/Monolog/Handler/InsightOpsHandlerTest.php index 209858aee..b867a0b83 100644 --- a/tests/Monolog/Handler/InsightOpsHandlerTest.php +++ b/tests/Monolog/Handler/InsightOpsHandlerTest.php @@ -12,9 +12,10 @@ namespace Monolog\Handler; use Monolog\Test\TestCase; - use Monolog\Logger; +use Monolog\Level; +use PHPUnit\Framework\MockObject\MockObject; - /** +/** * @author Robert Kaufmann III * @author Gabriel Machado */ @@ -25,20 +26,24 @@ class InsightOpsHandlerTest extends TestCase */ private $resource; - /** - * @var LogEntriesHandler - */ - private $handler; + private InsightOpsHandler&MockObject $handler; + + public function tearDown(): void + { + parent::tearDown(); + + unset($this->resource); + } public function testWriteContent() { $this->createHandler(); - $this->handler->handle($this->getRecord(Logger::CRITICAL, 'Critical write test')); + $this->handler->handle($this->getRecord(Level::Critical, 'Critical write test')); fseek($this->resource, 0); $content = fread($this->resource, 1024); - $this->assertRegexp('/testToken \[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}\+00:00\] test.CRITICAL: Critical write test/', $content); + $this->assertMatchesRegularExpression('/testToken \[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}\+00:00\] test.CRITICAL: Critical write test/', $content); } public function testWriteBatchContent() @@ -49,16 +54,16 @@ public function testWriteBatchContent() fseek($this->resource, 0); $content = fread($this->resource, 1024); - $this->assertRegexp('/(testToken \[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}\+00:00\] .* \[\] \[\]\n){3}/', $content); + $this->assertMatchesRegularExpression('/(testToken \[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}\+00:00\] .* \[\] \[\]\n){3}/', $content); } private function createHandler() { $useSSL = extension_loaded('openssl'); - $args = array('testToken', 'us', $useSSL, Logger::DEBUG, true); + $args = ['testToken', 'us', $useSSL, Level::Debug, true]; $this->resource = fopen('php://memory', 'a'); $this->handler = $this->getMockBuilder(InsightOpsHandler::class) - ->setMethods(array('fsockopen', 'streamSetTimeout', 'closeSocket')) + ->onlyMethods(['fsockopen', 'streamSetTimeout', 'closeSocket']) ->setConstructorArgs($args) ->getMock(); diff --git a/tests/Monolog/Handler/LogEntriesHandlerTest.php b/tests/Monolog/Handler/LogEntriesHandlerTest.php index 92a206d65..fa1d44840 100644 --- a/tests/Monolog/Handler/LogEntriesHandlerTest.php +++ b/tests/Monolog/Handler/LogEntriesHandlerTest.php @@ -12,7 +12,8 @@ namespace Monolog\Handler; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; +use PHPUnit\Framework\MockObject\MockObject; /** * @author Robert Kaufmann III @@ -24,20 +25,24 @@ class LogEntriesHandlerTest extends TestCase */ private $res; - /** - * @var LogEntriesHandler - */ - private $handler; + private LogEntriesHandler&MockObject $handler; + + public function tearDown(): void + { + parent::tearDown(); + + unset($this->res); + } public function testWriteContent() { $this->createHandler(); - $this->handler->handle($this->getRecord(Logger::CRITICAL, 'Critical write test')); + $this->handler->handle($this->getRecord(Level::Critical, 'Critical write test')); fseek($this->res, 0); $content = fread($this->res, 1024); - $this->assertRegexp('/testToken \[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}\+00:00\] test.CRITICAL: Critical write test/', $content); + $this->assertMatchesRegularExpression('/testToken \[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}\+00:00\] test.CRITICAL: Critical write test/', $content); } public function testWriteBatchContent() @@ -53,17 +58,17 @@ public function testWriteBatchContent() fseek($this->res, 0); $content = fread($this->res, 1024); - $this->assertRegexp('/(testToken \[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}\+00:00\] .* \[\] \[\]\n){3}/', $content); + $this->assertMatchesRegularExpression('/(testToken \[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}\+00:00\] .* \[\] \[\]\n){3}/', $content); } private function createHandler() { $useSSL = extension_loaded('openssl'); - $args = ['testToken', $useSSL, Logger::DEBUG, true]; + $args = ['testToken', $useSSL, Level::Debug, true]; $this->res = fopen('php://memory', 'a'); $this->handler = $this->getMockBuilder('Monolog\Handler\LogEntriesHandler') ->setConstructorArgs($args) - ->setMethods(['fsockopen', 'streamSetTimeout', 'closeSocket']) + ->onlyMethods(['fsockopen', 'streamSetTimeout', 'closeSocket']) ->getMock(); $reflectionProperty = new \ReflectionProperty('Monolog\Handler\SocketHandler', 'connectionString'); diff --git a/tests/Monolog/Handler/LogmaticHandlerTest.php b/tests/Monolog/Handler/LogmaticHandlerTest.php index de33c6bbc..e35f86acd 100644 --- a/tests/Monolog/Handler/LogmaticHandlerTest.php +++ b/tests/Monolog/Handler/LogmaticHandlerTest.php @@ -12,7 +12,8 @@ namespace Monolog\Handler; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; +use PHPUnit\Framework\MockObject\MockObject; /** * @author Julien Breux @@ -24,20 +25,24 @@ class LogmaticHandlerTest extends TestCase */ private $res; - /** - * @var LogmaticHandler - */ - private $handler; + private LogmaticHandler&MockObject $handler; + + public function tearDown(): void + { + parent::tearDown(); + + unset($this->res); + } public function testWriteContent() { $this->createHandler(); - $this->handler->handle($this->getRecord(Logger::CRITICAL, 'Critical write test')); + $this->handler->handle($this->getRecord(Level::Critical, 'Critical write test')); fseek($this->res, 0); $content = fread($this->res, 1024); - $this->assertRegexp('/testToken {"message":"Critical write test","context":{},"level":500,"level_name":"CRITICAL","channel":"test","datetime":"(.*)","extra":{},"hostname":"testHostname","appname":"testAppname","@marker":\["sourcecode","php"\]}/', $content); + $this->assertMatchesRegularExpression('/testToken {"message":"Critical write test","context":{},"level":500,"level_name":"CRITICAL","channel":"test","datetime":"(.*)","extra":{},"hostname":"testHostname","appname":"testAppname","@marker":\["sourcecode","php"\]}/', $content); } public function testWriteBatchContent() @@ -53,17 +58,17 @@ public function testWriteBatchContent() fseek($this->res, 0); $content = fread($this->res, 1024); - $this->assertRegexp('/testToken {"message":"test","context":{},"level":300,"level_name":"WARNING","channel":"test","datetime":"(.*)","extra":{},"hostname":"testHostname","appname":"testAppname","@marker":\["sourcecode","php"\]}/', $content); + $this->assertMatchesRegularExpression('/testToken {"message":"test","context":{},"level":300,"level_name":"WARNING","channel":"test","datetime":"(.*)","extra":{},"hostname":"testHostname","appname":"testAppname","@marker":\["sourcecode","php"\]}/', $content); } private function createHandler() { $useSSL = extension_loaded('openssl'); - $args = ['testToken', 'testHostname', 'testAppname', $useSSL, Logger::DEBUG, true]; + $args = ['testToken', 'testHostname', 'testAppname', $useSSL, Level::Debug, true]; $this->res = fopen('php://memory', 'a'); $this->handler = $this->getMockBuilder('Monolog\Handler\LogmaticHandler') ->setConstructorArgs($args) - ->setMethods(['fsockopen', 'streamSetTimeout', 'closeSocket']) + ->onlyMethods(['fsockopen', 'streamSetTimeout', 'closeSocket']) ->getMock(); $reflectionProperty = new \ReflectionProperty('Monolog\Handler\SocketHandler', 'connectionString'); diff --git a/tests/Monolog/Handler/MailHandlerTest.php b/tests/Monolog/Handler/MailHandlerTest.php index 5a52819c8..3c4a5805a 100644 --- a/tests/Monolog/Handler/MailHandlerTest.php +++ b/tests/Monolog/Handler/MailHandlerTest.php @@ -11,7 +11,7 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; use Monolog\Test\TestCase; class MailHandlerTest extends TestCase @@ -42,15 +42,15 @@ public function testHandleBatch() public function testHandleBatchNotSendsMailIfMessagesAreBelowLevel() { $records = [ - $this->getRecord(Logger::DEBUG, 'debug message 1'), - $this->getRecord(Logger::DEBUG, 'debug message 2'), - $this->getRecord(Logger::INFO, 'information'), + $this->getRecord(Level::Debug, 'debug message 1'), + $this->getRecord(Level::Debug, 'debug message 2'), + $this->getRecord(Level::Info, 'information'), ]; $handler = $this->getMockForAbstractClass('Monolog\\Handler\\MailHandler'); $handler->expects($this->never()) ->method('send'); - $handler->setLevel(Logger::ERROR); + $handler->setLevel(Level::Error); $handler->handleBatch($records); } @@ -65,7 +65,7 @@ public function testHandle() $record = $this->getRecord(); $records = [$record]; - $records[0]['formatted'] = '['.$record['datetime'].'] test.WARNING: test [] []'."\n"; + $records[0]['formatted'] = '['.$record->datetime.'] test.WARNING: test [] []'."\n"; $handler->expects($this->once()) ->method('send') diff --git a/tests/Monolog/Handler/MockRavenClient-gte-0-16-0.php b/tests/Monolog/Handler/MockRavenClient-gte-0-16-0.php deleted file mode 100644 index 07434e499..000000000 --- a/tests/Monolog/Handler/MockRavenClient-gte-0-16-0.php +++ /dev/null @@ -1,27 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Monolog\Handler; - -use Raven_Client; - -class MockRavenClient extends Raven_Client -{ - public function capture($data, $stack = null, $vars = null) - { - $data = array_merge($this->get_user_data(), $data); - $this->lastData = $data; - $this->lastStack = $stack; - } - - public $lastData; - public $lastStack; -} diff --git a/tests/Monolog/Handler/MongoDBHandlerTest.php b/tests/Monolog/Handler/MongoDBHandlerTest.php index 7333ef62a..0a518c717 100644 --- a/tests/Monolog/Handler/MongoDBHandlerTest.php +++ b/tests/Monolog/Handler/MongoDBHandlerTest.php @@ -13,15 +13,13 @@ use MongoDB\Driver\Manager; use Monolog\Test\TestCase; -use Monolog\Formatter\NormalizerFormatter; class MongoDBHandlerTest extends TestCase { - /** - * @expectedException InvalidArgumentException - */ public function testConstructorShouldThrowExceptionForInvalidMongo() { + $this->expectException(\TypeError::class); + new MongoDBHandler(new \stdClass, 'db', 'collection'); } @@ -45,8 +43,8 @@ public function testHandleWithLibraryClient() ->will($this->returnValue($collection)); $record = $this->getRecord(); - $expected = $record; - $expected['datetime'] = $record['datetime']->format(NormalizerFormatter::SIMPLE_DATE); + $expected = $record->toArray(); + $expected['datetime'] = new \MongoDB\BSON\UTCDateTime((int) floor(((float) $record->datetime->format('U.u')) * 1000)); $collection->expects($this->once()) ->method('insertOne') diff --git a/tests/Monolog/Handler/NativeMailerHandlerTest.php b/tests/Monolog/Handler/NativeMailerHandlerTest.php index d4aef95ec..25564e9f1 100644 --- a/tests/Monolog/Handler/NativeMailerHandlerTest.php +++ b/tests/Monolog/Handler/NativeMailerHandlerTest.php @@ -11,9 +11,8 @@ namespace Monolog\Handler; +use Monolog\Level; use Monolog\Test\TestCase; -use Monolog\Logger; -use InvalidArgumentException; function mail($to, $subject, $message, $additional_headers = null, $additional_parameters = null) { @@ -22,51 +21,46 @@ function mail($to, $subject, $message, $additional_headers = null, $additional_p class NativeMailerHandlerTest extends TestCase { - protected function setUp() + protected function setUp(): void { $GLOBALS['mail'] = []; } - /** - * @expectedException InvalidArgumentException - */ public function testConstructorHeaderInjection() { + $this->expectException(\InvalidArgumentException::class); + $mailer = new NativeMailerHandler('spammer@example.org', 'dear victim', "receiver@example.org\r\nFrom: faked@attacker.org"); } - /** - * @expectedException InvalidArgumentException - */ public function testSetterHeaderInjection() { + $this->expectException(\InvalidArgumentException::class); + $mailer = new NativeMailerHandler('spammer@example.org', 'dear victim', 'receiver@example.org'); $mailer->addHeader("Content-Type: text/html\r\nFrom: faked@attacker.org"); } - /** - * @expectedException InvalidArgumentException - */ public function testSetterArrayHeaderInjection() { + $this->expectException(\InvalidArgumentException::class); + $mailer = new NativeMailerHandler('spammer@example.org', 'dear victim', 'receiver@example.org'); $mailer->addHeader(["Content-Type: text/html\r\nFrom: faked@attacker.org"]); } - /** - * @expectedException InvalidArgumentException - */ public function testSetterContentTypeInjection() { + $this->expectException(\InvalidArgumentException::class); + $mailer = new NativeMailerHandler('spammer@example.org', 'dear victim', 'receiver@example.org'); $mailer->setContentType("text/html\r\nFrom: faked@attacker.org"); } - /** - * @expectedException InvalidArgumentException - */ public function testSetterEncodingInjection() { + $this->expectException(\InvalidArgumentException::class); + $mailer = new NativeMailerHandler('spammer@example.org', 'dear victim', 'receiver@example.org'); $mailer->setEncoding("utf-8\r\nFrom: faked@attacker.org"); } @@ -85,9 +79,9 @@ public function testSend() $this->assertEmpty($GLOBALS['mail']); // non-empty batch - $mailer->handle($this->getRecord(Logger::ERROR, "Foo\nBar\r\n\r\nBaz")); + $mailer->handle($this->getRecord(Level::Error, "Foo\nBar\r\n\r\nBaz")); $this->assertNotEmpty($GLOBALS['mail']); - $this->assertInternalType('array', $GLOBALS['mail']); + $this->assertIsArray($GLOBALS['mail']); $this->assertArrayHasKey('0', $GLOBALS['mail']); $params = $GLOBALS['mail'][0]; $this->assertCount(5, $params); @@ -101,9 +95,9 @@ public function testSend() public function testMessageSubjectFormatting() { $mailer = new NativeMailerHandler('to@example.org', 'Alert: %level_name% %message%', 'from@example.org'); - $mailer->handle($this->getRecord(Logger::ERROR, "Foo\nBar\r\n\r\nBaz")); + $mailer->handle($this->getRecord(Level::Error, "Foo\nBar\r\n\r\nBaz")); $this->assertNotEmpty($GLOBALS['mail']); - $this->assertInternalType('array', $GLOBALS['mail']); + $this->assertIsArray($GLOBALS['mail']); $this->assertArrayHasKey('0', $GLOBALS['mail']); $params = $GLOBALS['mail'][0]; $this->assertCount(5, $params); diff --git a/tests/Monolog/Handler/NewRelicHandlerTest.php b/tests/Monolog/Handler/NewRelicHandlerTest.php index a71bbf88c..17d7e833d 100644 --- a/tests/Monolog/Handler/NewRelicHandlerTest.php +++ b/tests/Monolog/Handler/NewRelicHandlerTest.php @@ -13,7 +13,7 @@ use Monolog\Formatter\LineFormatter; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; class NewRelicHandlerTest extends TestCase { @@ -21,40 +21,40 @@ class NewRelicHandlerTest extends TestCase public static $customParameters; public static $transactionName; - public function setUp() + public function setUp(): void { self::$appname = null; self::$customParameters = []; self::$transactionName = null; } - /** - * @expectedException Monolog\Handler\MissingExtensionException - */ public function testThehandlerThrowsAnExceptionIfTheNRExtensionIsNotLoaded() { $handler = new StubNewRelicHandlerWithoutExtension(); - $handler->handle($this->getRecord(Logger::ERROR)); + + $this->expectException(MissingExtensionException::class); + + $handler->handle($this->getRecord(Level::Error)); } public function testThehandlerCanHandleTheRecord() { $handler = new StubNewRelicHandler(); - $handler->handle($this->getRecord(Logger::ERROR)); + $handler->handle($this->getRecord(Level::Error)); } public function testThehandlerCanAddContextParamsToTheNewRelicTrace() { $handler = new StubNewRelicHandler(); - $handler->handle($this->getRecord(Logger::ERROR, 'log message', ['a' => 'b'])); + $handler->handle($this->getRecord(Level::Error, 'log message', ['a' => 'b'])); $this->assertEquals(['context_a' => 'b'], self::$customParameters); } public function testThehandlerCanAddExplodedContextParamsToTheNewRelicTrace() { - $handler = new StubNewRelicHandler(Logger::ERROR, true, self::$appname, true); + $handler = new StubNewRelicHandler(Level::Error, true, self::$appname, true); $handler->handle($this->getRecord( - Logger::ERROR, + Level::Error, 'log message', ['a' => ['key1' => 'value1', 'key2' => 'value2']] )); @@ -66,8 +66,8 @@ public function testThehandlerCanAddExplodedContextParamsToTheNewRelicTrace() public function testThehandlerCanAddExtraParamsToTheNewRelicTrace() { - $record = $this->getRecord(Logger::ERROR, 'log message'); - $record['extra'] = ['c' => 'd']; + $record = $this->getRecord(Level::Error, 'log message'); + $record->extra = ['c' => 'd']; $handler = new StubNewRelicHandler(); $handler->handle($record); @@ -77,10 +77,10 @@ public function testThehandlerCanAddExtraParamsToTheNewRelicTrace() public function testThehandlerCanAddExplodedExtraParamsToTheNewRelicTrace() { - $record = $this->getRecord(Logger::ERROR, 'log message'); - $record['extra'] = ['c' => ['key1' => 'value1', 'key2' => 'value2']]; + $record = $this->getRecord(Level::Error, 'log message'); + $record->extra = ['c' => ['key1' => 'value1', 'key2' => 'value2']]; - $handler = new StubNewRelicHandler(Logger::ERROR, true, self::$appname, true); + $handler = new StubNewRelicHandler(Level::Error, true, self::$appname, true); $handler->handle($record); $this->assertEquals( @@ -91,8 +91,8 @@ public function testThehandlerCanAddExplodedExtraParamsToTheNewRelicTrace() public function testThehandlerCanAddExtraContextAndParamsToTheNewRelicTrace() { - $record = $this->getRecord(Logger::ERROR, 'log message', ['a' => 'b']); - $record['extra'] = ['c' => 'd']; + $record = $this->getRecord(Level::Error, 'log message', ['a' => 'b']); + $record->extra = ['c' => 'd']; $handler = new StubNewRelicHandler(); $handler->handle($record); @@ -109,29 +109,29 @@ public function testThehandlerCanHandleTheRecordsFormattedUsingTheLineFormatter( { $handler = new StubNewRelicHandler(); $handler->setFormatter(new LineFormatter()); - $handler->handle($this->getRecord(Logger::ERROR)); + $handler->handle($this->getRecord(Level::Error)); } public function testTheAppNameIsNullByDefault() { $handler = new StubNewRelicHandler(); - $handler->handle($this->getRecord(Logger::ERROR, 'log message')); + $handler->handle($this->getRecord(Level::Error, 'log message')); $this->assertEquals(null, self::$appname); } public function testTheAppNameCanBeInjectedFromtheConstructor() { - $handler = new StubNewRelicHandler(Logger::DEBUG, false, 'myAppName'); - $handler->handle($this->getRecord(Logger::ERROR, 'log message')); + $handler = new StubNewRelicHandler(Level::Debug, false, 'myAppName'); + $handler->handle($this->getRecord(Level::Error, 'log message')); $this->assertEquals('myAppName', self::$appname); } public function testTheAppNameCanBeOverriddenFromEachLog() { - $handler = new StubNewRelicHandler(Logger::DEBUG, false, 'myAppName'); - $handler->handle($this->getRecord(Logger::ERROR, 'log message', ['appname' => 'logAppName'])); + $handler = new StubNewRelicHandler(Level::Debug, false, 'myAppName'); + $handler->handle($this->getRecord(Level::Error, 'log message', ['appname' => 'logAppName'])); $this->assertEquals('logAppName', self::$appname); } @@ -139,23 +139,23 @@ public function testTheAppNameCanBeOverriddenFromEachLog() public function testTheTransactionNameIsNullByDefault() { $handler = new StubNewRelicHandler(); - $handler->handle($this->getRecord(Logger::ERROR, 'log message')); + $handler->handle($this->getRecord(Level::Error, 'log message')); $this->assertEquals(null, self::$transactionName); } public function testTheTransactionNameCanBeInjectedFromTheConstructor() { - $handler = new StubNewRelicHandler(Logger::DEBUG, false, null, false, 'myTransaction'); - $handler->handle($this->getRecord(Logger::ERROR, 'log message')); + $handler = new StubNewRelicHandler(Level::Debug, false, null, false, 'myTransaction'); + $handler->handle($this->getRecord(Level::Error, 'log message')); $this->assertEquals('myTransaction', self::$transactionName); } public function testTheTransactionNameCanBeOverriddenFromEachLog() { - $handler = new StubNewRelicHandler(Logger::DEBUG, false, null, false, 'myTransaction'); - $handler->handle($this->getRecord(Logger::ERROR, 'log message', ['transaction_name' => 'logTransactName'])); + $handler = new StubNewRelicHandler(Level::Debug, false, null, false, 'myTransaction'); + $handler->handle($this->getRecord(Level::Error, 'log message', ['transaction_name' => 'logTransactName'])); $this->assertEquals('logTransactName', self::$transactionName); } @@ -163,7 +163,7 @@ public function testTheTransactionNameCanBeOverriddenFromEachLog() class StubNewRelicHandlerWithoutExtension extends NewRelicHandler { - protected function isNewRelicEnabled() + protected function isNewRelicEnabled(): bool { return false; } @@ -171,7 +171,7 @@ protected function isNewRelicEnabled() class StubNewRelicHandler extends NewRelicHandler { - protected function isNewRelicEnabled() + protected function isNewRelicEnabled(): bool { return true; } diff --git a/tests/Monolog/Handler/NoopHandlerTest.php b/tests/Monolog/Handler/NoopHandlerTest.php index 768f5e30a..83cb6a2a7 100644 --- a/tests/Monolog/Handler/NoopHandlerTest.php +++ b/tests/Monolog/Handler/NoopHandlerTest.php @@ -11,8 +11,8 @@ namespace Monolog\Handler; +use Monolog\Level; use Monolog\Test\TestCase; -use Monolog\Logger; /** * @covers Monolog\Handler\NoopHandler::handle @@ -22,7 +22,7 @@ class NoopHandlerTest extends TestCase /** * @dataProvider logLevelsProvider */ - public function testIsHandling($level) + public function testIsHandling(Level $level) { $handler = new NoopHandler(); $this->assertTrue($handler->isHandling($this->getRecord($level))); @@ -31,7 +31,7 @@ public function testIsHandling($level) /** * @dataProvider logLevelsProvider */ - public function testHandle($level) + public function testHandle(Level $level) { $handler = new NoopHandler(); $this->assertFalse($handler->handle($this->getRecord($level))); @@ -40,10 +40,8 @@ public function testHandle($level) public function logLevelsProvider() { return array_map( - function ($level) { - return [$level]; - }, - array_values(Logger::getLevels()) + fn ($level) => [$level], + Level::cases() ); } } diff --git a/tests/Monolog/Handler/NullHandlerTest.php b/tests/Monolog/Handler/NullHandlerTest.php index b7e482bac..59434b49c 100644 --- a/tests/Monolog/Handler/NullHandlerTest.php +++ b/tests/Monolog/Handler/NullHandlerTest.php @@ -12,7 +12,7 @@ namespace Monolog\Handler; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; /** * @covers Monolog\Handler\NullHandler::handle @@ -27,7 +27,18 @@ public function testHandle() public function testHandleLowerLevelRecord() { - $handler = new NullHandler(Logger::WARNING); - $this->assertFalse($handler->handle($this->getRecord(Logger::DEBUG))); + $handler = new NullHandler(Level::Warning); + $this->assertFalse($handler->handle($this->getRecord(Level::Debug))); + } + + public function testSerializeRestorePrivate() + { + $handler = new NullHandler(Level::Warning); + self::assertFalse($handler->handle($this->getRecord(Level::Debug))); + self::assertTrue($handler->handle($this->getRecord(Level::Warning))); + + $handler = unserialize(serialize($handler)); + self::assertFalse($handler->handle($this->getRecord(Level::Debug))); + self::assertTrue($handler->handle($this->getRecord(Level::Warning))); } } diff --git a/tests/Monolog/Handler/OverflowHandlerTest.php b/tests/Monolog/Handler/OverflowHandlerTest.php new file mode 100644 index 000000000..e50966f9d --- /dev/null +++ b/tests/Monolog/Handler/OverflowHandlerTest.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Handler; + +use Monolog\Level; +use Monolog\Test\TestCase; + +/** + * @author Kris Buist + * @covers \Monolog\Handler\OverflowHandler + */ +class OverflowHandlerTest extends TestCase +{ + public function testNotPassingRecordsBeneathLogLevel() + { + $testHandler = new TestHandler(); + $handler = new OverflowHandler($testHandler, [], Level::Info); + $handler->handle($this->getRecord(Level::Debug)); + $this->assertFalse($testHandler->hasDebugRecords()); + } + + public function testPassThroughWithoutThreshold() + { + $testHandler = new TestHandler(); + $handler = new OverflowHandler($testHandler, [], Level::Info); + + $handler->handle($this->getRecord(Level::Info, 'Info 1')); + $handler->handle($this->getRecord(Level::Info, 'Info 2')); + $handler->handle($this->getRecord(Level::Warning, 'Warning 1')); + + $this->assertTrue($testHandler->hasInfoThatContains('Info 1')); + $this->assertTrue($testHandler->hasInfoThatContains('Info 2')); + $this->assertTrue($testHandler->hasWarningThatContains('Warning 1')); + } + + /** + * @test + */ + public function testHoldingMessagesBeneathThreshold() + { + $testHandler = new TestHandler(); + $handler = new OverflowHandler($testHandler, [Level::Info->value => 3]); + + $handler->handle($this->getRecord(Level::Debug, 'debug 1')); + $handler->handle($this->getRecord(Level::Debug, 'debug 2')); + + foreach (range(1, 3) as $i) { + $handler->handle($this->getRecord(Level::Info, 'info ' . $i)); + } + + $this->assertTrue($testHandler->hasDebugThatContains('debug 1')); + $this->assertTrue($testHandler->hasDebugThatContains('debug 2')); + $this->assertFalse($testHandler->hasInfoRecords()); + + $handler->handle($this->getRecord(Level::Info, 'info 4')); + + foreach (range(1, 4) as $i) { + $this->assertTrue($testHandler->hasInfoThatContains('info ' . $i)); + } + } + + /** + * @test + */ + public function testCombinedThresholds() + { + $testHandler = new TestHandler(); + $handler = new OverflowHandler($testHandler, [Level::Info->value => 5, Level::Warning->value => 10]); + + $handler->handle($this->getRecord(Level::Debug)); + + foreach (range(1, 5) as $i) { + $handler->handle($this->getRecord(Level::Info, 'info ' . $i)); + } + + foreach (range(1, 10) as $i) { + $handler->handle($this->getRecord(Level::Warning, 'warning ' . $i)); + } + + // Only 1 DEBUG records + $this->assertCount(1, $testHandler->getRecords()); + + $handler->handle($this->getRecord(Level::Info, 'info final')); + + // 1 DEBUG + 5 buffered INFO + 1 new INFO + $this->assertCount(7, $testHandler->getRecords()); + + $handler->handle($this->getRecord(Level::Warning, 'warning final')); + + // 1 DEBUG + 6 INFO + 10 buffered WARNING + 1 new WARNING + $this->assertCount(18, $testHandler->getRecords()); + + $handler->handle($this->getRecord(Level::Info, 'Another info')); + $handler->handle($this->getRecord(Level::Warning, 'Anther warning')); + + // 1 DEBUG + 6 INFO + 11 WARNING + 1 new INFO + 1 new WARNING + $this->assertCount(20, $testHandler->getRecords()); + } +} diff --git a/tests/Monolog/Handler/PHPConsoleHandlerTest.php b/tests/Monolog/Handler/PHPConsoleHandlerTest.php index 0836b9958..3dc3d18e9 100644 --- a/tests/Monolog/Handler/PHPConsoleHandlerTest.php +++ b/tests/Monolog/Handler/PHPConsoleHandlerTest.php @@ -13,13 +13,14 @@ use Exception; use Monolog\ErrorHandler; +use Monolog\Level; use Monolog\Logger; use Monolog\Test\TestCase; use PhpConsole\Connector; use PhpConsole\Dispatcher\Debug as DebugDispatcher; use PhpConsole\Dispatcher\Errors as ErrorDispatcher; use PhpConsole\Handler as VendorPhpConsoleHandler; -use PHPUnit_Framework_MockObject_MockObject; +use PHPUnit\Framework\MockObject\MockObject; /** * @covers Monolog\Handler\PHPConsoleHandler @@ -27,18 +28,23 @@ */ class PHPConsoleHandlerTest extends TestCase { - /** @var Connector|PHPUnit_Framework_MockObject_MockObject */ - protected $connector; - /** @var DebugDispatcher|PHPUnit_Framework_MockObject_MockObject */ - protected $debugDispatcher; - /** @var ErrorDispatcher|PHPUnit_Framework_MockObject_MockObject */ - protected $errorDispatcher; - - protected function setUp() + protected Connector&MockObject $connector; + protected DebugDispatcher&MockObject $debugDispatcher; + protected ErrorDispatcher&MockObject $errorDispatcher; + + protected function setUp(): void { + // suppress warnings until https://github.com/barbushin/php-console/pull/173 is merged + $previous = error_reporting(0); if (!class_exists('PhpConsole\Connector')) { + error_reporting($previous); + $this->markTestSkipped('PHP Console library not found. See https://github.com/barbushin/php-console#installation'); + } + if (!class_exists('PhpConsole\Handler')) { + error_reporting($previous); $this->markTestSkipped('PHP Console library not found. See https://github.com/barbushin/php-console#installation'); } + error_reporting($previous); $this->connector = $this->initConnectorMock(); $this->debugDispatcher = $this->initDebugDispatcherMock($this->connector); @@ -48,11 +54,18 @@ protected function setUp() $this->connector->setErrorsDispatcher($this->errorDispatcher); } + public function tearDown(): void + { + parent::tearDown(); + + unset($this->connector, $this->debugDispatcher, $this->errorDispatcher); + } + protected function initDebugDispatcherMock(Connector $connector) { return $this->getMockBuilder('PhpConsole\Dispatcher\Debug') ->disableOriginalConstructor() - ->setMethods(['dispatchDebug']) + ->onlyMethods(['dispatchDebug']) ->setConstructorArgs([$connector, $connector->getDumper()]) ->getMock(); } @@ -61,7 +74,7 @@ protected function initErrorDispatcherMock(Connector $connector) { return $this->getMockBuilder('PhpConsole\Dispatcher\Errors') ->disableOriginalConstructor() - ->setMethods(['dispatchError', 'dispatchException']) + ->onlyMethods(['dispatchError', 'dispatchException']) ->setConstructorArgs([$connector, $connector->getDumper()]) ->getMock(); } @@ -70,7 +83,7 @@ protected function initConnectorMock() { $connector = $this->getMockBuilder('PhpConsole\Connector') ->disableOriginalConstructor() - ->setMethods([ + ->onlyMethods([ 'sendMessage', 'onShutDown', 'isActiveClient', @@ -99,7 +112,7 @@ protected function getHandlerDefaultOption($name) return $options[$name]; } - protected function initLogger($handlerOptions = [], $level = Logger::DEBUG) + protected function initLogger($handlerOptions = [], $level = Level::Debug) { return new Logger('test', [ new PHPConsoleHandler($handlerOptions, $this->connector, $level), @@ -170,7 +183,8 @@ public function testError($classesPartialsTraceIgnore = null) ); $errorHandler = ErrorHandler::register($this->initLogger($classesPartialsTraceIgnore ? ['classesPartialsTraceIgnore' => $classesPartialsTraceIgnore] : []), false); $errorHandler->registerErrorHandler([], false, E_USER_WARNING); - $errorHandler->handleError($code, $message, $file, $line); + $reflMethod = new \ReflectionMethod($errorHandler, 'handleError'); + $reflMethod->invoke($errorHandler, $code, $message, $file, $line); } public function testException() @@ -187,11 +201,10 @@ public function testException() ); } - /** - * @expectedException Exception - */ public function testWrongOptionsThrowsException() { + $this->expectException(\Exception::class); + new PHPConsoleHandler(['xxx' => 1]); } diff --git a/tests/Monolog/Handler/ProcessHandlerTest.php b/tests/Monolog/Handler/ProcessHandlerTest.php index c78d55902..38eb31953 100644 --- a/tests/Monolog/Handler/ProcessHandlerTest.php +++ b/tests/Monolog/Handler/ProcessHandlerTest.php @@ -12,7 +12,7 @@ namespace Monolog\Handler; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; class ProcessHandlerTest extends TestCase { @@ -40,7 +40,7 @@ public function testWriteOpensProcessAndWritesToStdInOfProcess() ]; $mockBuilder = $this->getMockBuilder('Monolog\Handler\ProcessHandler'); - $mockBuilder->setMethods(['writeProcessInput']); + $mockBuilder->onlyMethods(['writeProcessInput']); // using echo as command, as it is most probably available $mockBuilder->setConstructorArgs([self::DUMMY_COMMAND]); @@ -48,19 +48,17 @@ public function testWriteOpensProcessAndWritesToStdInOfProcess() $handler->expects($this->exactly(2)) ->method('writeProcessInput') - ->withConsecutive($this->stringContains($fixtures[0]), $this->stringContains($fixtures[1])); + ->withConsecutive([$this->stringContains($fixtures[0])], [$this->stringContains($fixtures[1])]); /** @var ProcessHandler $handler */ - $handler->handle($this->getRecord(Logger::WARNING, $fixtures[0])); - $handler->handle($this->getRecord(Logger::ERROR, $fixtures[1])); + $handler->handle($this->getRecord(Level::Warning, $fixtures[0])); + $handler->handle($this->getRecord(Level::Error, $fixtures[1])); } /** * Data provider for invalid commands. - * - * @return array */ - public function invalidCommandProvider() + public function invalidCommandProvider(): array { return [ [1337, 'TypeError'], @@ -78,15 +76,13 @@ public function invalidCommandProvider() public function testConstructWithInvalidCommandThrowsInvalidArgumentException($invalidCommand, $expectedExcep) { $this->expectException($expectedExcep); - new ProcessHandler($invalidCommand, Logger::DEBUG); + new ProcessHandler($invalidCommand, Level::Debug); } /** * Data provider for invalid CWDs. - * - * @return array */ - public function invalidCwdProvider() + public function invalidCwdProvider(): array { return [ [1337, 'TypeError'], @@ -103,7 +99,7 @@ public function invalidCwdProvider() public function testConstructWithInvalidCwdThrowsInvalidArgumentException($invalidCwd, $expectedExcep) { $this->expectException($expectedExcep); - new ProcessHandler(self::DUMMY_COMMAND, Logger::DEBUG, true, $invalidCwd); + new ProcessHandler(self::DUMMY_COMMAND, Level::Debug, true, $invalidCwd); } /** @@ -112,7 +108,7 @@ public function testConstructWithInvalidCwdThrowsInvalidArgumentException($inval */ public function testConstructWithValidCwdWorks() { - $handler = new ProcessHandler(self::DUMMY_COMMAND, Logger::DEBUG, true, sys_get_temp_dir()); + $handler = new ProcessHandler(self::DUMMY_COMMAND, Level::Debug, true, sys_get_temp_dir()); $this->assertInstanceOf( 'Monolog\Handler\ProcessHandler', $handler, @@ -126,7 +122,7 @@ public function testConstructWithValidCwdWorks() public function testStartupWithFailingToSelectErrorStreamThrowsUnexpectedValueException() { $mockBuilder = $this->getMockBuilder('Monolog\Handler\ProcessHandler'); - $mockBuilder->setMethods(['selectErrorStream']); + $mockBuilder->onlyMethods(['selectErrorStream']); $mockBuilder->setConstructorArgs([self::DUMMY_COMMAND]); $handler = $mockBuilder->getMock(); @@ -135,9 +131,9 @@ public function testStartupWithFailingToSelectErrorStreamThrowsUnexpectedValueEx ->method('selectErrorStream') ->will($this->returnValue(false)); - $this->expectException('\UnexpectedValueException'); + $this->expectException(\UnexpectedValueException::class); /** @var ProcessHandler $handler */ - $handler->handle($this->getRecord(Logger::WARNING, 'stream failing, whoops')); + $handler->handle($this->getRecord(Level::Warning, 'stream failing, whoops')); } /** @@ -147,8 +143,10 @@ public function testStartupWithFailingToSelectErrorStreamThrowsUnexpectedValueEx public function testStartupWithErrorsThrowsUnexpectedValueException() { $handler = new ProcessHandler('>&2 echo "some fake error message"'); - $this->expectException('\UnexpectedValueException'); - $handler->handle($this->getRecord(Logger::WARNING, 'some warning in the house')); + + $this->expectException(\UnexpectedValueException::class); + + $handler->handle($this->getRecord(Level::Warning, 'some warning in the house')); } /** @@ -157,7 +155,7 @@ public function testStartupWithErrorsThrowsUnexpectedValueException() public function testWritingWithErrorsOnStdOutOfProcessThrowsInvalidArgumentException() { $mockBuilder = $this->getMockBuilder('Monolog\Handler\ProcessHandler'); - $mockBuilder->setMethods(['readProcessErrors']); + $mockBuilder->onlyMethods(['readProcessErrors']); // using echo as command, as it is most probably available $mockBuilder->setConstructorArgs([self::DUMMY_COMMAND]); @@ -167,9 +165,9 @@ public function testWritingWithErrorsOnStdOutOfProcessThrowsInvalidArgumentExcep ->method('readProcessErrors') ->willReturnOnConsecutiveCalls('', $this->returnValue('some fake error message here')); - $this->expectException('\UnexpectedValueException'); + $this->expectException(\UnexpectedValueException::class); /** @var ProcessHandler $handler */ - $handler->handle($this->getRecord(Logger::WARNING, 'some test stuff')); + $handler->handle($this->getRecord(Level::Warning, 'some test stuff')); } /** @@ -182,7 +180,7 @@ public function testCloseClosesProcess() $property->setAccessible(true); $handler = new ProcessHandler(self::DUMMY_COMMAND); - $handler->handle($this->getRecord(Logger::WARNING, '21 is only the half truth')); + $handler->handle($this->getRecord(Level::Warning, '21 is only the half truth')); $process = $property->getValue($handler); $this->assertTrue(is_resource($process), 'Process is not running although it should.'); diff --git a/tests/Monolog/Handler/PsrHandlerTest.php b/tests/Monolog/Handler/PsrHandlerTest.php index e371512d9..73d4d923d 100644 --- a/tests/Monolog/Handler/PsrHandlerTest.php +++ b/tests/Monolog/Handler/PsrHandlerTest.php @@ -11,8 +11,9 @@ namespace Monolog\Handler; +use Monolog\Level; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Formatter\LineFormatter; /** * @covers Monolog\Handler\PsrHandler::handle @@ -21,30 +22,42 @@ class PsrHandlerTest extends TestCase { public function logLevelProvider() { - $levels = []; - $monologLogger = new Logger(''); - - foreach ($monologLogger->getLevels() as $levelName => $level) { - $levels[] = [$levelName, $level]; - } - - return $levels; + return array_map( + fn (Level $level) => [$level->toPsrLogLevel(), $level], + Level::cases() + ); } /** * @dataProvider logLevelProvider */ - public function testHandlesAllLevels($levelName, $level) + public function testHandlesAllLevels(string $levelName, Level $level) + { + $message = 'Hello, world! ' . $level->value; + $context = ['foo' => 'bar', 'level' => $level->value]; + + $psrLogger = $this->createMock('Psr\Log\NullLogger'); + $psrLogger->expects($this->once()) + ->method('log') + ->with($levelName, $message, $context); + + $handler = new PsrHandler($psrLogger); + $handler->handle($this->getRecord($level, $message, context: $context)); + } + + public function testFormatter() { - $message = 'Hello, world! ' . $level; - $context = ['foo' => 'bar', 'level' => $level]; + $message = 'Hello, world!'; + $context = ['foo' => 'bar']; + $level = Level::Error; $psrLogger = $this->createMock('Psr\Log\NullLogger'); $psrLogger->expects($this->once()) ->method('log') - ->with(strtolower($levelName), $message, $context); + ->with($level->toPsrLogLevel(), 'dummy', $context); $handler = new PsrHandler($psrLogger); - $handler->handle(['level' => $level, 'level_name' => $levelName, 'message' => $message, 'context' => $context]); + $handler->setFormatter(new LineFormatter('dummy')); + $handler->handle($this->getRecord($level, $message, context: $context, datetime: new \DateTimeImmutable())); } } diff --git a/tests/Monolog/Handler/PushoverHandlerTest.php b/tests/Monolog/Handler/PushoverHandlerTest.php index 6a295c92e..4c3c9481f 100644 --- a/tests/Monolog/Handler/PushoverHandlerTest.php +++ b/tests/Monolog/Handler/PushoverHandlerTest.php @@ -12,7 +12,8 @@ namespace Monolog\Handler; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; +use PHPUnit\Framework\MockObject\MockObject; /** * Almost all examples (expected header, titles, messages) taken from @@ -22,18 +23,26 @@ */ class PushoverHandlerTest extends TestCase { + /** @var resource */ private $res; - private $handler; + private PushoverHandler&MockObject $handler; + + public function tearDown(): void + { + parent::tearDown(); + + unset($this->res); + } public function testWriteHeader() { $this->createHandler(); - $this->handler->setHighPriorityLevel(Logger::EMERGENCY); // skip priority notifications - $this->handler->handle($this->getRecord(Logger::CRITICAL, 'test1')); + $this->handler->setHighPriorityLevel(Level::Emergency); // skip priority notifications + $this->handler->handle($this->getRecord(Level::Critical, 'test1')); fseek($this->res, 0); $content = fread($this->res, 1024); - $this->assertRegexp('/POST \/1\/messages.json HTTP\/1.1\\r\\nHost: api.pushover.net\\r\\nContent-Type: application\/x-www-form-urlencoded\\r\\nContent-Length: \d{2,4}\\r\\n\\r\\n/', $content); + $this->assertMatchesRegularExpression('/POST \/1\/messages.json HTTP\/1.1\\r\\nHost: api.pushover.net\\r\\nContent-Type: application\/x-www-form-urlencoded\\r\\nContent-Length: \d{2,4}\\r\\n\\r\\n/', $content); return $content; } @@ -43,82 +52,82 @@ public function testWriteHeader() */ public function testWriteContent($content) { - $this->assertRegexp('/token=myToken&user=myUser&message=test1&title=Monolog×tamp=\d{10}$/', $content); + $this->assertMatchesRegularExpression('/token=myToken&user=myUser&message=test1&title=Monolog×tamp=\d{10}$/', $content); } public function testWriteWithComplexTitle() { $this->createHandler('myToken', 'myUser', 'Backup finished - SQL1'); - $this->handler->handle($this->getRecord(Logger::CRITICAL, 'test1')); + $this->handler->handle($this->getRecord(Level::Critical, 'test1')); fseek($this->res, 0); $content = fread($this->res, 1024); - $this->assertRegexp('/title=Backup\+finished\+-\+SQL1/', $content); + $this->assertMatchesRegularExpression('/title=Backup\+finished\+-\+SQL1/', $content); } public function testWriteWithComplexMessage() { $this->createHandler(); - $this->handler->setHighPriorityLevel(Logger::EMERGENCY); // skip priority notifications - $this->handler->handle($this->getRecord(Logger::CRITICAL, 'Backup of database "example" finished in 16 minutes.')); + $this->handler->setHighPriorityLevel(Level::Emergency); // skip priority notifications + $this->handler->handle($this->getRecord(Level::Critical, 'Backup of database "example" finished in 16 minutes.')); fseek($this->res, 0); $content = fread($this->res, 1024); - $this->assertRegexp('/message=Backup\+of\+database\+%22example%22\+finished\+in\+16\+minutes\./', $content); + $this->assertMatchesRegularExpression('/message=Backup\+of\+database\+%22example%22\+finished\+in\+16\+minutes\./', $content); } public function testWriteWithTooLongMessage() { $message = str_pad('test', 520, 'a'); $this->createHandler(); - $this->handler->setHighPriorityLevel(Logger::EMERGENCY); // skip priority notifications - $this->handler->handle($this->getRecord(Logger::CRITICAL, $message)); + $this->handler->setHighPriorityLevel(Level::Emergency); // skip priority notifications + $this->handler->handle($this->getRecord(Level::Critical, $message)); fseek($this->res, 0); $content = fread($this->res, 1024); $expectedMessage = substr($message, 0, 505); - $this->assertRegexp('/message=' . $expectedMessage . '&title/', $content); + $this->assertMatchesRegularExpression('/message=' . $expectedMessage . '&title/', $content); } public function testWriteWithHighPriority() { $this->createHandler(); - $this->handler->handle($this->getRecord(Logger::CRITICAL, 'test1')); + $this->handler->handle($this->getRecord(Level::Critical, 'test1')); fseek($this->res, 0); $content = fread($this->res, 1024); - $this->assertRegexp('/token=myToken&user=myUser&message=test1&title=Monolog×tamp=\d{10}&priority=1$/', $content); + $this->assertMatchesRegularExpression('/token=myToken&user=myUser&message=test1&title=Monolog×tamp=\d{10}&priority=1$/', $content); } public function testWriteWithEmergencyPriority() { $this->createHandler(); - $this->handler->handle($this->getRecord(Logger::EMERGENCY, 'test1')); + $this->handler->handle($this->getRecord(Level::Emergency, 'test1')); fseek($this->res, 0); $content = fread($this->res, 1024); - $this->assertRegexp('/token=myToken&user=myUser&message=test1&title=Monolog×tamp=\d{10}&priority=2&retry=30&expire=25200$/', $content); + $this->assertMatchesRegularExpression('/token=myToken&user=myUser&message=test1&title=Monolog×tamp=\d{10}&priority=2&retry=30&expire=25200$/', $content); } public function testWriteToMultipleUsers() { $this->createHandler('myToken', ['userA', 'userB']); - $this->handler->handle($this->getRecord(Logger::EMERGENCY, 'test1')); + $this->handler->handle($this->getRecord(Level::Emergency, 'test1')); fseek($this->res, 0); $content = fread($this->res, 1024); - $this->assertRegexp('/token=myToken&user=userA&message=test1&title=Monolog×tamp=\d{10}&priority=2&retry=30&expire=25200POST/', $content); - $this->assertRegexp('/token=myToken&user=userB&message=test1&title=Monolog×tamp=\d{10}&priority=2&retry=30&expire=25200$/', $content); + $this->assertMatchesRegularExpression('/token=myToken&user=userA&message=test1&title=Monolog×tamp=\d{10}&priority=2&retry=30&expire=25200POST/', $content); + $this->assertMatchesRegularExpression('/token=myToken&user=userB&message=test1&title=Monolog×tamp=\d{10}&priority=2&retry=30&expire=25200$/', $content); } private function createHandler($token = 'myToken', $user = 'myUser', $title = 'Monolog') { $constructorArgs = [$token, $user, $title]; $this->res = fopen('php://memory', 'a'); - $this->handler = $this->getMockBuilder('Monolog\Handler\PushoverHandler') + $this->handler = $this->getMockBuilder(PushoverHandler::class) ->setConstructorArgs($constructorArgs) - ->setMethods(['fsockopen', 'streamSetTimeout', 'closeSocket']) + ->onlyMethods(['fsockopen', 'streamSetTimeout', 'closeSocket']) ->getMock(); $reflectionProperty = new \ReflectionProperty('Monolog\Handler\SocketHandler', 'connectionString'); diff --git a/tests/Monolog/Handler/RavenHandlerTest.php b/tests/Monolog/Handler/RavenHandlerTest.php deleted file mode 100644 index 082042f0d..000000000 --- a/tests/Monolog/Handler/RavenHandlerTest.php +++ /dev/null @@ -1,263 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Monolog\Handler; - -use Monolog\Test\TestCase; -use Monolog\Logger; -use Monolog\Formatter\LineFormatter; -use Raven_Client; - -class RavenHandlerTest extends TestCase -{ - public function setUp() - { - if (!class_exists('Raven_Client')) { - $this->markTestSkipped('sentry/sentry not installed'); - } - - if (version_compare(Raven_Client::VERSION, '0.16.0', '>=')) { - require_once __DIR__ . '/MockRavenClient-gte-0-16-0.php'; - } else { - require_once __DIR__ . '/MockRavenClient.php'; - } - } - - /** - * @covers Monolog\Handler\RavenHandler::__construct - */ - public function testConstruct() - { - $handler = new RavenHandler($this->getRavenClient()); - $this->assertInstanceOf('Monolog\Handler\RavenHandler', $handler); - } - - protected function getHandler($ravenClient) - { - $handler = new RavenHandler($ravenClient); - - return $handler; - } - - protected function getRavenClient() - { - $dsn = 'http://43f6017361224d098402974103bfc53d:a6a0538fc2934ba2bed32e08741b2cd3@marca.python.live.cheggnet.com:9000/1'; - - return new MockRavenClient($dsn); - } - - public function testDebug() - { - $ravenClient = $this->getRavenClient(); - $handler = $this->getHandler($ravenClient); - - $record = $this->getRecord(Logger::DEBUG, 'A test debug message'); - $handler->handle($record); - - $this->assertEquals($ravenClient::DEBUG, $ravenClient->lastData['level']); - $this->assertContains($record['message'], $ravenClient->lastData['message']); - } - - public function testWarning() - { - $ravenClient = $this->getRavenClient(); - $handler = $this->getHandler($ravenClient); - - $record = $this->getRecord(Logger::WARNING, 'A test warning message'); - $handler->handle($record); - - $this->assertEquals($ravenClient::WARNING, $ravenClient->lastData['level']); - $this->assertContains($record['message'], $ravenClient->lastData['message']); - } - - public function testTag() - { - $ravenClient = $this->getRavenClient(); - $handler = $this->getHandler($ravenClient); - - $tags = [1, 2, 'foo']; - $record = $this->getRecord(Logger::INFO, 'test', ['tags' => $tags]); - $handler->handle($record); - - $this->assertEquals($tags, $ravenClient->lastData['tags']); - } - - public function testExtraParameters() - { - $ravenClient = $this->getRavenClient(); - $handler = $this->getHandler($ravenClient); - - $checksum = '098f6bcd4621d373cade4e832627b4f6'; - $release = '05a671c66aefea124cc08b76ea6d30bb'; - $eventId = '31423'; - $record = $this->getRecord(Logger::INFO, 'test', ['checksum' => $checksum, 'release' => $release, 'event_id' => $eventId]); - $handler->handle($record); - - $this->assertEquals($checksum, $ravenClient->lastData['checksum']); - $this->assertEquals($release, $ravenClient->lastData['release']); - $this->assertEquals($eventId, $ravenClient->lastData['event_id']); - } - - public function testFingerprint() - { - $ravenClient = $this->getRavenClient(); - $handler = $this->getHandler($ravenClient); - - $fingerprint = ['{{ default }}', 'other value']; - $record = $this->getRecord(Logger::INFO, 'test', ['fingerprint' => $fingerprint]); - $handler->handle($record); - - $this->assertEquals($fingerprint, $ravenClient->lastData['fingerprint']); - } - - public function testUserContext() - { - $ravenClient = $this->getRavenClient(); - $handler = $this->getHandler($ravenClient); - - $recordWithNoContext = $this->getRecord(Logger::INFO, 'test with default user context'); - // set user context 'externally' - - $user = [ - 'id' => '123', - 'email' => 'test@test.com', - ]; - - $recordWithContext = $this->getRecord(Logger::INFO, 'test', ['user' => $user]); - - $ravenClient->user_context(['id' => 'test_user_id']); - // handle context - $handler->handle($recordWithContext); - $this->assertEquals($user, $ravenClient->lastData['user']); - - // check to see if its reset - $handler->handle($recordWithNoContext); - $this->assertInternalType('array', $ravenClient->context->user); - $this->assertSame('test_user_id', $ravenClient->context->user['id']); - - // handle with null context - $ravenClient->user_context(null); - $handler->handle($recordWithContext); - $this->assertEquals($user, $ravenClient->lastData['user']); - - // check to see if its reset - $handler->handle($recordWithNoContext); - $this->assertNull($ravenClient->context->user); - } - - public function testException() - { - $ravenClient = $this->getRavenClient(); - $handler = $this->getHandler($ravenClient); - - try { - $this->methodThatThrowsAnException(); - } catch (\Exception $e) { - $record = $this->getRecord(Logger::ERROR, $e->getMessage(), ['exception' => $e]); - $handler->handle($record); - } - - $this->assertEquals($record['message'], $ravenClient->lastData['message']); - } - - public function testHandleBatch() - { - $records = $this->getMultipleRecords(); - $records[] = $this->getRecord(Logger::WARNING, 'warning'); - $records[] = $this->getRecord(Logger::WARNING, 'warning'); - - $logFormatter = $this->createMock('Monolog\\Formatter\\FormatterInterface'); - $logFormatter->expects($this->once())->method('formatBatch'); - - $formatter = $this->createMock('Monolog\\Formatter\\FormatterInterface'); - $formatter->expects($this->once())->method('format')->with($this->callback(function ($record) { - return $record['level'] == 400; - })); - - $handler = $this->getHandler($this->getRavenClient()); - $handler->setBatchFormatter($logFormatter); - $handler->setFormatter($formatter); - $handler->handleBatch($records); - } - - public function testHandleBatchDoNothingIfRecordsAreBelowLevel() - { - $records = [ - $this->getRecord(Logger::DEBUG, 'debug message 1'), - $this->getRecord(Logger::DEBUG, 'debug message 2'), - $this->getRecord(Logger::INFO, 'information'), - ]; - - $handler = $this->getMockBuilder('Monolog\Handler\RavenHandler') - ->setMethods(['handle']) - ->setConstructorArgs([$this->getRavenClient()]) - ->getMock(); - $handler->expects($this->never())->method('handle'); - $handler->setLevel(Logger::ERROR); - $handler->handleBatch($records); - } - - public function testHandleBatchPicksProperMessage() - { - $records = array( - $this->getRecord(Logger::DEBUG, 'debug message 1'), - $this->getRecord(Logger::DEBUG, 'debug message 2'), - $this->getRecord(Logger::INFO, 'information 1'), - $this->getRecord(Logger::ERROR, 'error 1'), - $this->getRecord(Logger::WARNING, 'warning'), - $this->getRecord(Logger::ERROR, 'error 2'), - $this->getRecord(Logger::INFO, 'information 2'), - ); - - $logFormatter = $this->createMock('Monolog\\Formatter\\FormatterInterface'); - $logFormatter->expects($this->once())->method('formatBatch'); - - $formatter = $this->createMock('Monolog\\Formatter\\FormatterInterface'); - $formatter->expects($this->once())->method('format')->with($this->callback(function ($record) use ($records) { - return $record['message'] == 'error 1'; - })); - - $handler = $this->getHandler($this->getRavenClient()); - $handler->setBatchFormatter($logFormatter); - $handler->setFormatter($formatter); - $handler->handleBatch($records); - } - - public function testGetSetBatchFormatter() - { - $ravenClient = $this->getRavenClient(); - $handler = $this->getHandler($ravenClient); - - $handler->setBatchFormatter($formatter = new LineFormatter()); - $this->assertSame($formatter, $handler->getBatchFormatter()); - } - - public function testRelease() - { - $ravenClient = $this->getRavenClient(); - $handler = $this->getHandler($ravenClient); - $release = 'v42.42.42'; - $handler->setRelease($release); - $record = $this->getRecord(Logger::INFO, 'test'); - $handler->handle($record); - $this->assertEquals($release, $ravenClient->lastData['release']); - - $localRelease = 'v41.41.41'; - $record = $this->getRecord(Logger::INFO, 'test', ['release' => $localRelease]); - $handler->handle($record); - $this->assertEquals($localRelease, $ravenClient->lastData['release']); - } - - private function methodThatThrowsAnException() - { - throw new \Exception('This is an exception'); - } -} diff --git a/tests/Monolog/Handler/RedisHandlerTest.php b/tests/Monolog/Handler/RedisHandlerTest.php index a0260e1d8..806919e69 100644 --- a/tests/Monolog/Handler/RedisHandlerTest.php +++ b/tests/Monolog/Handler/RedisHandlerTest.php @@ -12,19 +12,11 @@ namespace Monolog\Handler; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; use Monolog\Formatter\LineFormatter; class RedisHandlerTest extends TestCase { - /** - * @expectedException InvalidArgumentException - */ - public function testConstructorShouldThrowExceptionForInvalidRedis() - { - new RedisHandler(new \stdClass(), 'key'); - } - public function testConstructorShouldWorkWithPredis() { $redis = $this->createMock('Predis\Client'); @@ -43,14 +35,12 @@ public function testConstructorShouldWorkWithRedis() public function testPredisHandle() { - $redis = $this->createPartialMock('Predis\Client', ['rpush']); - - // Predis\Client uses rpush - $redis->expects($this->once()) - ->method('rpush') - ->with('key', 'test'); + $redis = $this->getMockBuilder('Predis\Client')->getMock(); + $redis->expects($this->atLeastOnce()) + ->method('__call') + ->with(self::equalTo('rpush'), self::equalTo(['key', 'test'])); - $record = $this->getRecord(Logger::WARNING, 'test', ['data' => new \stdClass, 'foo' => 34]); + $record = $this->getRecord(Level::Warning, 'test', ['data' => new \stdClass, 'foo' => 34]); $handler = new RedisHandler($redis, 'key'); $handler->setFormatter(new LineFormatter("%message%")); @@ -63,14 +53,14 @@ public function testRedisHandle() $this->markTestSkipped('The redis ext is required to run this test'); } - $redis = $this->createPartialMock('Redis', ['rpush']); + $redis = $this->createPartialMock('Redis', ['rPush']); // Redis uses rPush $redis->expects($this->once()) - ->method('rpush') + ->method('rPush') ->with('key', 'test'); - $record = $this->getRecord(Logger::WARNING, 'test', ['data' => new \stdClass, 'foo' => 34]); + $record = $this->getRecord(Level::Warning, 'test', ['data' => new \stdClass, 'foo' => 34]); $handler = new RedisHandler($redis, 'key'); $handler->setFormatter(new LineFormatter("%message%")); @@ -83,7 +73,7 @@ public function testRedisHandleCapped() $this->markTestSkipped('The redis ext is required to run this test'); } - $redis = $this->createPartialMock('Redis', ['multi', 'rpush', 'ltrim', 'exec']); + $redis = $this->createPartialMock('Redis', ['multi', 'rPush', 'lTrim', 'exec']); // Redis uses multi $redis->expects($this->once()) @@ -91,20 +81,20 @@ public function testRedisHandleCapped() ->will($this->returnSelf()); $redis->expects($this->once()) - ->method('rpush') + ->method('rPush') ->will($this->returnSelf()); $redis->expects($this->once()) - ->method('ltrim') + ->method('lTrim') ->will($this->returnSelf()); $redis->expects($this->once()) ->method('exec') ->will($this->returnSelf()); - $record = $this->getRecord(Logger::WARNING, 'test', ['data' => new \stdClass, 'foo' => 34]); + $record = $this->getRecord(Level::Warning, 'test', ['data' => new \stdClass, 'foo' => 34]); - $handler = new RedisHandler($redis, 'key', Logger::DEBUG, true, 10); + $handler = new RedisHandler($redis, 'key', Level::Debug, true, 10); $handler->setFormatter(new LineFormatter("%message%")); $handler->handle($record); } @@ -113,14 +103,17 @@ public function testPredisHandleCapped() { $redis = $this->createPartialMock('Predis\Client', ['transaction']); - $redisTransaction = $this->createPartialMock('Predis\Client', ['rpush', 'ltrim']); + $redisTransaction = $this->getMockBuilder('Predis\Client') + ->disableOriginalConstructor() + ->addMethods(['rPush', 'lTrim']) + ->getMock(); $redisTransaction->expects($this->once()) - ->method('rpush') + ->method('rPush') ->will($this->returnSelf()); $redisTransaction->expects($this->once()) - ->method('ltrim') + ->method('lTrim') ->will($this->returnSelf()); // Redis uses multi @@ -130,9 +123,9 @@ public function testPredisHandleCapped() $cb($redisTransaction); })); - $record = $this->getRecord(Logger::WARNING, 'test', ['data' => new \stdClass, 'foo' => 34]); + $record = $this->getRecord(Level::Warning, 'test', ['data' => new \stdClass, 'foo' => 34]); - $handler = new RedisHandler($redis, 'key', Logger::DEBUG, true, 10); + $handler = new RedisHandler($redis, 'key', Level::Debug, true, 10); $handler->setFormatter(new LineFormatter("%message%")); $handler->handle($record); } diff --git a/tests/Monolog/Handler/RedisPubSubHandlerTest.php b/tests/Monolog/Handler/RedisPubSubHandlerTest.php new file mode 100644 index 000000000..1dea351fa --- /dev/null +++ b/tests/Monolog/Handler/RedisPubSubHandlerTest.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Handler; + +use Monolog\Test\TestCase; +use Monolog\Level; +use Monolog\Formatter\LineFormatter; + +class RedisPubSubHandlerTest extends TestCase +{ + public function testConstructorShouldWorkWithPredis() + { + $redis = $this->createMock('Predis\Client'); + $this->assertInstanceof('Monolog\Handler\RedisPubSubHandler', new RedisPubSubHandler($redis, 'key')); + } + + public function testConstructorShouldWorkWithRedis() + { + if (!class_exists('Redis')) { + $this->markTestSkipped('The redis ext is required to run this test'); + } + + $redis = $this->createMock('Redis'); + $this->assertInstanceof('Monolog\Handler\RedisPubSubHandler', new RedisPubSubHandler($redis, 'key')); + } + + public function testPredisHandle() + { + $redis = $this->getMockBuilder('Predis\Client')->getMock(); + $redis->expects($this->atLeastOnce()) + ->method('__call') + ->with(self::equalTo('publish'), self::equalTo(['key', 'test'])); + + $record = $this->getRecord(Level::Warning, 'test', ['data' => new \stdClass(), 'foo' => 34]); + + $handler = new RedisPubSubHandler($redis, 'key'); + $handler->setFormatter(new LineFormatter("%message%")); + $handler->handle($record); + } + + public function testRedisHandle() + { + if (!class_exists('Redis')) { + $this->markTestSkipped('The redis ext is required to run this test'); + } + + $redis = $this->createPartialMock('Redis', ['publish']); + + $redis->expects($this->once()) + ->method('publish') + ->with('key', 'test'); + + $record = $this->getRecord(Level::Warning, 'test', ['data' => new \stdClass(), 'foo' => 34]); + + $handler = new RedisPubSubHandler($redis, 'key'); + $handler->setFormatter(new LineFormatter("%message%")); + $handler->handle($record); + } +} diff --git a/tests/Monolog/Handler/RollbarHandlerTest.php b/tests/Monolog/Handler/RollbarHandlerTest.php index 67a0eb683..03eda4cf6 100644 --- a/tests/Monolog/Handler/RollbarHandlerTest.php +++ b/tests/Monolog/Handler/RollbarHandlerTest.php @@ -13,8 +13,8 @@ use Exception; use Monolog\Test\TestCase; -use Monolog\Logger; -use PHPUnit_Framework_MockObject_MockObject as MockObject; +use Monolog\Level; +use PHPUnit\Framework\MockObject\MockObject; use Rollbar\RollbarLogger; /** @@ -22,26 +22,29 @@ * @see https://rollbar.com/docs/notifier/rollbar-php/ * * @coversDefaultClass Monolog\Handler\RollbarHandler + * + * @requires function \Rollbar\RollbarLogger::__construct */ class RollbarHandlerTest extends TestCase { - /** - * @var MockObject - */ - private $rollbarLogger; + private RollbarLogger&MockObject $rollbarLogger; - /** - * @var array - */ - private $reportedExceptionArguments = null; + private array $reportedExceptionArguments; - protected function setUp() + protected function setUp(): void { parent::setUp(); $this->setupRollbarLoggerMock(); } + public function tearDown(): void + { + parent::tearDown(); + + unset($this->rollbarLogger, $this->reportedExceptionArguments); + } + /** * When reporting exceptions to Rollbar the * level has to be set in the payload data @@ -50,21 +53,21 @@ public function testExceptionLogLevel() { $handler = $this->createHandler(); - $handler->handle($this->createExceptionRecord(Logger::DEBUG)); + $handler->handle($this->createExceptionRecord(Level::Debug)); $this->assertEquals('debug', $this->reportedExceptionArguments['payload']['level']); } private function setupRollbarLoggerMock() { - $config = array( + $config = [ 'access_token' => 'ad865e76e7fb496fab096ac07b1dbabb', 'environment' => 'test', - ); + ]; $this->rollbarLogger = $this->getMockBuilder(RollbarLogger::class) - ->setConstructorArgs(array($config)) - ->setMethods(array('log')) + ->setConstructorArgs([$config]) + ->onlyMethods(['log']) ->getMock(); $this->rollbarLogger @@ -77,10 +80,10 @@ private function setupRollbarLoggerMock() private function createHandler(): RollbarHandler { - return new RollbarHandler($this->rollbarLogger, Logger::DEBUG); + return new RollbarHandler($this->rollbarLogger, Level::Debug); } - private function createExceptionRecord($level = Logger::DEBUG, $message = 'test', $exception = null): array + private function createExceptionRecord($level = Level::Debug, $message = 'test', $exception = null): array { return $this->getRecord($level, $message, [ 'exception' => $exception ?: new Exception(), diff --git a/tests/Monolog/Handler/RotatingFileHandlerTest.php b/tests/Monolog/Handler/RotatingFileHandlerTest.php index 7d3e8c4f4..4294b7f30 100644 --- a/tests/Monolog/Handler/RotatingFileHandlerTest.php +++ b/tests/Monolog/Handler/RotatingFileHandlerTest.php @@ -19,9 +19,9 @@ */ class RotatingFileHandlerTest extends TestCase { - private $lastError; + private array|null $lastError = null; - public function setUp() + public function setUp(): void { $dir = __DIR__.'/Fixtures'; chmod($dir, 0777); @@ -34,9 +34,50 @@ public function setUp() 'code' => $code, 'message' => $message, ]; + + return true; }); } + public function tearDown(): void + { + parent::tearDown(); + + foreach (glob(__DIR__.'/Fixtures/*.rot') as $file) { + unlink($file); + } + + if ('testRotationWithFolderByDate' === $this->getName(false)) { + foreach (glob(__DIR__.'/Fixtures/[0-9]*') as $folder) { + $this->rrmdir($folder); + } + } + + restore_error_handler(); + + unset($this->lastError); + } + + private function rrmdir($directory) { + if (! is_dir($directory)) { + throw new InvalidArgumentException("$directory must be a directory"); + } + + if (substr($directory, strlen($directory) - 1, 1) !== '/') { + $directory .= '/'; + } + + foreach (glob($directory . '*', GLOB_MARK) as $path) { + if (is_dir($path)) { + $this->rrmdir($path); + } else { + unlink($path); + } + } + + return rmdir($directory); + } + private function assertErrorWasTriggered($code, $message) { if (empty($this->lastError)) { @@ -127,6 +168,76 @@ public function rotationTests() ]; } + private function createDeep($file) + { + mkdir(dirname($file), 0777, true); + touch($file); + + return $file; + } + + /** + * @dataProvider rotationWithFolderByDateTests + */ + public function testRotationWithFolderByDate($createFile, $dateFormat, $timeCallback) + { + $old1 = $this->createDeep(__DIR__.'/Fixtures/'.date($dateFormat, $timeCallback(-1)).'/foo.rot'); + $old2 = $this->createDeep(__DIR__.'/Fixtures/'.date($dateFormat, $timeCallback(-2)).'/foo.rot'); + $old3 = $this->createDeep(__DIR__.'/Fixtures/'.date($dateFormat, $timeCallback(-3)).'/foo.rot'); + $old4 = $this->createDeep(__DIR__.'/Fixtures/'.date($dateFormat, $timeCallback(-4)).'/foo.rot'); + + $log = __DIR__.'/Fixtures/'.date($dateFormat).'/foo.rot'; + + if ($createFile) { + $this->createDeep($log); + } + + $handler = new RotatingFileHandler(__DIR__.'/Fixtures/foo.rot', 2); + $handler->setFormatter($this->getIdentityFormatter()); + $handler->setFilenameFormat('{date}/{filename}', $dateFormat); + $handler->handle($this->getRecord()); + + $handler->close(); + + $this->assertTrue(file_exists($log)); + $this->assertTrue(file_exists($old1)); + $this->assertEquals($createFile, file_exists($old2)); + $this->assertEquals($createFile, file_exists($old3)); + $this->assertEquals($createFile, file_exists($old4)); + $this->assertEquals('test', file_get_contents($log)); + } + + public function rotationWithFolderByDateTests() + { + $now = time(); + $dayCallback = function ($ago) use ($now) { + return $now + 86400 * $ago; + }; + $monthCallback = function ($ago) { + return gmmktime(0, 0, 0, (int) (date('n') + $ago), 1, (int) date('Y')); + }; + $yearCallback = function ($ago) { + return gmmktime(0, 0, 0, 1, 1, (int) (date('Y') + $ago)); + }; + + return [ + 'Rotation is triggered when the file of the current day is not present' + => [true, 'Y/m/d', $dayCallback], + 'Rotation is not triggered when the file of the current day is already present' + => [false, 'Y/m/d', $dayCallback], + + 'Rotation is triggered when the file of the current month is not present' + => [true, 'Y/m', $monthCallback], + 'Rotation is not triggered when the file of the current month is already present' + => [false, 'Y/m', $monthCallback], + + 'Rotation is triggered when the file of the current year is not present' + => [true, 'Y', $yearCallback], + 'Rotation is not triggered when the file of the current year is already present' + => [false, 'Y', $yearCallback], + ]; + } + /** * @dataProvider dateFormatProvider */ @@ -135,7 +246,7 @@ public function testAllowOnlyFixedDefinedDateFormats($dateFormat, $valid) $handler = new RotatingFileHandler(__DIR__.'/Fixtures/foo.rot', 2); if (!$valid) { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageRegExp('~^Invalid date format~'); + $this->expectExceptionMessageMatches('~^Invalid date format~'); } $handler->setFilenameFormat('{filename}-{date}', $dateFormat); $this->assertTrue(true); @@ -176,7 +287,7 @@ public function testDisallowFilenameFormatsWithoutDate($filenameFormat, $valid) $handler = new RotatingFileHandler(__DIR__.'/Fixtures/foo.rot', 2); if (!$valid) { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageRegExp('~^Invalid filename format~'); + $this->expectExceptionMessageMatches('~^Invalid filename format~'); } $handler->setFilenameFormat($filenameFormat, RotatingFileHandler::FILE_PER_DAY); @@ -191,6 +302,7 @@ public function filenameFormatProvider() ['foobar-{date}', true], ['foo-{date}-bar', true], ['{date}-foobar', true], + ['{date}/{filename}', true], ['foobar', false], ]; } @@ -216,16 +328,16 @@ public function testRotationWhenSimilarFileNamesExist($dateFormat) public function rotationWhenSimilarFilesExistTests() { - return array( + return [ 'Rotation is triggered when the file of the current day is not present but similar exists' - => array(RotatingFileHandler::FILE_PER_DAY), + => [RotatingFileHandler::FILE_PER_DAY], 'Rotation is triggered when the file of the current month is not present but similar exists' - => array(RotatingFileHandler::FILE_PER_MONTH), + => [RotatingFileHandler::FILE_PER_MONTH], 'Rotation is triggered when the file of the current year is not present but similar exists' - => array(RotatingFileHandler::FILE_PER_YEAR), - ); + => [RotatingFileHandler::FILE_PER_YEAR], + ]; } public function testReuseCurrentFile() @@ -237,12 +349,4 @@ public function testReuseCurrentFile() $handler->handle($this->getRecord()); $this->assertEquals('footest', file_get_contents($log)); } - - public function tearDown() - { - foreach (glob(__DIR__.'/Fixtures/*.rot') as $file) { - unlink($file); - } - restore_error_handler(); - } } diff --git a/tests/Monolog/Handler/Slack/SlackRecordTest.php b/tests/Monolog/Handler/Slack/SlackRecordTest.php index eaa15571d..b19a1b63c 100644 --- a/tests/Monolog/Handler/Slack/SlackRecordTest.php +++ b/tests/Monolog/Handler/Slack/SlackRecordTest.php @@ -11,7 +11,7 @@ namespace Monolog\Handler\Slack; -use Monolog\Logger; +use Monolog\Level; use Monolog\Test\TestCase; /** @@ -19,34 +19,25 @@ */ class SlackRecordTest extends TestCase { - private $jsonPrettyPrintFlag; - - protected function setUp() - { - $this->jsonPrettyPrintFlag = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 128; - } - public function dataGetAttachmentColor() { - return array( - array(Logger::DEBUG, SlackRecord::COLOR_DEFAULT), - array(Logger::INFO, SlackRecord::COLOR_GOOD), - array(Logger::NOTICE, SlackRecord::COLOR_GOOD), - array(Logger::WARNING, SlackRecord::COLOR_WARNING), - array(Logger::ERROR, SlackRecord::COLOR_DANGER), - array(Logger::CRITICAL, SlackRecord::COLOR_DANGER), - array(Logger::ALERT, SlackRecord::COLOR_DANGER), - array(Logger::EMERGENCY, SlackRecord::COLOR_DANGER), - ); + return [ + [Level::Debug, SlackRecord::COLOR_DEFAULT], + [Level::Info, SlackRecord::COLOR_GOOD], + [Level::Notice, SlackRecord::COLOR_GOOD], + [Level::Warning, SlackRecord::COLOR_WARNING], + [Level::Error, SlackRecord::COLOR_DANGER], + [Level::Critical, SlackRecord::COLOR_DANGER], + [Level::Alert, SlackRecord::COLOR_DANGER], + [Level::Emergency, SlackRecord::COLOR_DANGER], + ]; } /** * @dataProvider dataGetAttachmentColor - * @param int $logLevel - * @param string $expectedColour RGB hex color or name of Slack color * @covers ::getAttachmentColor */ - public function testGetAttachmentColor($logLevel, $expectedColour) + public function testGetAttachmentColor(Level $logLevel, string $expectedColour) { $slackRecord = new SlackRecord(); $this->assertSame( @@ -73,23 +64,18 @@ public function testNoUsernameByDefault() $this->assertArrayNotHasKey('username', $data); } - /** - * @return array - */ - public function dataStringify() + public function dataStringify(): array { - $jsonPrettyPrintFlag = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 128; - - $multipleDimensions = array(array(1, 2)); - $numericKeys = array('library' => 'monolog'); - $singleDimension = array(1, 'Hello', 'Jordi'); - - return array( - array(array(), '[]'), - array($multipleDimensions, json_encode($multipleDimensions, $jsonPrettyPrintFlag)), - array($numericKeys, json_encode($numericKeys, $jsonPrettyPrintFlag)), - array($singleDimension, json_encode($singleDimension)), - ); + $multipleDimensions = [[1, 2]]; + $numericKeys = ['library' => 'monolog']; + $singleDimension = [1, 'Hello', 'Jordi']; + + return [ + [[], '[]'], + [$multipleDimensions, json_encode($multipleDimensions, JSON_PRETTY_PRINT)], + [$numericKeys, json_encode($numericKeys, JSON_PRETTY_PRINT)], + [$singleDimension, json_encode($singleDimension)], + ]; } /** @@ -157,14 +143,14 @@ public function testAddsOneAttachment() $this->assertArrayHasKey('attachments', $data); $this->assertArrayHasKey(0, $data['attachments']); - $this->assertInternalType('array', $data['attachments'][0]); + $this->assertIsArray($data['attachments'][0]); } public function testTextEqualsMessageIfNoAttachment() { $message = 'Test message'; $record = new SlackRecord(null, null, false); - $data = $record->getSlackData($this->getRecord(Logger::WARNING, $message)); + $data = $record->getSlackData($this->getRecord(Level::Warning, $message)); $this->assertArrayHasKey('text', $data); $this->assertSame($message, $data['text']); @@ -177,7 +163,7 @@ public function testTextEqualsFormatterOutput() ->expects($this->any()) ->method('format') ->will($this->returnCallback(function ($record) { - return $record['message'] . 'test'; + return $record->message . 'test'; })); $formatter2 = $this->createMock('Monolog\\Formatter\\FormatterInterface'); @@ -185,18 +171,18 @@ public function testTextEqualsFormatterOutput() ->expects($this->any()) ->method('format') ->will($this->returnCallback(function ($record) { - return $record['message'] . 'test1'; + return $record->message . 'test1'; })); $message = 'Test message'; - $record = new SlackRecord(null, null, false, null, false, false, array(), $formatter); - $data = $record->getSlackData($this->getRecord(Logger::WARNING, $message)); + $record = new SlackRecord(null, null, false, null, false, false, [], $formatter); + $data = $record->getSlackData($this->getRecord(Level::Warning, $message)); $this->assertArrayHasKey('text', $data); $this->assertSame($message . 'test', $data['text']); $record->setFormatter($formatter2); - $data = $record->getSlackData($this->getRecord(Logger::WARNING, $message)); + $data = $record->getSlackData($this->getRecord(Level::Warning, $message)); $this->assertArrayHasKey('text', $data); $this->assertSame($message . 'test1', $data['text']); @@ -206,7 +192,7 @@ public function testAddsFallbackAndTextToAttachment() { $message = 'Test message'; $record = new SlackRecord(null); - $data = $record->getSlackData($this->getRecord(Logger::WARNING, $message)); + $data = $record->getSlackData($this->getRecord(Level::Warning, $message)); $this->assertSame($message, $data['attachments'][0]['text']); $this->assertSame($message, $data['attachments'][0]['fallback']); @@ -215,11 +201,11 @@ public function testAddsFallbackAndTextToAttachment() public function testMapsLevelToColorAttachmentColor() { $record = new SlackRecord(null); - $errorLoggerRecord = $this->getRecord(Logger::ERROR); - $emergencyLoggerRecord = $this->getRecord(Logger::EMERGENCY); - $warningLoggerRecord = $this->getRecord(Logger::WARNING); - $infoLoggerRecord = $this->getRecord(Logger::INFO); - $debugLoggerRecord = $this->getRecord(Logger::DEBUG); + $errorLoggerRecord = $this->getRecord(Level::Error); + $emergencyLoggerRecord = $this->getRecord(Level::Emergency); + $warningLoggerRecord = $this->getRecord(Level::Warning); + $infoLoggerRecord = $this->getRecord(Level::Info); + $debugLoggerRecord = $this->getRecord(Level::Debug); $data = $record->getSlackData($errorLoggerRecord); $this->assertSame(SlackRecord::COLOR_DANGER, $data['attachments'][0]['color']); @@ -239,24 +225,24 @@ public function testMapsLevelToColorAttachmentColor() public function testAddsShortAttachmentWithoutContextAndExtra() { - $level = Logger::ERROR; - $levelName = Logger::getLevelName($level); + $level = Level::Error; + $levelName = $level->getName(); $record = new SlackRecord(null, null, true, null, true); - $data = $record->getSlackData($this->getRecord($level, 'test', array('test' => 1))); + $data = $record->getSlackData($this->getRecord($level, 'test', ['test' => 1])); $attachment = $data['attachments'][0]; $this->assertArrayHasKey('title', $attachment); $this->assertArrayHasKey('fields', $attachment); $this->assertSame($levelName, $attachment['title']); - $this->assertSame(array(), $attachment['fields']); + $this->assertSame([], $attachment['fields']); } public function testAddsShortAttachmentWithContextAndExtra() { - $level = Logger::ERROR; - $levelName = Logger::getLevelName($level); - $context = array('test' => 1); - $extra = array('tags' => array('web')); + $level = Level::Error; + $levelName = $level->getName(); + $context = ['test' => 1]; + $extra = ['tags' => ['web']]; $record = new SlackRecord(null, null, true, null, true, true); $loggerRecord = $this->getRecord($level, 'test', $context); $loggerRecord['extra'] = $extra; @@ -268,28 +254,28 @@ public function testAddsShortAttachmentWithContextAndExtra() $this->assertCount(2, $attachment['fields']); $this->assertSame($levelName, $attachment['title']); $this->assertSame( - array( - array( + [ + [ 'title' => 'Extra', - 'value' => sprintf('```%s```', json_encode($extra, $this->jsonPrettyPrintFlag)), + 'value' => sprintf('```%s```', json_encode($extra, JSON_PRETTY_PRINT)), 'short' => false, - ), - array( + ], + [ 'title' => 'Context', - 'value' => sprintf('```%s```', json_encode($context, $this->jsonPrettyPrintFlag)), + 'value' => sprintf('```%s```', json_encode($context, JSON_PRETTY_PRINT)), 'short' => false, - ), - ), + ], + ], $attachment['fields'] ); } public function testAddsLongAttachmentWithoutContextAndExtra() { - $level = Logger::ERROR; - $levelName = Logger::getLevelName($level); + $level = Level::Error; + $levelName = $level->getName(); $record = new SlackRecord(null, null, true, null); - $data = $record->getSlackData($this->getRecord($level, 'test', array('test' => 1))); + $data = $record->getSlackData($this->getRecord($level, 'test', ['test' => 1])); $attachment = $data['attachments'][0]; $this->assertArrayHasKey('title', $attachment); @@ -297,43 +283,43 @@ public function testAddsLongAttachmentWithoutContextAndExtra() $this->assertCount(1, $attachment['fields']); $this->assertSame('Message', $attachment['title']); $this->assertSame( - array(array( + [[ 'title' => 'Level', 'value' => $levelName, 'short' => false, - )), + ]], $attachment['fields'] ); } public function testAddsLongAttachmentWithContextAndExtra() { - $level = Logger::ERROR; - $levelName = Logger::getLevelName($level); - $context = array('test' => 1); - $extra = array('tags' => array('web')); + $level = Level::Error; + $levelName = $level->getName(); + $context = ['test' => 1]; + $extra = ['tags' => ['web']]; $record = new SlackRecord(null, null, true, null, false, true); $loggerRecord = $this->getRecord($level, 'test', $context); $loggerRecord['extra'] = $extra; $data = $record->getSlackData($loggerRecord); - $expectedFields = array( - array( + $expectedFields = [ + [ 'title' => 'Level', 'value' => $levelName, 'short' => false, - ), - array( + ], + [ 'title' => 'Tags', 'value' => sprintf('```%s```', json_encode($extra['tags'])), 'short' => false, - ), - array( + ], + [ 'title' => 'Test', 'value' => $context['test'], 'short' => false, - ), - ); + ], + ]; $attachment = $data['attachments'][0]; $this->assertArrayHasKey('title', $attachment); @@ -354,42 +340,42 @@ public function testAddsTimestampToAttachment() $attachment = $data['attachments'][0]; $this->assertArrayHasKey('ts', $attachment); - $this->assertSame($record['datetime']->getTimestamp(), $attachment['ts']); + $this->assertSame($record->datetime->getTimestamp(), $attachment['ts']); } public function testContextHasException() { - $record = $this->getRecord(Logger::CRITICAL, 'This is a critical message.', array('exception' => new \Exception())); + $record = $this->getRecord(Level::Critical, 'This is a critical message.', ['exception' => new \Exception()]); $slackRecord = new SlackRecord(null, null, true, null, false, true); $data = $slackRecord->getSlackData($record); - $this->assertInternalType('string', $data['attachments'][0]['fields'][1]['value']); + $this->assertIsString($data['attachments'][0]['fields'][1]['value']); } public function testExcludeExtraAndContextFields() { $record = $this->getRecord( - Logger::WARNING, + Level::Warning, 'test', - array('info' => array('library' => 'monolog', 'author' => 'Jordi')) + context: ['info' => ['library' => 'monolog', 'author' => 'Jordi']], + extra: ['tags' => ['web', 'cli']], ); - $record['extra'] = array('tags' => array('web', 'cli')); - $slackRecord = new SlackRecord(null, null, true, null, false, true, array('context.info.library', 'extra.tags.1')); + $slackRecord = new SlackRecord(null, null, true, null, false, true, ['context.info.library', 'extra.tags.1']); $data = $slackRecord->getSlackData($record); $attachment = $data['attachments'][0]; - $expected = array( - array( + $expected = [ + [ 'title' => 'Info', - 'value' => sprintf('```%s```', json_encode(array('author' => 'Jordi'), $this->jsonPrettyPrintFlag)), + 'value' => sprintf('```%s```', json_encode(['author' => 'Jordi'], JSON_PRETTY_PRINT)), 'short' => false, - ), - array( + ], + [ 'title' => 'Tags', - 'value' => sprintf('```%s```', json_encode(array('web'))), + 'value' => sprintf('```%s```', json_encode(['web'])), 'short' => false, - ), - ); + ], + ]; foreach ($expected as $field) { $this->assertNotFalse(array_search($field, $attachment['fields'])); diff --git a/tests/Monolog/Handler/SlackHandlerTest.php b/tests/Monolog/Handler/SlackHandlerTest.php index e8abd15de..6d1bbb131 100644 --- a/tests/Monolog/Handler/SlackHandlerTest.php +++ b/tests/Monolog/Handler/SlackHandlerTest.php @@ -12,7 +12,7 @@ namespace Monolog\Handler; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; use Monolog\Formatter\LineFormatter; use Monolog\Handler\Slack\SlackRecord; @@ -27,66 +27,70 @@ class SlackHandlerTest extends TestCase */ private $res; - /** - * @var SlackHandler - */ - private $handler; + private SlackHandler $handler; - public function setUp() + public function setUp(): void { if (!extension_loaded('openssl')) { $this->markTestSkipped('This test requires openssl to run'); } } + public function tearDown(): void + { + parent::tearDown(); + + unset($this->res); + } + public function testWriteHeader() { $this->createHandler(); - $this->handler->handle($this->getRecord(Logger::CRITICAL, 'test1')); + $this->handler->handle($this->getRecord(Level::Critical, 'test1')); fseek($this->res, 0); $content = fread($this->res, 1024); - $this->assertRegexp('{POST /api/chat.postMessage HTTP/1.1\\r\\nHost: slack.com\\r\\nContent-Type: application/x-www-form-urlencoded\\r\\nContent-Length: \d{2,4}\\r\\n\\r\\n}', $content); + $this->assertMatchesRegularExpression('{POST /api/chat.postMessage HTTP/1.1\\r\\nHost: slack.com\\r\\nContent-Type: application/x-www-form-urlencoded\\r\\nContent-Length: \d{2,4}\\r\\n\\r\\n}', $content); } public function testWriteContent() { $this->createHandler(); - $this->handler->handle($this->getRecord(Logger::CRITICAL, 'test1')); + $this->handler->handle($this->getRecord(Level::Critical, 'test1')); fseek($this->res, 0); $content = fread($this->res, 1024); - $this->assertRegExp('/username=Monolog/', $content); - $this->assertRegExp('/channel=channel1/', $content); - $this->assertRegExp('/token=myToken/', $content); - $this->assertRegExp('/attachments/', $content); + $this->assertMatchesRegularExpression('/username=Monolog/', $content); + $this->assertMatchesRegularExpression('/channel=channel1/', $content); + $this->assertMatchesRegularExpression('/token=myToken/', $content); + $this->assertMatchesRegularExpression('/attachments/', $content); } public function testWriteContentUsesFormatterIfProvided() { $this->createHandler('myToken', 'channel1', 'Monolog', false); - $this->handler->handle($this->getRecord(Logger::CRITICAL, 'test1')); + $this->handler->handle($this->getRecord(Level::Critical, 'test1')); fseek($this->res, 0); $content = fread($this->res, 1024); $this->createHandler('myToken', 'channel1', 'Monolog', false); $this->handler->setFormatter(new LineFormatter('foo--%message%')); - $this->handler->handle($this->getRecord(Logger::CRITICAL, 'test2')); + $this->handler->handle($this->getRecord(Level::Critical, 'test2')); fseek($this->res, 0); $content2 = fread($this->res, 1024); - $this->assertRegexp('/text=test1/', $content); - $this->assertRegexp('/text=foo--test2/', $content2); + $this->assertMatchesRegularExpression('/text=test1/', $content); + $this->assertMatchesRegularExpression('/text=foo--test2/', $content2); } public function testWriteContentWithEmoji() { $this->createHandler('myToken', 'channel1', 'Monolog', true, 'alien'); - $this->handler->handle($this->getRecord(Logger::CRITICAL, 'test1')); + $this->handler->handle($this->getRecord(Level::Critical, 'test1')); fseek($this->res, 0); $content = fread($this->res, 1024); - $this->assertRegexp('/icon_emoji=%3Aalien%3A/', $content); + $this->assertMatchesRegularExpression('/icon_emoji=%3Aalien%3A/', $content); } /** @@ -99,40 +103,40 @@ public function testWriteContentWithColors($level, $expectedColor) fseek($this->res, 0); $content = fread($this->res, 1024); - $this->assertRegexp('/%22color%22%3A%22'.$expectedColor.'/', $content); + $this->assertMatchesRegularExpression('/%22color%22%3A%22'.$expectedColor.'/', $content); } public function testWriteContentWithPlainTextMessage() { $this->createHandler('myToken', 'channel1', 'Monolog', false); - $this->handler->handle($this->getRecord(Logger::CRITICAL, 'test1')); + $this->handler->handle($this->getRecord(Level::Critical, 'test1')); fseek($this->res, 0); $content = fread($this->res, 1024); - $this->assertRegexp('/text=test1/', $content); + $this->assertMatchesRegularExpression('/text=test1/', $content); } public function provideLevelColors() { - return array( - array(Logger::DEBUG, urlencode(SlackRecord::COLOR_DEFAULT)), - array(Logger::INFO, SlackRecord::COLOR_GOOD), - array(Logger::NOTICE, SlackRecord::COLOR_GOOD), - array(Logger::WARNING, SlackRecord::COLOR_WARNING), - array(Logger::ERROR, SlackRecord::COLOR_DANGER), - array(Logger::CRITICAL, SlackRecord::COLOR_DANGER), - array(Logger::ALERT, SlackRecord::COLOR_DANGER), - array(Logger::EMERGENCY,SlackRecord::COLOR_DANGER), - ); + return [ + [Level::Debug, urlencode(SlackRecord::COLOR_DEFAULT)], + [Level::Info, SlackRecord::COLOR_GOOD], + [Level::Notice, SlackRecord::COLOR_GOOD], + [Level::Warning, SlackRecord::COLOR_WARNING], + [Level::Error, SlackRecord::COLOR_DANGER], + [Level::Critical, SlackRecord::COLOR_DANGER], + [Level::Alert, SlackRecord::COLOR_DANGER], + [Level::Emergency,SlackRecord::COLOR_DANGER], + ]; } private function createHandler($token = 'myToken', $channel = 'channel1', $username = 'Monolog', $useAttachment = true, $iconEmoji = null, $useShortAttachment = false, $includeExtra = false) { - $constructorArgs = [$token, $channel, $username, $useAttachment, $iconEmoji, Logger::DEBUG, true, $useShortAttachment, $includeExtra]; + $constructorArgs = [$token, $channel, $username, $useAttachment, $iconEmoji, Level::Debug, true, $useShortAttachment, $includeExtra]; $this->res = fopen('php://memory', 'a'); $this->handler = $this->getMockBuilder('Monolog\Handler\SlackHandler') ->setConstructorArgs($constructorArgs) - ->setMethods(['fsockopen', 'streamSetTimeout', 'closeSocket']) + ->onlyMethods(['fsockopen', 'streamSetTimeout', 'closeSocket']) ->getMock(); $reflectionProperty = new \ReflectionProperty('Monolog\Handler\SocketHandler', 'connectionString'); diff --git a/tests/Monolog/Handler/SlackWebhookHandlerTest.php b/tests/Monolog/Handler/SlackWebhookHandlerTest.php index 8ce721086..f0c2f2a09 100644 --- a/tests/Monolog/Handler/SlackWebhookHandlerTest.php +++ b/tests/Monolog/Handler/SlackWebhookHandlerTest.php @@ -12,7 +12,7 @@ namespace Monolog\Handler; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; use Monolog\Formatter\LineFormatter; use Monolog\Handler\Slack\SlackRecord; @@ -35,25 +35,27 @@ public function testConstructorMinimal() $record = $this->getRecord(); $slackRecord = $handler->getSlackRecord(); $this->assertInstanceOf('Monolog\Handler\Slack\SlackRecord', $slackRecord); - $this->assertEquals(array( - 'attachments' => array( - array( + $this->assertEquals([ + 'attachments' => [ + [ 'fallback' => 'test', 'text' => 'test', 'color' => SlackRecord::COLOR_WARNING, - 'fields' => array( - array( + 'fields' => [ + [ 'title' => 'Level', 'value' => 'WARNING', 'short' => false, - ), - ), + ], + ], 'title' => 'Message', - 'mrkdwn_in' => array('fields'), - 'ts' => $record['datetime']->getTimestamp(), - ), - ), - ), $slackRecord->getSlackData($record)); + 'mrkdwn_in' => ['fields'], + 'ts' => $record->datetime->getTimestamp(), + 'footer' => null, + 'footer_icon' => null, + ], + ], + ], $slackRecord->getSlackData($record)); } /** @@ -70,18 +72,65 @@ public function testConstructorFull() ':ghost:', false, false, - Logger::DEBUG, + Level::Debug, false ); $slackRecord = $handler->getSlackRecord(); $this->assertInstanceOf('Monolog\Handler\Slack\SlackRecord', $slackRecord); - $this->assertEquals(array( + $this->assertEquals([ 'username' => 'test-username', 'text' => 'test', 'channel' => 'test-channel', 'icon_emoji' => ':ghost:', - ), $slackRecord->getSlackData($this->getRecord())); + ], $slackRecord->getSlackData($this->getRecord())); + } + + /** + * @covers ::__construct + * @covers ::getSlackRecord + */ + public function testConstructorFullWithAttachment() + { + $handler = new SlackWebhookHandler( + self::WEBHOOK_URL, + 'test-channel-with-attachment', + 'test-username-with-attachment', + true, + 'https://www.example.com/example.png', + false, + false, + Level::Debug, + false + ); + + $record = $this->getRecord(); + $slackRecord = $handler->getSlackRecord(); + $this->assertInstanceOf('Monolog\Handler\Slack\SlackRecord', $slackRecord); + $this->assertEquals([ + 'username' => 'test-username-with-attachment', + 'channel' => 'test-channel-with-attachment', + 'attachments' => [ + [ + 'fallback' => 'test', + 'text' => 'test', + 'color' => SlackRecord::COLOR_WARNING, + 'fields' => [ + [ + 'title' => 'Level', + 'value' => Level::Warning->getName(), + 'short' => false, + ], + ], + 'mrkdwn_in' => ['fields'], + 'ts' => $record['datetime']->getTimestamp(), + 'footer' => 'test-username-with-attachment', + 'footer_icon' => 'https://www.example.com/example.png', + 'title' => 'Message', + ], + ], + 'icon_url' => 'https://www.example.com/example.png', + ], $slackRecord->getSlackData($record)); } /** diff --git a/tests/Monolog/Handler/SlackbotHandlerTest.php b/tests/Monolog/Handler/SlackbotHandlerTest.php deleted file mode 100644 index 340c4c65e..000000000 --- a/tests/Monolog/Handler/SlackbotHandlerTest.php +++ /dev/null @@ -1,47 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Monolog\Handler; - -use Monolog\Test\TestCase; -use Monolog\Logger; - -/** - * @author Haralan Dobrev - * @see https://slack.com/apps/A0F81R8ET-slackbot - * @coversDefaultClass Monolog\Handler\SlackbotHandler - */ -class SlackbotHandlerTest extends TestCase -{ - /** - * @covers ::__construct - */ - public function testConstructorMinimal() - { - $handler = new SlackbotHandler('test-team', 'test-token', 'test-channel'); - $this->assertInstanceOf('Monolog\Handler\AbstractProcessingHandler', $handler); - } - - /** - * @covers ::__construct - */ - public function testConstructorFull() - { - $handler = new SlackbotHandler( - 'test-team', - 'test-token', - 'test-channel', - Logger::DEBUG, - false - ); - $this->assertInstanceOf('Monolog\Handler\AbstractProcessingHandler', $handler); - } -} diff --git a/tests/Monolog/Handler/SocketHandlerTest.php b/tests/Monolog/Handler/SocketHandlerTest.php index 5f76048cc..bb7ab9ba2 100644 --- a/tests/Monolog/Handler/SocketHandlerTest.php +++ b/tests/Monolog/Handler/SocketHandlerTest.php @@ -12,137 +12,137 @@ namespace Monolog\Handler; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; +use PHPUnit\Framework\MockObject\MockObject; /** * @author Pablo de Leon Belloc */ class SocketHandlerTest extends TestCase { - /** - * @var Monolog\Handler\SocketHandler - */ - private $handler; + private SocketHandler&MockObject $handler; /** * @var resource */ private $res; - /** - * @expectedException UnexpectedValueException - */ + public function tearDown(): void + { + parent::tearDown(); + + unset($this->res); + } + public function testInvalidHostname() { - $this->createHandler('garbage://here'); - $this->writeRecord('data'); + $this->expectException(\UnexpectedValueException::class); + + $handler = $this->createHandler('garbage://here'); + $handler->handle($this->getRecord(Level::Warning, 'data')); } - /** - * @expectedException \InvalidArgumentException - */ public function testBadConnectionTimeout() { - $this->createHandler('localhost:1234'); - $this->handler->setConnectionTimeout(-1); + $this->expectException(\InvalidArgumentException::class); + + $handler = $this->createHandler('localhost:1234'); + $handler->setConnectionTimeout(-1); } public function testSetConnectionTimeout() { - $this->createHandler('localhost:1234'); - $this->handler->setConnectionTimeout(10.1); - $this->assertEquals(10.1, $this->handler->getConnectionTimeout()); + $handler = $this->createHandler('localhost:1234'); + $handler->setConnectionTimeout(10.1); + $this->assertEquals(10.1, $handler->getConnectionTimeout()); } - /** - * @expectedException \InvalidArgumentException - */ public function testBadTimeout() { - $this->createHandler('localhost:1234'); - $this->handler->setTimeout(-1); + $this->expectException(\InvalidArgumentException::class); + + $handler = $this->createHandler('localhost:1234'); + $handler->setTimeout(-1); } public function testSetTimeout() { - $this->createHandler('localhost:1234'); - $this->handler->setTimeout(10.25); - $this->assertEquals(10.25, $this->handler->getTimeout()); + $handler = $this->createHandler('localhost:1234'); + $handler->setTimeout(10.25); + $this->assertEquals(10.25, $handler->getTimeout()); } public function testSetWritingTimeout() { - $this->createHandler('localhost:1234'); - $this->handler->setWritingTimeout(10.25); - $this->assertEquals(10.25, $this->handler->getWritingTimeout()); + $handler = $this->createHandler('localhost:1234'); + $handler->setWritingTimeout(10.25); + $this->assertEquals(10.25, $handler->getWritingTimeout()); } public function testSetChunkSize() { - $this->createHandler('localhost:1234'); - $this->handler->setChunkSize(1025); - $this->assertEquals(1025, $this->handler->getChunkSize()); + $handler = $this->createHandler('localhost:1234'); + $handler->setChunkSize(1025); + $this->assertEquals(1025, $handler->getChunkSize()); } public function testSetConnectionString() { - $this->createHandler('tcp://localhost:9090'); - $this->assertEquals('tcp://localhost:9090', $this->handler->getConnectionString()); + $handler = $this->createHandler('tcp://localhost:9090'); + $this->assertEquals('tcp://localhost:9090', $handler->getConnectionString()); } - /** - * @expectedException UnexpectedValueException - */ public function testExceptionIsThrownOnFsockopenError() { $this->setMockHandler(['fsockopen']); $this->handler->expects($this->once()) ->method('fsockopen') ->will($this->returnValue(false)); + + $this->expectException(\UnexpectedValueException::class); + $this->writeRecord('Hello world'); } - /** - * @expectedException UnexpectedValueException - */ public function testExceptionIsThrownOnPfsockopenError() { $this->setMockHandler(['pfsockopen']); $this->handler->expects($this->once()) ->method('pfsockopen') ->will($this->returnValue(false)); + $this->handler->setPersistent(true); + + $this->expectException(\UnexpectedValueException::class); + $this->writeRecord('Hello world'); } - /** - * @expectedException UnexpectedValueException - */ public function testExceptionIsThrownIfCannotSetTimeout() { $this->setMockHandler(['streamSetTimeout']); $this->handler->expects($this->once()) ->method('streamSetTimeout') ->will($this->returnValue(false)); + + $this->expectException(\UnexpectedValueException::class); + $this->writeRecord('Hello world'); } - /** - * @expectedException UnexpectedValueException - */ public function testExceptionIsThrownIfCannotSetChunkSize() { - $this->setMockHandler(array('streamSetChunkSize')); + $this->setMockHandler(['streamSetChunkSize']); $this->handler->setChunkSize(8192); $this->handler->expects($this->once()) ->method('streamSetChunkSize') ->will($this->returnValue(false)); + + $this->expectException(\UnexpectedValueException::class); + $this->writeRecord('Hello world'); } - /** - * @expectedException RuntimeException - */ public function testWriteFailsOnIfFwriteReturnsFalse() { $this->setMockHandler(['fwrite']); @@ -160,12 +160,11 @@ public function testWriteFailsOnIfFwriteReturnsFalse() ->method('fwrite') ->will($this->returnCallback($callback)); + $this->expectException(\RuntimeException::class); + $this->writeRecord('Hello world'); } - /** - * @expectedException RuntimeException - */ public function testWriteFailsIfStreamTimesOut() { $this->setMockHandler(['fwrite', 'streamGetMetadata']); @@ -186,12 +185,11 @@ public function testWriteFailsIfStreamTimesOut() ->method('streamGetMetadata') ->will($this->returnValue(['timed_out' => true])); + $this->expectException(\RuntimeException::class); + $this->writeRecord('Hello world'); } - /** - * @expectedException RuntimeException - */ public function testWriteFailsOnIncompleteWrite() { $this->setMockHandler(['fwrite', 'streamGetMetadata']); @@ -210,6 +208,8 @@ public function testWriteFailsOnIncompleteWrite() ->method('streamGetMetadata') ->will($this->returnValue(['timed_out' => false])); + $this->expectException(\RuntimeException::class); + $this->writeRecord('Hello world'); } @@ -247,7 +247,7 @@ public function testClose() { $this->setMockHandler(); $this->writeRecord('Hello world'); - $this->assertInternalType('resource', $this->res); + $this->assertIsResource($this->res); $this->handler->close(); $this->assertFalse(is_resource($this->res), "Expected resource to be closed after closing handler"); } @@ -262,9 +262,6 @@ public function testCloseDoesNotClosePersistentSocket() $this->assertTrue(is_resource($this->res)); } - /** - * @expectedException \RuntimeException - */ public function testAvoidInfiniteLoopWhenNoDataIsWrittenForAWritingTimeoutSeconds() { $this->setMockHandler(['fwrite', 'streamGetMetadata']); @@ -279,18 +276,22 @@ public function testAvoidInfiniteLoopWhenNoDataIsWrittenForAWritingTimeoutSecond $this->handler->setWritingTimeout(1); + $this->expectException(\RuntimeException::class); + $this->writeRecord('Hello world'); } - private function createHandler($connectionString) + private function createHandler(string $connectionString): SocketHandler { - $this->handler = new SocketHandler($connectionString); - $this->handler->setFormatter($this->getIdentityFormatter()); + $handler = new SocketHandler($connectionString); + $handler->setFormatter($this->getIdentityFormatter()); + + return $handler; } private function writeRecord($string) { - $this->handler->handle($this->getRecord(Logger::WARNING, $string)); + $this->handler->handle($this->getRecord(Level::Warning, $string)); } private function setMockHandler(array $methods = []) @@ -303,7 +304,7 @@ private function setMockHandler(array $methods = []) $finalMethods = array_merge($defaultMethods, $newMethods); $this->handler = $this->getMockBuilder('Monolog\Handler\SocketHandler') - ->setMethods($finalMethods) + ->onlyMethods($finalMethods) ->setConstructorArgs(['localhost:1234']) ->getMock(); diff --git a/tests/Monolog/Handler/StreamHandlerTest.php b/tests/Monolog/Handler/StreamHandlerTest.php index 377e29641..2d93b30ce 100644 --- a/tests/Monolog/Handler/StreamHandlerTest.php +++ b/tests/Monolog/Handler/StreamHandlerTest.php @@ -12,7 +12,7 @@ namespace Monolog\Handler; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; class StreamHandlerTest extends TestCase { @@ -25,9 +25,9 @@ public function testWrite() $handle = fopen('php://memory', 'a+'); $handler = new StreamHandler($handle); $handler->setFormatter($this->getIdentityFormatter()); - $handler->handle($this->getRecord(Logger::WARNING, 'test')); - $handler->handle($this->getRecord(Logger::WARNING, 'test2')); - $handler->handle($this->getRecord(Logger::WARNING, 'test3')); + $handler->handle($this->getRecord(Level::Warning, 'test')); + $handler->handle($this->getRecord(Level::Warning, 'test2')); + $handler->handle($this->getRecord(Level::Warning, 'test3')); fseek($handle, 0); $this->assertEquals('testtest2test3', fread($handle, 100)); } @@ -50,7 +50,7 @@ public function testCloseKeepsExternalHandlersOpen() public function testClose() { $handler = new StreamHandler('php://memory'); - $handler->handle($this->getRecord(Logger::WARNING, 'test')); + $handler->handle($this->getRecord(Level::Warning, 'test')); $stream = $handler->getStream(); $this->assertTrue(is_resource($stream)); @@ -65,24 +65,24 @@ public function testClose() public function testSerialization() { $handler = new StreamHandler('php://memory'); - $handler->handle($this->getRecord(Logger::WARNING, 'testfoo')); + $handler->handle($this->getRecord(Level::Warning, 'testfoo')); $stream = $handler->getStream(); $this->assertTrue(is_resource($stream)); fseek($stream, 0); - $this->assertContains('testfoo', stream_get_contents($stream)); + $this->assertStringContainsString('testfoo', stream_get_contents($stream)); $serialized = serialize($handler); $this->assertFalse(is_resource($stream)); $handler = unserialize($serialized); - $handler->handle($this->getRecord(Logger::WARNING, 'testbar')); + $handler->handle($this->getRecord(Level::Warning, 'testbar')); $stream = $handler->getStream(); $this->assertTrue(is_resource($stream)); fseek($stream, 0); $contents = stream_get_contents($stream); - $this->assertNotContains('testfoo', $contents); - $this->assertContains('testbar', $contents); + $this->assertStringNotContainsString('testfoo', $contents); + $this->assertStringContainsString('testbar', $contents); } /** @@ -101,17 +101,18 @@ public function testWriteCreatesTheStreamResource() public function testWriteLocking() { $temp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'monolog_locked_log'; - $handler = new StreamHandler($temp, Logger::DEBUG, true, null, true); + $handler = new StreamHandler($temp, Level::Debug, true, null, true); $handler->handle($this->getRecord()); } /** - * @expectedException LogicException * @covers Monolog\Handler\StreamHandler::__construct * @covers Monolog\Handler\StreamHandler::write */ public function testWriteMissingResource() { + $this->expectException(\LogicException::class); + $handler = new StreamHandler(null); $handler->handle($this->getRecord()); } @@ -127,32 +128,58 @@ public function invalidArgumentProvider() /** * @dataProvider invalidArgumentProvider - * @expectedException InvalidArgumentException * @covers Monolog\Handler\StreamHandler::__construct */ public function testWriteInvalidArgument($invalidArgument) { + $this->expectException(\InvalidArgumentException::class); + $handler = new StreamHandler($invalidArgument); } /** - * @expectedException UnexpectedValueException * @covers Monolog\Handler\StreamHandler::__construct * @covers Monolog\Handler\StreamHandler::write */ public function testWriteInvalidResource() { + $this->expectException(\UnexpectedValueException::class); + $php7xMessage = <<expectExceptionMessage(($majorVersion >= 8) ? $php8xMessage : $php7xMessage); + $handler = new StreamHandler('bogus://url'); - $handler->handle($this->getRecord()); + $record = $this->getRecord( + context: ['foo' => 'bar'], + extra: [1, 2, 3], + ); + $handler->handle($record); } /** - * @expectedException UnexpectedValueException * @covers Monolog\Handler\StreamHandler::__construct * @covers Monolog\Handler\StreamHandler::write */ public function testWriteNonExistingResource() { + $this->expectException(\UnexpectedValueException::class); + $handler = new StreamHandler('ftp://foo/bar/baz/'.rand(0, 10000)); $handler->handle($this->getRecord()); } @@ -178,32 +205,102 @@ public function testWriteNonExistingFileResource() } /** - * @expectedException Exception - * @expectedExceptionMessageRegExp /There is no existing directory at/ * @covers Monolog\Handler\StreamHandler::__construct * @covers Monolog\Handler\StreamHandler::write + * @dataProvider provideNonExistingAndNotCreatablePath */ - public function testWriteNonExistingAndNotCreatablePath() + public function testWriteNonExistingAndNotCreatablePath($nonExistingAndNotCreatablePath) { if (defined('PHP_WINDOWS_VERSION_BUILD')) { $this->markTestSkipped('Permissions checks can not run on windows'); } - $handler = new StreamHandler('/foo/bar/'.rand(0, 10000).DIRECTORY_SEPARATOR.rand(0, 10000)); + + $handler = null; + + try { + $handler = new StreamHandler($nonExistingAndNotCreatablePath); + } catch (\Exception $fail) { + $this->fail( + 'A non-existing and not creatable path should throw an Exception earliest on first write. + Not during instantiation.' + ); + } + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('There is no existing directory at'); + $handler->handle($this->getRecord()); } + public function provideNonExistingAndNotCreatablePath() + { + return [ + '/foo/bar/…' => [ + '/foo/bar/'.rand(0, 10000).DIRECTORY_SEPARATOR.rand(0, 10000), + ], + 'file:///foo/bar/…' => [ + 'file:///foo/bar/'.rand(0, 10000).DIRECTORY_SEPARATOR.rand(0, 10000), + ], + ]; + } + + public function provideMemoryValues() + { + return [ + ['1M', (int) (1024*1024/10)], + ['10M', (int) (1024*1024)], + ['1024M', (int) (1024*1024*1024/10)], + ['1G', (int) (1024*1024*1024/10)], + ['2000M', (int) (2000*1024*1024/10)], + ['2050M', (int) (2050*1024*1024/10)], + ['2048M', (int) (2048*1024*1024/10)], + ['3G', (int) (3*1024*1024*1024/10)], + ['2560M', (int) (2560*1024*1024/10)], + ]; + } + /** - * @expectedException Exception - * @expectedExceptionMessageRegExp /There is no existing directory at/ - * @covers Monolog\Handler\StreamHandler::__construct - * @covers Monolog\Handler\StreamHandler::write + * @dataProvider provideMemoryValues */ - public function testWriteNonExistingAndNotCreatableFileResource() + public function testPreventOOMError($phpMemory, $expectedChunkSize): void { - if (defined('PHP_WINDOWS_VERSION_BUILD')) { - $this->markTestSkipped('Permissions checks can not run on windows'); + $previousValue = ini_set('memory_limit', $phpMemory); + + if ($previousValue === false) { + $this->markTestSkipped('We could not set a memory limit that would trigger the error.'); + } + + try { + $stream = tmpfile(); + + if ($stream === false) { + $this->markTestSkipped('We could not create a temp file to be use as a stream.'); + } + + $handler = new StreamHandler($stream); + stream_get_contents($stream, 1024); + + $this->assertEquals($expectedChunkSize, $handler->getStreamChunkSize()); + } finally { + ini_set('memory_limit', $previousValue); + } + } + + public function testSimpleOOMPrevention(): void + { + $previousValue = ini_set('memory_limit', '2048M'); + + if ($previousValue === false) { + $this->markTestSkipped('We could not set a memory limit that would trigger the error.'); + } + + try { + $stream = tmpfile(); + new StreamHandler($stream); + stream_get_contents($stream); + $this->assertTrue(true); + } finally { + ini_set('memory_limit', $previousValue); } - $handler = new StreamHandler('file:///foo/bar/'.rand(0, 10000).DIRECTORY_SEPARATOR.rand(0, 10000)); - $handler->handle($this->getRecord()); } } diff --git a/tests/Monolog/Handler/SwiftMailerHandlerTest.php b/tests/Monolog/Handler/SymfonyMailerHandlerTest.php similarity index 57% rename from tests/Monolog/Handler/SwiftMailerHandlerTest.php rename to tests/Monolog/Handler/SymfonyMailerHandlerTest.php index 3c77127b4..ea4b9e26f 100644 --- a/tests/Monolog/Handler/SwiftMailerHandlerTest.php +++ b/tests/Monolog/Handler/SymfonyMailerHandlerTest.php @@ -13,29 +13,39 @@ use Monolog\Logger; use Monolog\Test\TestCase; +use PHPUnit\Framework\MockObject\MockObject; +use Symfony\Component\Mailer\MailerInterface; +use Symfony\Component\Mime\Email; -class SwiftMailerHandlerTest extends TestCase +class SymfonyMailerHandlerTest extends TestCase { - /** @var \Swift_Mailer|\PHPUnit_Framework_MockObject_MockObject */ + /** @var MailerInterface&MockObject */ private $mailer; - public function setUp() + public function setUp(): void { $this->mailer = $this - ->getMockBuilder('Swift_Mailer') + ->getMockBuilder(MailerInterface::class) ->disableOriginalConstructor() ->getMock(); } + public function tearDown(): void + { + parent::tearDown(); + + unset($this->mailer); + } + public function testMessageCreationIsLazyWhenUsingCallback() { $this->mailer->expects($this->never()) ->method('send'); $callback = function () { - throw new \RuntimeException('Swift_Message creation callback should not have been called in this test'); + throw new \RuntimeException('Email creation callback should not have been called in this test'); }; - $handler = new SwiftMailerHandler($this->mailer, $callback); + $handler = new SymfonyMailerHandler($this->mailer, $callback); $records = [ $this->getRecord(Logger::DEBUG), @@ -46,12 +56,12 @@ public function testMessageCreationIsLazyWhenUsingCallback() public function testMessageCanBeCustomizedGivenLoggedData() { - // Wire Mailer to expect a specific Swift_Message with a customized Subject - $expectedMessage = new \Swift_Message(); + // Wire Mailer to expect a specific Email with a customized Subject + $expectedMessage = new Email(); $this->mailer->expects($this->once()) ->method('send') ->with($this->callback(function ($value) use ($expectedMessage) { - return $value instanceof \Swift_Message + return $value instanceof Email && $value->getSubject() === 'Emergency' && $value === $expectedMessage; })); @@ -59,11 +69,9 @@ public function testMessageCanBeCustomizedGivenLoggedData() // Callback dynamically changes subject based on number of logged records $callback = function ($content, array $records) use ($expectedMessage) { $subject = count($records) > 0 ? 'Emergency' : 'Normal'; - $expectedMessage->setSubject($subject); - - return $expectedMessage; + return $expectedMessage->subject($subject); }; - $handler = new SwiftMailerHandler($this->mailer, $callback); + $handler = new SymfonyMailerHandler($this->mailer, $callback); // Logging 1 record makes this an Emergency $records = [ @@ -74,9 +82,9 @@ public function testMessageCanBeCustomizedGivenLoggedData() public function testMessageSubjectFormatting() { - // Wire Mailer to expect a specific Swift_Message with a customized Subject - $messageTemplate = new \Swift_Message(); - $messageTemplate->setSubject('Alert: %level_name% %message%'); + // Wire Mailer to expect a specific Email with a customized Subject + $messageTemplate = new Email(); + $messageTemplate->subject('Alert: %level_name% %message%'); $receivedMessage = null; $this->mailer->expects($this->once()) @@ -87,7 +95,7 @@ public function testMessageSubjectFormatting() return true; })); - $handler = new SwiftMailerHandler($this->mailer, $messageTemplate); + $handler = new SymfonyMailerHandler($this->mailer, $messageTemplate); $records = [ $this->getRecord(Logger::EMERGENCY), @@ -96,19 +104,4 @@ public function testMessageSubjectFormatting() $this->assertEquals('Alert: EMERGENCY test', $receivedMessage->getSubject()); } - - public function testMessageHaveUniqueId() - { - $messageTemplate = new \Swift_Message(); - $handler = new SwiftMailerHandler($this->mailer, $messageTemplate); - - $method = new \ReflectionMethod('Monolog\Handler\SwiftMailerHandler', 'buildMessage'); - $method->setAccessible(true); - $method->invokeArgs($handler, [$messageTemplate, []]); - - $builtMessage1 = $method->invoke($handler, $messageTemplate, []); - $builtMessage2 = $method->invoke($handler, $messageTemplate, []); - - $this->assertFalse($builtMessage1->getId() === $builtMessage2->getId(), 'Two different messages have the same id'); - } } diff --git a/tests/Monolog/Handler/SyslogHandlerTest.php b/tests/Monolog/Handler/SyslogHandlerTest.php index 550e21050..18fb2bcb8 100644 --- a/tests/Monolog/Handler/SyslogHandlerTest.php +++ b/tests/Monolog/Handler/SyslogHandlerTest.php @@ -11,7 +11,7 @@ namespace Monolog\Handler; -use Monolog\Logger; +use Monolog\Level; class SyslogHandlerTest extends \PHPUnit\Framework\TestCase { @@ -29,7 +29,7 @@ public function testConstruct() $handler = new SyslogHandler('test', 'user'); $this->assertInstanceOf('Monolog\Handler\SyslogHandler', $handler); - $handler = new SyslogHandler('test', LOG_USER, Logger::DEBUG, true, LOG_PERROR); + $handler = new SyslogHandler('test', LOG_USER, Level::Debug, true, LOG_PERROR); $this->assertInstanceOf('Monolog\Handler\SyslogHandler', $handler); } @@ -38,7 +38,7 @@ public function testConstruct() */ public function testConstructInvalidFacility() { - $this->expectException('UnexpectedValueException'); + $this->expectException(\UnexpectedValueException::class); $handler = new SyslogHandler('test', 'unknown'); } } diff --git a/tests/Monolog/Handler/SyslogUdpHandlerTest.php b/tests/Monolog/Handler/SyslogUdpHandlerTest.php index 9f32d9103..f27d5ad57 100644 --- a/tests/Monolog/Handler/SyslogUdpHandlerTest.php +++ b/tests/Monolog/Handler/SyslogUdpHandlerTest.php @@ -11,6 +11,7 @@ namespace Monolog\Handler; +use Monolog\Level; use Monolog\Test\TestCase; /** @@ -18,40 +19,32 @@ */ class SyslogUdpHandlerTest extends TestCase { - /** - * @expectedException UnexpectedValueException - */ public function testWeValidateFacilities() { - $handler = new SyslogUdpHandler("ip", null, "invalidFacility"); + $this->expectException(\UnexpectedValueException::class); + + $handler = new SyslogUdpHandler("ip", 514, "invalidFacility"); } public function testWeSplitIntoLines() { - $time = '2014-01-07T12:34'; $pid = getmypid(); $host = gethostname(); - $handler = $this->getMockBuilder('\Monolog\Handler\SyslogUdpHandler') - ->setConstructorArgs(array("127.0.0.1", 514, "authpriv")) - ->setMethods(array('getDateTime')) - ->getMock(); - - $handler->method('getDateTime') - ->willReturn($time); - + $handler = new \Monolog\Handler\SyslogUdpHandler("127.0.0.1", 514, "authpriv"); $handler->setFormatter(new \Monolog\Formatter\ChromePHPFormatter()); + $time = '2014-01-07T12:34:56+00:00'; $socket = $this->getMockBuilder('Monolog\Handler\SyslogUdp\UdpSocket') - ->setMethods(['write']) - ->setConstructorArgs(['lol', 'lol']) + ->onlyMethods(['write']) + ->setConstructorArgs(['lol']) ->getMock(); - $socket->expects($this->at(0)) - ->method('write') - ->with("lol", "<".(LOG_AUTHPRIV + LOG_WARNING).">1 $time $host php $pid - - "); - $socket->expects($this->at(1)) + $socket->expects($this->atLeast(2)) ->method('write') - ->with("hej", "<".(LOG_AUTHPRIV + LOG_WARNING).">1 $time $host php $pid - - "); + ->withConsecutive( + [$this->equalTo("lol"), $this->equalTo("<".(LOG_AUTHPRIV + LOG_WARNING).">1 $time $host php $pid - - ")], + [$this->equalTo("hej"), $this->equalTo("<".(LOG_AUTHPRIV + LOG_WARNING).">1 $time $host php $pid - - ")], + ); $handler->setSocket($socket); @@ -64,19 +57,48 @@ public function testSplitWorksOnEmptyMsg() $handler->setFormatter($this->getIdentityFormatter()); $socket = $this->getMockBuilder('Monolog\Handler\SyslogUdp\UdpSocket') - ->setMethods(['write']) - ->setConstructorArgs(['lol', 'lol']) + ->onlyMethods(['write']) + ->setConstructorArgs(['lol']) ->getMock(); $socket->expects($this->never()) ->method('write'); $handler->setSocket($socket); - $handler->handle($this->getRecordWithMessage(null)); + $handler->handle($this->getRecordWithMessage('')); + } + + public function testRfc() + { + $time = 'Jan 07 12:34:56'; + $pid = getmypid(); + $host = gethostname(); + + $handler = $this->getMockBuilder('\Monolog\Handler\SyslogUdpHandler') + ->setConstructorArgs(["127.0.0.1", 514, "authpriv", 'debug', true, "php", \Monolog\Handler\SyslogUdpHandler::RFC3164]) + ->onlyMethods([]) + ->getMock(); + + $handler->setFormatter(new \Monolog\Formatter\ChromePHPFormatter()); + + $socket = $this->getMockBuilder('\Monolog\Handler\SyslogUdp\UdpSocket') + ->setConstructorArgs(['lol', 999]) + ->onlyMethods(['write']) + ->getMock(); + $socket->expects($this->atLeast(2)) + ->method('write') + ->withConsecutive( + [$this->equalTo("lol"), $this->equalTo("<".(LOG_AUTHPRIV + LOG_WARNING).">$time $host php[$pid]: ")], + [$this->equalTo("hej"), $this->equalTo("<".(LOG_AUTHPRIV + LOG_WARNING).">$time $host php[$pid]: ")], + ); + + $handler->setSocket($socket); + + $handler->handle($this->getRecordWithMessage("hej\nlol")); } protected function getRecordWithMessage($msg) { - return ['message' => $msg, 'level' => \Monolog\Logger::WARNING, 'context' => null, 'extra' => [], 'channel' => 'lol']; + return $this->getRecord(message: $msg, level: Level::Warning, channel: 'lol', datetime: new \DateTimeImmutable('2014-01-07 12:34:56')); } } diff --git a/tests/Monolog/Handler/TelegramBotHandlerTest.php b/tests/Monolog/Handler/TelegramBotHandlerTest.php new file mode 100644 index 000000000..5aa50c405 --- /dev/null +++ b/tests/Monolog/Handler/TelegramBotHandlerTest.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Handler; + +use Monolog\Level; +use Monolog\Test\TestCase; +use PHPUnit\Framework\MockObject\MockObject; + +/** + * @author Mazur Alexandr + * @link https://core.telegram.org/bots/api + */ +class TelegramBotHandlerTest extends TestCase +{ + private TelegramBotHandler&MockObject $handler; + + public function testSendTelegramRequest(): void + { + $this->createHandler(); + $this->handler->handle($this->getRecord()); + } + + private function createHandler( + string $apiKey = 'testKey', + string $channel = 'testChannel', + string $parseMode = 'Markdown', + bool $disableWebPagePreview = false, + bool $disableNotification = true + ): void { + $constructorArgs = [$apiKey, $channel, Level::Debug, true, $parseMode, $disableWebPagePreview, $disableNotification]; + + $this->handler = $this->getMockBuilder(TelegramBotHandler::class) + ->setConstructorArgs($constructorArgs) + ->onlyMethods(['send']) + ->getMock(); + + $this->handler->expects($this->atLeast(1)) + ->method('send'); + } + + public function testSetInvalidParseMode(): void + { + $this->expectException(\InvalidArgumentException::class); + + $handler = new TelegramBotHandler('testKey', 'testChannel'); + $handler->setParseMode('invalid parse mode'); + } + + public function testSetParseMode(): void + { + $handler = new TelegramBotHandler('testKey', 'testChannel'); + $handler->setParseMode('HTML'); + } +} diff --git a/tests/Monolog/Handler/TestHandlerTest.php b/tests/Monolog/Handler/TestHandlerTest.php index cc8e60ff2..218d111ce 100644 --- a/tests/Monolog/Handler/TestHandlerTest.php +++ b/tests/Monolog/Handler/TestHandlerTest.php @@ -11,8 +11,8 @@ namespace Monolog\Handler; +use Monolog\Level; use Monolog\Test\TestCase; -use Monolog\Logger; /** * @covers Monolog\Handler\TestHandler @@ -22,13 +22,13 @@ class TestHandlerTest extends TestCase /** * @dataProvider methodProvider */ - public function testHandler($method, $level) + public function testHandler($method, Level $level) { $handler = new TestHandler; $record = $this->getRecord($level, 'test'.$method); $this->assertFalse($handler->hasRecords($level)); - $this->assertFalse($handler->hasRecord($record, $level)); - $this->assertFalse($handler->{'has'.$method}($record), 'has'.$method); + $this->assertFalse($handler->hasRecord($record->message, $level)); + $this->assertFalse($handler->{'has'.$method}($record->message), 'has'.$method); $this->assertFalse($handler->{'has'.$method.'ThatContains'}('test'), 'has'.$method.'ThatContains'); $this->assertFalse($handler->{'has'.$method.'ThatPasses'}(function ($rec) { return true; @@ -39,8 +39,8 @@ public function testHandler($method, $level) $this->assertFalse($handler->{'has'.$method}('bar'), 'has'.$method); $this->assertTrue($handler->hasRecords($level)); - $this->assertTrue($handler->hasRecord($record, $level)); - $this->assertTrue($handler->{'has'.$method}($record), 'has'.$method); + $this->assertTrue($handler->hasRecord($record->message, $level)); + $this->assertTrue($handler->{'has'.$method}($record->message), 'has'.$method); $this->assertTrue($handler->{'has'.$method}('test'.$method), 'has'.$method); $this->assertTrue($handler->{'has'.$method.'ThatContains'}('test'), 'has'.$method.'ThatContains'); $this->assertTrue($handler->{'has'.$method.'ThatPasses'}(function ($rec) { @@ -50,14 +50,14 @@ public function testHandler($method, $level) $this->assertTrue($handler->{'has'.$method.'Records'}(), 'has'.$method.'Records'); $records = $handler->getRecords(); - unset($records[0]['formatted']); + $records[0]->formatted = null; $this->assertEquals([$record], $records); } public function testHandlerAssertEmptyContext() { $handler = new TestHandler; - $record = $this->getRecord(Logger::WARNING, 'test', []); + $record = $this->getRecord(Level::Warning, 'test', []); $this->assertFalse($handler->hasWarning([ 'message' => 'test', 'context' => [], @@ -80,7 +80,7 @@ public function testHandlerAssertEmptyContext() public function testHandlerAssertNonEmptyContext() { $handler = new TestHandler; - $record = $this->getRecord(Logger::WARNING, 'test', ['foo' => 'bar']); + $record = $this->getRecord(Level::Warning, 'test', ['foo' => 'bar']); $this->assertFalse($handler->hasWarning([ 'message' => 'test', 'context' => [ @@ -105,14 +105,14 @@ public function testHandlerAssertNonEmptyContext() public function methodProvider() { return [ - ['Emergency', Logger::EMERGENCY], - ['Alert' , Logger::ALERT], - ['Critical' , Logger::CRITICAL], - ['Error' , Logger::ERROR], - ['Warning' , Logger::WARNING], - ['Info' , Logger::INFO], - ['Notice' , Logger::NOTICE], - ['Debug' , Logger::DEBUG], + ['Emergency', Level::Emergency], + ['Alert' , Level::Alert], + ['Critical' , Level::Critical], + ['Error' , Level::Error], + ['Warning' , Level::Warning], + ['Info' , Level::Info], + ['Notice' , Level::Notice], + ['Debug' , Level::Debug], ]; } } diff --git a/tests/Monolog/Handler/UdpSocketTest.php b/tests/Monolog/Handler/UdpSocketTest.php index 1adf79a0e..986a3ddb8 100644 --- a/tests/Monolog/Handler/UdpSocketTest.php +++ b/tests/Monolog/Handler/UdpSocketTest.php @@ -11,8 +11,8 @@ namespace Monolog\Handler; -use Monolog\Test\TestCase; use Monolog\Handler\SyslogUdp\UdpSocket; +use Monolog\Test\TestCase; /** * @requires extension sockets @@ -22,11 +22,11 @@ class UdpSocketTest extends TestCase public function testWeDoNotTruncateShortMessages() { $socket = $this->getMockBuilder('Monolog\Handler\SyslogUdp\UdpSocket') - ->setMethods(['send']) - ->setConstructorArgs(['lol', 'lol']) + ->onlyMethods(['send']) + ->setConstructorArgs(['lol']) ->getMock(); - $socket->expects($this->at(0)) + $socket ->method('send') ->with("HEADER: The quick brown fox jumps over the lazy dog"); @@ -36,8 +36,8 @@ public function testWeDoNotTruncateShortMessages() public function testLongMessagesAreTruncated() { $socket = $this->getMockBuilder('Monolog\Handler\SyslogUdp\UdpSocket') - ->setMethods(['send']) - ->setConstructorArgs(['lol', 'lol']) + ->onlyMethods(['send']) + ->setConstructorArgs(['lol']) ->getMock(); $truncatedString = str_repeat("derp", 16254).'d'; @@ -58,10 +58,7 @@ public function testDoubleCloseDoesNotError() $socket->close(); } - /** - * @expectedException RuntimeException - */ - public function testWriteAfterCloseErrors() + public function testWriteAfterCloseReopened() { $socket = new UdpSocket('127.0.0.1', 514); $socket->close(); diff --git a/tests/Monolog/Handler/WhatFailureGroupHandlerTest.php b/tests/Monolog/Handler/WhatFailureGroupHandlerTest.php index 475889e20..e28403ae3 100644 --- a/tests/Monolog/Handler/WhatFailureGroupHandlerTest.php +++ b/tests/Monolog/Handler/WhatFailureGroupHandlerTest.php @@ -12,16 +12,17 @@ namespace Monolog\Handler; use Monolog\Test\TestCase; -use Monolog\Logger; +use Monolog\Level; class WhatFailureGroupHandlerTest extends TestCase { /** * @covers Monolog\Handler\WhatFailureGroupHandler::__construct - * @expectedException InvalidArgumentException */ public function testConstructorOnlyTakesHandler() { + $this->expectException(\InvalidArgumentException::class); + new WhatFailureGroupHandler([new TestHandler(), "foo"]); } @@ -33,12 +34,12 @@ public function testHandle() { $testHandlers = [new TestHandler(), new TestHandler()]; $handler = new WhatFailureGroupHandler($testHandlers); - $handler->handle($this->getRecord(Logger::DEBUG)); - $handler->handle($this->getRecord(Logger::INFO)); + $handler->handle($this->getRecord(Level::Debug)); + $handler->handle($this->getRecord(Level::Info)); foreach ($testHandlers as $test) { $this->assertTrue($test->hasDebugRecords()); $this->assertTrue($test->hasInfoRecords()); - $this->assertTrue(count($test->getRecords()) === 2); + $this->assertCount(2, $test->getRecords()); } } @@ -49,11 +50,11 @@ public function testHandleBatch() { $testHandlers = [new TestHandler(), new TestHandler()]; $handler = new WhatFailureGroupHandler($testHandlers); - $handler->handleBatch([$this->getRecord(Logger::DEBUG), $this->getRecord(Logger::INFO)]); + $handler->handleBatch([$this->getRecord(Level::Debug), $this->getRecord(Level::Info)]); foreach ($testHandlers as $test) { $this->assertTrue($test->hasDebugRecords()); $this->assertTrue($test->hasInfoRecords()); - $this->assertTrue(count($test->getRecords()) === 2); + $this->assertCount(2, $test->getRecords()); } } @@ -62,11 +63,11 @@ public function testHandleBatch() */ public function testIsHandling() { - $testHandlers = [new TestHandler(Logger::ERROR), new TestHandler(Logger::WARNING)]; + $testHandlers = [new TestHandler(Level::Error), new TestHandler(Level::Warning)]; $handler = new WhatFailureGroupHandler($testHandlers); - $this->assertTrue($handler->isHandling($this->getRecord(Logger::ERROR))); - $this->assertTrue($handler->isHandling($this->getRecord(Logger::WARNING))); - $this->assertFalse($handler->isHandling($this->getRecord(Logger::DEBUG))); + $this->assertTrue($handler->isHandling($this->getRecord(Level::Error))); + $this->assertTrue($handler->isHandling($this->getRecord(Level::Warning))); + $this->assertFalse($handler->isHandling($this->getRecord(Level::Debug))); } /** @@ -77,11 +78,11 @@ public function testHandleUsesProcessors() $test = new TestHandler(); $handler = new WhatFailureGroupHandler([$test]); $handler->pushProcessor(function ($record) { - $record['extra']['foo'] = true; + $record->extra['foo'] = true; return $record; }); - $handler->handle($this->getRecord(Logger::WARNING)); + $handler->handle($this->getRecord(Level::Warning)); $this->assertTrue($test->hasWarningRecords()); $records = $test->getRecords(); $this->assertTrue($records[0]['extra']['foo']); @@ -92,21 +93,28 @@ public function testHandleUsesProcessors() */ public function testHandleBatchUsesProcessors() { - $testHandlers = array(new TestHandler(), new TestHandler()); + $testHandlers = [new TestHandler(), new TestHandler()]; $handler = new WhatFailureGroupHandler($testHandlers); $handler->pushProcessor(function ($record) { - $record['extra']['foo'] = true; + $record->extra['foo'] = true; return $record; }); - $handler->handleBatch(array($this->getRecord(Logger::DEBUG), $this->getRecord(Logger::INFO))); + $handler->pushProcessor(function ($record) { + $record->extra['foo2'] = true; + + return $record; + }); + $handler->handleBatch([$this->getRecord(Level::Debug), $this->getRecord(Level::Info)]); foreach ($testHandlers as $test) { $this->assertTrue($test->hasDebugRecords()); $this->assertTrue($test->hasInfoRecords()); - $this->assertTrue(count($test->getRecords()) === 2); + $this->assertCount(2, $test->getRecords()); $records = $test->getRecords(); $this->assertTrue($records[0]['extra']['foo']); $this->assertTrue($records[1]['extra']['foo']); + $this->assertTrue($records[0]['extra']['foo2']); + $this->assertTrue($records[1]['extra']['foo2']); } } @@ -119,26 +127,13 @@ public function testHandleException() $exception = new ExceptionTestHandler(); $handler = new WhatFailureGroupHandler([$exception, $test, $exception]); $handler->pushProcessor(function ($record) { - $record['extra']['foo'] = true; + $record->extra['foo'] = true; return $record; }); - $handler->handle($this->getRecord(Logger::WARNING)); + $handler->handle($this->getRecord(Level::Warning)); $this->assertTrue($test->hasWarningRecords()); $records = $test->getRecords(); $this->assertTrue($records[0]['extra']['foo']); } } - -class ExceptionTestHandler extends TestHandler -{ - /** - * {@inheritdoc} - */ - public function handle(array $record): bool - { - parent::handle($record); - - throw new \Exception("ExceptionTestHandler::handle"); - } -} diff --git a/tests/Monolog/Handler/ZendMonitorHandlerTest.php b/tests/Monolog/Handler/ZendMonitorHandlerTest.php index 4879ebef3..c79aa4392 100644 --- a/tests/Monolog/Handler/ZendMonitorHandlerTest.php +++ b/tests/Monolog/Handler/ZendMonitorHandlerTest.php @@ -15,15 +15,20 @@ class ZendMonitorHandlerTest extends TestCase { - protected $zendMonitorHandler; - - public function setUp() + public function setUp(): void { if (!function_exists('zend_monitor_custom_event')) { $this->markTestSkipped('ZendServer is not installed'); } } + public function tearDown(): void + { + parent::tearDown(); + + unset($this->zendMonitorHandler); + } + /** * @covers Monolog\Handler\ZendMonitorHandler::write */ @@ -31,11 +36,11 @@ public function testWrite() { $record = $this->getRecord(); $formatterResult = [ - 'message' => $record['message'], + 'message' => $record->message, ]; $zendMonitor = $this->getMockBuilder('Monolog\Handler\ZendMonitorHandler') - ->setMethods(['writeZendMonitorCustomEvent', 'getDefaultFormatter']) + ->onlyMethods(['writeZendMonitorCustomEvent', 'getDefaultFormatter']) ->getMock(); $formatterMock = $this->getMockBuilder('Monolog\Formatter\NormalizerFormatter') @@ -50,11 +55,14 @@ public function testWrite() ->method('getDefaultFormatter') ->will($this->returnValue($formatterMock)); - $levelMap = $zendMonitor->getLevelMap(); - $zendMonitor->expects($this->once()) ->method('writeZendMonitorCustomEvent') - ->with($levelMap[$record['level']], $record['message'], $formatterResult); + ->with( + $record->level->getName(), + $record->message, + $formatterResult, + \ZEND_MONITOR_EVENT_SEVERITY_WARNING + ); $zendMonitor->handle($record); } diff --git a/tests/Monolog/LoggerTest.php b/tests/Monolog/LoggerTest.php index 69ea40102..697518e20 100644 --- a/tests/Monolog/LoggerTest.php +++ b/tests/Monolog/LoggerTest.php @@ -11,13 +11,15 @@ namespace Monolog; +use Monolog\Handler\HandlerInterface; use Monolog\Processor\WebProcessor; use Monolog\Handler\TestHandler; +use Monolog\Test\TestCase; -class LoggerTest extends \PHPUnit\Framework\TestCase +class LoggerTest extends TestCase { /** - * @covers Monolog\Logger::getName + * @covers Logger::getName */ public function testGetName() { @@ -26,15 +28,7 @@ public function testGetName() } /** - * @covers Monolog\Logger::getLevelName - */ - public function testGetLevelName() - { - $this->assertEquals('ERROR', Logger::getLevelName(Logger::ERROR)); - } - - /** - * @covers Monolog\Logger::withName + * @covers Logger::withName */ public function testWithName() { @@ -47,31 +41,53 @@ public function testWithName() } /** - * @covers Monolog\Logger::toMonologLevel + * @covers Logger::toMonologLevel */ public function testConvertPSR3ToMonologLevel() { - $this->assertEquals(Logger::toMonologLevel('debug'), 100); - $this->assertEquals(Logger::toMonologLevel('info'), 200); - $this->assertEquals(Logger::toMonologLevel('notice'), 250); - $this->assertEquals(Logger::toMonologLevel('warning'), 300); - $this->assertEquals(Logger::toMonologLevel('error'), 400); - $this->assertEquals(Logger::toMonologLevel('critical'), 500); - $this->assertEquals(Logger::toMonologLevel('alert'), 550); - $this->assertEquals(Logger::toMonologLevel('emergency'), 600); + $this->assertEquals(Logger::toMonologLevel('debug'), Level::Debug); + $this->assertEquals(Logger::toMonologLevel('info'), Level::Info); + $this->assertEquals(Logger::toMonologLevel('notice'), Level::Notice); + $this->assertEquals(Logger::toMonologLevel('warning'), Level::Warning); + $this->assertEquals(Logger::toMonologLevel('error'), Level::Error); + $this->assertEquals(Logger::toMonologLevel('critical'), Level::Critical); + $this->assertEquals(Logger::toMonologLevel('alert'), Level::Alert); + $this->assertEquals(Logger::toMonologLevel('emergency'), Level::Emergency); } /** - * @covers Monolog\Logger::getLevelName - * @expectedException InvalidArgumentException + * @covers Monolog\Logger::addRecord + * @covers Monolog\Logger::log */ - public function testGetLevelNameThrows() + public function testConvertRFC5424ToMonologLevelInAddRecordAndLog() { - Logger::getLevelName(5); + $logger = new Logger('test'); + $handler = new TestHandler; + $logger->pushHandler($handler); + + foreach ([ + 7 => 100, + 6 => 200, + 5 => 250, + 4 => 300, + 3 => 400, + 2 => 500, + 1 => 550, + 0 => 600, + ] as $rfc5424Level => $monologLevel) { + $handler->reset(); + $logger->addRecord($rfc5424Level, 'test'); + $logger->log($rfc5424Level, 'test'); + $records = $handler->getRecords(); + + self::assertCount(2, $records); + self::assertSame($monologLevel, $records[0]['level']); + self::assertSame($monologLevel, $records[1]['level']); + } } /** - * @covers Monolog\Logger::__construct + * @covers Logger::__construct */ public function testChannel() { @@ -80,7 +96,29 @@ public function testChannel() $logger->pushHandler($handler); $logger->warning('test'); list($record) = $handler->getRecords(); - $this->assertEquals('foo', $record['channel']); + $this->assertEquals('foo', $record->channel); + } + + /** + * @covers Logger::addRecord + */ + public function testLogPreventsCircularLogging() + { + $logger = new Logger(__METHOD__); + + $loggingHandler = new LoggingHandler($logger); + $testHandler = new TestHandler(); + + $logger->pushHandler($loggingHandler); + $logger->pushHandler($testHandler); + + $logger->addRecord(Level::Alert, 'test'); + + $records = $testHandler->getRecords(); + $this->assertCount(3, $records); + $this->assertSame('ALERT', $records[0]->level->getName()); + $this->assertSame('DEBUG', $records[1]->level->getName()); + $this->assertSame('WARNING', $records[2]->level->getName()); } /** @@ -90,29 +128,46 @@ public function testLog() { $logger = new Logger(__METHOD__); - $handler = $this->prophesize('Monolog\Handler\NullHandler'); - $handler->handle(\Prophecy\Argument::any())->shouldBeCalled(); - $handler->isHandling(['level' => 300])->willReturn(true); + $handler = $this->getMockBuilder('Monolog\Handler\HandlerInterface')->getMock(); + $handler->expects($this->never())->method('isHandling'); + $handler->expects($this->once())->method('handle'); - $logger->pushHandler($handler->reveal()); + $logger->pushHandler($handler); - $this->assertTrue($logger->addRecord(Logger::WARNING, 'test')); + $this->assertTrue($logger->addRecord(Level::Warning, 'test')); } /** - * @covers Monolog\Logger::addRecord + * @covers Logger::addRecord + */ + public function testLogAlwaysHandledIfNoProcessorsArePresent() + { + $logger = new Logger(__METHOD__); + + $handler = $this->getMockBuilder('Monolog\Handler\HandlerInterface')->getMock(); + $handler->expects($this->never())->method('isHandling'); + $handler->expects($this->once())->method('handle'); + + $logger->pushHandler($handler); + + $this->assertTrue($logger->addRecord(Level::Warning, 'test')); + } + + /** + * @covers Logger::addRecord */ - public function testLogNotHandled() + public function testLogNotHandledIfProcessorsArePresent() { $logger = new Logger(__METHOD__); - $handler = $this->prophesize('Monolog\Handler\NullHandler'); - $handler->handle()->shouldNotBeCalled(); - $handler->isHandling(['level' => 300])->willReturn(false); + $handler = $this->getMockBuilder('Monolog\Handler\HandlerInterface')->getMock(); + $handler->expects($this->once())->method('isHandling')->will($this->returnValue(false)); + $handler->expects($this->never())->method('handle'); - $logger->pushHandler($handler->reveal()); + $logger->pushProcessor(fn (LogRecord $record) => $record); + $logger->pushHandler($handler); - $this->assertFalse($logger->addRecord(Logger::WARNING, 'test')); + $this->assertFalse($logger->addRecord(Level::Warning, 'test')); } public function testHandlersInCtor() @@ -136,9 +191,8 @@ public function testProcessorsInCtor() } /** - * @covers Monolog\Logger::pushHandler - * @covers Monolog\Logger::popHandler - * @expectedException LogicException + * @covers Logger::pushHandler + * @covers Logger::popHandler */ public function testPushPopHandler() { @@ -151,11 +205,14 @@ public function testPushPopHandler() $this->assertEquals($handler2, $logger->popHandler()); $this->assertEquals($handler1, $logger->popHandler()); + + $this->expectException(\LogicException::class); + $logger->popHandler(); } /** - * @covers Monolog\Logger::setHandlers + * @covers Logger::setHandlers */ public function testSetHandlers() { @@ -179,9 +236,8 @@ public function testSetHandlers() } /** - * @covers Monolog\Logger::pushProcessor - * @covers Monolog\Logger::popProcessor - * @expectedException LogicException + * @covers Logger::pushProcessor + * @covers Logger::popProcessor */ public function testPushPopProcessor() { @@ -194,11 +250,14 @@ public function testPushPopProcessor() $this->assertEquals($processor2, $logger->popProcessor()); $this->assertEquals($processor1, $logger->popProcessor()); + + $this->expectException(\LogicException::class); + $logger->popProcessor(); } /** - * @covers Monolog\Logger::addRecord + * @covers Logger::addRecord */ public function testProcessorsAreExecuted() { @@ -206,17 +265,17 @@ public function testProcessorsAreExecuted() $handler = new TestHandler; $logger->pushHandler($handler); $logger->pushProcessor(function ($record) { - $record['extra']['win'] = true; + $record->extra['win'] = true; return $record; }); $logger->error('test'); list($record) = $handler->getRecords(); - $this->assertTrue($record['extra']['win']); + $this->assertTrue($record->extra['win']); } /** - * @covers Monolog\Logger::addRecord + * @covers Logger::addRecord */ public function testProcessorsAreCalledOnlyOnce() { @@ -234,7 +293,7 @@ public function testProcessorsAreCalledOnlyOnce() $processor = $this->getMockBuilder('Monolog\Processor\WebProcessor') ->disableOriginalConstructor() - ->setMethods(['__invoke']) + ->onlyMethods(['__invoke']) ->getMock() ; $processor->expects($this->once()) @@ -247,7 +306,7 @@ public function testProcessorsAreCalledOnlyOnce() } /** - * @covers Monolog\Logger::addRecord + * @covers Logger::addRecord */ public function testProcessorsNotCalledWhenNotHandled() { @@ -266,11 +325,12 @@ public function testProcessorsNotCalledWhenNotHandled() } /** - * @covers Monolog\Logger::addRecord + * @covers Logger::addRecord */ - public function testHandlersNotCalledBeforeFirstHandling() + public function testHandlersNotCalledBeforeFirstHandlingWhenProcessorsPresent() { $logger = new Logger(__METHOD__); + $logger->pushProcessor(fn ($record) => $record); $handler1 = $this->createMock('Monolog\Handler\HandlerInterface'); $handler1->expects($this->never()) @@ -308,9 +368,9 @@ public function testHandlersNotCalledBeforeFirstHandling() } /** - * @covers Monolog\Logger::addRecord + * @covers Logger::addRecord */ - public function testHandlersNotCalledBeforeFirstHandlingWithAssocArray() + public function testHandlersNotCalledBeforeFirstHandlingWhenProcessorsPresentWithAssocArray() { $handler1 = $this->createMock('Monolog\Handler\HandlerInterface'); $handler1->expects($this->never()) @@ -342,12 +402,13 @@ public function testHandlersNotCalledBeforeFirstHandlingWithAssocArray() ; $logger = new Logger(__METHOD__, ['last' => $handler3, 'second' => $handler2, 'first' => $handler1]); + $logger->pushProcessor(fn ($record) => $record); $logger->debug('test'); } /** - * @covers Monolog\Logger::addRecord + * @covers Logger::addRecord */ public function testBubblingWhenTheHandlerReturnsFalse() { @@ -379,7 +440,7 @@ public function testBubblingWhenTheHandlerReturnsFalse() } /** - * @covers Monolog\Logger::addRecord + * @covers Logger::addRecord */ public function testNotBubblingWhenTheHandlerReturnsTrue() { @@ -410,7 +471,7 @@ public function testNotBubblingWhenTheHandlerReturnsTrue() } /** - * @covers Monolog\Logger::isHandling + * @covers Logger::isHandling */ public function testIsHandling() { @@ -423,7 +484,7 @@ public function testIsHandling() ; $logger->pushHandler($handler1); - $this->assertFalse($logger->isHandling(Logger::DEBUG)); + $this->assertFalse($logger->isHandling(Level::Debug)); $handler2 = $this->createMock('Monolog\Handler\HandlerInterface'); $handler2->expects($this->any()) @@ -432,48 +493,48 @@ public function testIsHandling() ; $logger->pushHandler($handler2); - $this->assertTrue($logger->isHandling(Logger::DEBUG)); + $this->assertTrue($logger->isHandling(Level::Debug)); } /** * @dataProvider logMethodProvider - * @covers Monolog\Logger::debug - * @covers Monolog\Logger::info - * @covers Monolog\Logger::notice - * @covers Monolog\Logger::warning - * @covers Monolog\Logger::error - * @covers Monolog\Logger::critical - * @covers Monolog\Logger::alert - * @covers Monolog\Logger::emergency + * @covers Level::Debug + * @covers Level::Info + * @covers Level::Notice + * @covers Level::Warning + * @covers Level::Error + * @covers Level::Critical + * @covers Level::Alert + * @covers Level::Emergency */ - public function testLogMethods($method, $expectedLevel) + public function testLogMethods(string $method, Level $expectedLevel) { $logger = new Logger('foo'); $handler = new TestHandler; $logger->pushHandler($handler); $logger->{$method}('test'); list($record) = $handler->getRecords(); - $this->assertEquals($expectedLevel, $record['level']); + $this->assertEquals($expectedLevel, $record->level); } public function logMethodProvider() { return [ // PSR-3 methods - ['debug', Logger::DEBUG], - ['info', Logger::INFO], - ['notice', Logger::NOTICE], - ['warning', Logger::WARNING], - ['error', Logger::ERROR], - ['critical', Logger::CRITICAL], - ['alert', Logger::ALERT], - ['emergency', Logger::EMERGENCY], + ['debug', Level::Debug], + ['info', Level::Info], + ['notice', Level::Notice], + ['warning', Level::Warning], + ['error', Level::Error], + ['critical', Level::Critical], + ['alert', Level::Alert], + ['emergency', Level::Emergency], ]; } /** * @dataProvider setTimezoneProvider - * @covers Monolog\Logger::setTimezone + * @covers Logger::setTimezone */ public function testSetTimezone($tz) { @@ -483,7 +544,7 @@ public function testSetTimezone($tz) $logger->pushHandler($handler); $logger->info('test'); list($record) = $handler->getRecords(); - $this->assertEquals($tz, $record['datetime']->getTimezone()); + $this->assertEquals($tz, $record->datetime->getTimezone()); } public function setTimezoneProvider() @@ -497,8 +558,8 @@ function ($tz) { } /** - * @covers Monolog\Logger::setTimezone - * @covers Monolog\DateTimeImmutable::__construct + * @covers Logger::setTimezone + * @covers DateTimeImmutable::__construct */ public function testTimezoneIsRespectedInUTC() { @@ -513,14 +574,14 @@ public function testTimezoneIsRespectedInUTC() $logger->info('test'); list($record) = $handler->getRecords(); - $this->assertEquals($tz, $record['datetime']->getTimezone()); - $this->assertEquals($dt->format('Y/m/d H:i'), $record['datetime']->format('Y/m/d H:i'), 'Time should match timezone with microseconds set to: '.var_export($microseconds, true)); + $this->assertEquals($tz, $record->datetime->getTimezone()); + $this->assertEquals($dt->format('Y/m/d H:i'), $record->datetime->format('Y/m/d H:i'), 'Time should match timezone with microseconds set to: '.var_export($microseconds, true)); } } /** - * @covers Monolog\Logger::setTimezone - * @covers Monolog\DateTimeImmutable::__construct + * @covers Logger::setTimezone + * @covers DateTimeImmutable::__construct */ public function testTimezoneIsRespectedInOtherTimezone() { @@ -536,20 +597,20 @@ public function testTimezoneIsRespectedInOtherTimezone() $logger->info('test'); list($record) = $handler->getRecords(); - $this->assertEquals($tz, $record['datetime']->getTimezone()); - $this->assertEquals($dt->format('Y/m/d H:i'), $record['datetime']->format('Y/m/d H:i'), 'Time should match timezone with microseconds set to: '.var_export($microseconds, true)); + $this->assertEquals($tz, $record->datetime->getTimezone()); + $this->assertEquals($dt->format('Y/m/d H:i'), $record->datetime->format('Y/m/d H:i'), 'Time should match timezone with microseconds set to: '.var_export($microseconds, true)); } } - public function tearDown() + public function tearDown(): void { date_default_timezone_set('UTC'); } /** * @dataProvider useMicrosecondTimestampsProvider - * @covers Monolog\Logger::useMicrosecondTimestamps - * @covers Monolog\Logger::addRecord + * @covers Logger::useMicrosecondTimestamps + * @covers Logger::addRecord */ public function testUseMicrosecondTimestamps($micro, $assert, $assertFormat) { @@ -563,8 +624,8 @@ public function testUseMicrosecondTimestamps($micro, $assert, $assertFormat) $logger->pushHandler($handler); $logger->info('test'); list($record) = $handler->getRecords(); - $this->{$assert}('000000', $record['datetime']->format('u')); - $this->assertSame($record['datetime']->format($assertFormat), (string) $record['datetime']); + $this->{$assert}('000000', $record->datetime->format('u')); + $this->assertSame($record->datetime->format($assertFormat), (string) $record->datetime); } public function useMicrosecondTimestampsProvider() @@ -573,12 +634,12 @@ public function useMicrosecondTimestampsProvider() // this has a very small chance of a false negative (1/10^6) 'with microseconds' => [true, 'assertNotSame', 'Y-m-d\TH:i:s.uP'], // php 7.1 always includes microseconds, so we keep them in, but we format the datetime without - 'without microseconds' => [false, PHP_VERSION_ID >= 70100 ? 'assertNotSame' : 'assertSame', 'Y-m-d\TH:i:sP'], + 'without microseconds' => [false, 'assertNotSame', 'Y-m-d\TH:i:sP'], ]; } /** - * @covers Monolog\Logger::setExceptionHandler + * @covers Logger::setExceptionHandler */ public function testSetExceptionHandler() { @@ -591,8 +652,7 @@ public function testSetExceptionHandler() } /** - * @covers Monolog\Logger::handleException - * @expectedException Exception + * @covers Logger::handleException */ public function testDefaultHandleException() { @@ -606,13 +666,16 @@ public function testDefaultHandleException() ->method('handle') ->will($this->throwException(new \Exception('Some handler exception'))) ; + + $this->expectException(\Exception::class); + $logger->pushHandler($handler); $logger->info('test'); } /** - * @covers Monolog\Logger::handleException - * @covers Monolog\Logger::addRecord + * @covers Logger::handleException + * @covers Logger::addRecord */ public function testCustomHandleException() { @@ -620,8 +683,8 @@ public function testCustomHandleException() $that = $this; $logger->setExceptionHandler(function ($e, $record) use ($that) { $that->assertEquals($e->getMessage(), 'Some handler exception'); - $that->assertTrue(is_array($record)); - $that->assertEquals($record['message'], 'test'); + $that->assertInstanceOf(LogRecord::class, $record); + $that->assertEquals($record->message, 'test'); }); $handler = $this->getMockBuilder('Monolog\Handler\HandlerInterface')->getMock(); $handler->expects($this->any()) @@ -635,4 +698,132 @@ public function testCustomHandleException() $logger->pushHandler($handler); $logger->info('test'); } + + public function testReset() + { + $logger = new Logger('app'); + + $testHandler = new Handler\TestHandler(); + $testHandler->setSkipReset(true); + $bufferHandler = new Handler\BufferHandler($testHandler); + $groupHandler = new Handler\GroupHandler([$bufferHandler]); + $fingersCrossedHandler = new Handler\FingersCrossedHandler($groupHandler); + + $logger->pushHandler($fingersCrossedHandler); + + $processorUid1 = new Processor\UidProcessor(10); + $uid1 = $processorUid1->getUid(); + $groupHandler->pushProcessor($processorUid1); + + $processorUid2 = new Processor\UidProcessor(5); + $uid2 = $processorUid2->getUid(); + $logger->pushProcessor($processorUid2); + + $getProperty = function ($object, $property) { + $reflectionProperty = new \ReflectionProperty(get_class($object), $property); + $reflectionProperty->setAccessible(true); + + return $reflectionProperty->getValue($object); + }; + $assertBufferOfBufferHandlerEmpty = function () use ($getProperty, $bufferHandler) { + self::assertEmpty($getProperty($bufferHandler, 'buffer')); + }; + $assertBuffersEmpty = function () use ($assertBufferOfBufferHandlerEmpty, $getProperty, $fingersCrossedHandler) { + $assertBufferOfBufferHandlerEmpty(); + self::assertEmpty($getProperty($fingersCrossedHandler, 'buffer')); + }; + + $logger->debug('debug1'); + $logger->reset(); + $assertBuffersEmpty(); + $this->assertFalse($testHandler->hasDebugRecords()); + $this->assertFalse($testHandler->hasErrorRecords()); + $this->assertNotSame($uid1, $uid1 = $processorUid1->getUid()); + $this->assertNotSame($uid2, $uid2 = $processorUid2->getUid()); + + $logger->debug('debug2'); + $logger->error('error2'); + $logger->reset(); + $assertBuffersEmpty(); + $this->assertTrue($testHandler->hasRecordThatContains('debug2', Level::Debug)); + $this->assertTrue($testHandler->hasRecordThatContains('error2', Level::Error)); + $this->assertNotSame($uid1, $uid1 = $processorUid1->getUid()); + $this->assertNotSame($uid2, $uid2 = $processorUid2->getUid()); + + $logger->info('info3'); + $this->assertNotEmpty($getProperty($fingersCrossedHandler, 'buffer')); + $assertBufferOfBufferHandlerEmpty(); + $this->assertFalse($testHandler->hasInfoRecords()); + + $logger->reset(); + $assertBuffersEmpty(); + $this->assertFalse($testHandler->hasInfoRecords()); + $this->assertNotSame($uid1, $uid1 = $processorUid1->getUid()); + $this->assertNotSame($uid2, $uid2 = $processorUid2->getUid()); + + $logger->notice('notice4'); + $logger->emergency('emergency4'); + $logger->reset(); + $assertBuffersEmpty(); + $this->assertFalse($testHandler->hasInfoRecords()); + $this->assertTrue($testHandler->hasRecordThatContains('notice4', Level::Notice)); + $this->assertTrue($testHandler->hasRecordThatContains('emergency4', Level::Emergency)); + $this->assertNotSame($uid1, $processorUid1->getUid()); + $this->assertNotSame($uid2, $processorUid2->getUid()); + } + + /** + * @covers Logger::addRecord + */ + public function testLogWithDateTime() + { + foreach ([true, false] as $microseconds) { + $logger = new Logger(__METHOD__); + + $loggingHandler = new LoggingHandler($logger); + $testHandler = new TestHandler(); + + $logger->pushHandler($loggingHandler); + $logger->pushHandler($testHandler); + + $datetime = (new DateTimeImmutable($microseconds))->modify('2022-03-04 05:06:07'); + $logger->addRecord(Level::Debug, 'test', [], $datetime); + + list($record) = $testHandler->getRecords(); + $this->assertEquals($datetime->format('Y-m-d H:i:s'), $record->datetime->format('Y-m-d H:i:s')); + } + } +} + +class LoggingHandler implements HandlerInterface +{ + /** + * @var Logger + */ + private $logger; + + public function __construct(Logger $logger) + { + $this->logger = $logger; + } + + public function isHandling(LogRecord $record): bool + { + return true; + } + + public function handle(LogRecord $record): bool + { + $this->logger->debug('Log triggered while logging'); + + return false; + } + + public function handleBatch(array $records): void + { + } + + public function close(): void + { + } } diff --git a/tests/Monolog/Processor/GitProcessorTest.php b/tests/Monolog/Processor/GitProcessorTest.php index 0059e6d39..a8fc3d007 100644 --- a/tests/Monolog/Processor/GitProcessorTest.php +++ b/tests/Monolog/Processor/GitProcessorTest.php @@ -11,6 +11,7 @@ namespace Monolog\Processor; +use Monolog\Level; use Monolog\Test\TestCase; class GitProcessorTest extends TestCase @@ -23,7 +24,18 @@ public function testProcessor() $processor = new GitProcessor(); $record = $processor($this->getRecord()); - $this->assertArrayHasKey('git', $record['extra']); - $this->assertTrue(!is_array($record['extra']['git']['branch'])); + $this->assertArrayHasKey('git', $record->extra); + $this->assertTrue(!is_array($record->extra['git']['branch'])); + } + + /** + * @covers Monolog\Processor\GitProcessor::__invoke + */ + public function testProcessorWithLevel() + { + $processor = new GitProcessor(Level::Error); + $record = $processor($this->getRecord()); + + $this->assertArrayNotHasKey('git', $record->extra); } } diff --git a/tests/Monolog/Processor/HostnameProcessorTest.php b/tests/Monolog/Processor/HostnameProcessorTest.php index 1659851b0..2329ea858 100644 --- a/tests/Monolog/Processor/HostnameProcessorTest.php +++ b/tests/Monolog/Processor/HostnameProcessorTest.php @@ -22,9 +22,9 @@ public function testProcessor() { $processor = new HostnameProcessor(); $record = $processor($this->getRecord()); - $this->assertArrayHasKey('hostname', $record['extra']); - $this->assertInternalType('string', $record['extra']['hostname']); - $this->assertNotEmpty($record['extra']['hostname']); - $this->assertEquals(gethostname(), $record['extra']['hostname']); + $this->assertArrayHasKey('hostname', $record->extra); + $this->assertIsString($record->extra['hostname']); + $this->assertNotEmpty($record->extra['hostname']); + $this->assertEquals(gethostname(), $record->extra['hostname']); } } diff --git a/tests/Monolog/Processor/IntrospectionProcessorTest.php b/tests/Monolog/Processor/IntrospectionProcessorTest.php index 5f5d9aea9..d18dbd00a 100644 --- a/tests/Monolog/Processor/IntrospectionProcessorTest.php +++ b/tests/Monolog/Processor/IntrospectionProcessorTest.php @@ -26,7 +26,7 @@ function tester($handler, $record) namespace Monolog\Processor; -use Monolog\Logger; +use Monolog\Level; use Monolog\Test\TestCase; use Monolog\Handler\TestHandler; @@ -47,10 +47,10 @@ public function testProcessorFromClass() $tester = new \Acme\Tester; $tester->test($handler, $this->getRecord()); list($record) = $handler->getRecords(); - $this->assertEquals(__FILE__, $record['extra']['file']); - $this->assertEquals(18, $record['extra']['line']); - $this->assertEquals('Acme\Tester', $record['extra']['class']); - $this->assertEquals('test', $record['extra']['function']); + $this->assertEquals(__FILE__, $record->extra['file']); + $this->assertEquals(18, $record->extra['line']); + $this->assertEquals('Acme\Tester', $record->extra['class']); + $this->assertEquals('test', $record->extra['function']); } public function testProcessorFromFunc() @@ -58,22 +58,19 @@ public function testProcessorFromFunc() $handler = $this->getHandler(); \Acme\tester($handler, $this->getRecord()); list($record) = $handler->getRecords(); - $this->assertEquals(__FILE__, $record['extra']['file']); - $this->assertEquals(24, $record['extra']['line']); - $this->assertEquals(null, $record['extra']['class']); - $this->assertEquals('Acme\tester', $record['extra']['function']); + $this->assertEquals(__FILE__, $record->extra['file']); + $this->assertEquals(24, $record->extra['line']); + $this->assertEquals(null, $record->extra['class']); + $this->assertEquals('Acme\tester', $record->extra['function']); } public function testLevelTooLow() { - $input = [ - 'level' => Logger::DEBUG, - 'extra' => [], - ]; + $input = $this->getRecord(Level::Debug); $expected = $input; - $processor = new IntrospectionProcessor(Logger::CRITICAL); + $processor = new IntrospectionProcessor(Level::Critical); $actual = $processor($input); $this->assertEquals($expected, $actual); @@ -81,20 +78,18 @@ public function testLevelTooLow() public function testLevelEqual() { - $input = [ - 'level' => Logger::CRITICAL, - 'extra' => [], - ]; + $input = $this->getRecord(Level::Critical); $expected = $input; $expected['extra'] = [ 'file' => null, 'line' => null, - 'class' => 'ReflectionMethod', - 'function' => 'invokeArgs', + 'class' => 'PHPUnit\Framework\TestCase', + 'function' => 'runTest', + 'callType' => '->', ]; - $processor = new IntrospectionProcessor(Logger::CRITICAL); + $processor = new IntrospectionProcessor(Level::Critical); $actual = $processor($input); $this->assertEquals($expected, $actual); @@ -102,20 +97,18 @@ public function testLevelEqual() public function testLevelHigher() { - $input = [ - 'level' => Logger::EMERGENCY, - 'extra' => [], - ]; + $input = $this->getRecord(Level::Emergency); $expected = $input; $expected['extra'] = [ 'file' => null, 'line' => null, - 'class' => 'ReflectionMethod', - 'function' => 'invokeArgs', + 'class' => 'PHPUnit\Framework\TestCase', + 'function' => 'runTest', + 'callType' => '->', ]; - $processor = new IntrospectionProcessor(Logger::CRITICAL); + $processor = new IntrospectionProcessor(Level::Critical); $actual = $processor($input); $this->assertEquals($expected, $actual); diff --git a/tests/Monolog/Processor/MemoryPeakUsageProcessorTest.php b/tests/Monolog/Processor/MemoryPeakUsageProcessorTest.php index cd8052763..0ff304ae0 100644 --- a/tests/Monolog/Processor/MemoryPeakUsageProcessorTest.php +++ b/tests/Monolog/Processor/MemoryPeakUsageProcessorTest.php @@ -23,8 +23,8 @@ public function testProcessor() { $processor = new MemoryPeakUsageProcessor(); $record = $processor($this->getRecord()); - $this->assertArrayHasKey('memory_peak_usage', $record['extra']); - $this->assertRegExp('#[0-9.]+ (M|K)?B$#', $record['extra']['memory_peak_usage']); + $this->assertArrayHasKey('memory_peak_usage', $record->extra); + $this->assertMatchesRegularExpression('#[0-9.]+ (M|K)?B$#', $record->extra['memory_peak_usage']); } /** @@ -35,8 +35,8 @@ public function testProcessorWithoutFormatting() { $processor = new MemoryPeakUsageProcessor(true, false); $record = $processor($this->getRecord()); - $this->assertArrayHasKey('memory_peak_usage', $record['extra']); - $this->assertInternalType('int', $record['extra']['memory_peak_usage']); - $this->assertGreaterThan(0, $record['extra']['memory_peak_usage']); + $this->assertArrayHasKey('memory_peak_usage', $record->extra); + $this->assertIsInt($record->extra['memory_peak_usage']); + $this->assertGreaterThan(0, $record->extra['memory_peak_usage']); } } diff --git a/tests/Monolog/Processor/MemoryUsageProcessorTest.php b/tests/Monolog/Processor/MemoryUsageProcessorTest.php index a4809cbea..a68c71eaa 100644 --- a/tests/Monolog/Processor/MemoryUsageProcessorTest.php +++ b/tests/Monolog/Processor/MemoryUsageProcessorTest.php @@ -23,8 +23,8 @@ public function testProcessor() { $processor = new MemoryUsageProcessor(); $record = $processor($this->getRecord()); - $this->assertArrayHasKey('memory_usage', $record['extra']); - $this->assertRegExp('#[0-9.]+ (M|K)?B$#', $record['extra']['memory_usage']); + $this->assertArrayHasKey('memory_usage', $record->extra); + $this->assertMatchesRegularExpression('#[0-9.]+ (M|K)?B$#', $record->extra['memory_usage']); } /** @@ -35,8 +35,8 @@ public function testProcessorWithoutFormatting() { $processor = new MemoryUsageProcessor(true, false); $record = $processor($this->getRecord()); - $this->assertArrayHasKey('memory_usage', $record['extra']); - $this->assertInternalType('int', $record['extra']['memory_usage']); - $this->assertGreaterThan(0, $record['extra']['memory_usage']); + $this->assertArrayHasKey('memory_usage', $record->extra); + $this->assertIsInt($record->extra['memory_usage']); + $this->assertGreaterThan(0, $record->extra['memory_usage']); } } diff --git a/tests/Monolog/Processor/MercurialProcessorTest.php b/tests/Monolog/Processor/MercurialProcessorTest.php index 9028e41e8..2e20bae15 100644 --- a/tests/Monolog/Processor/MercurialProcessorTest.php +++ b/tests/Monolog/Processor/MercurialProcessorTest.php @@ -35,8 +35,8 @@ public function testProcessor() $processor = new MercurialProcessor(); $record = $processor($this->getRecord()); - $this->assertArrayHasKey('hg', $record['extra']); - $this->assertTrue(!is_array($record['extra']['hg']['branch'])); - $this->assertTrue(!is_array($record['extra']['hg']['revision'])); + $this->assertArrayHasKey('hg', $record->extra); + $this->assertTrue(!is_array($record->extra['hg']['branch'])); + $this->assertTrue(!is_array($record->extra['hg']['revision'])); } } diff --git a/tests/Monolog/Processor/ProcessIdProcessorTest.php b/tests/Monolog/Processor/ProcessIdProcessorTest.php index ec39e8eec..57de72209 100644 --- a/tests/Monolog/Processor/ProcessIdProcessorTest.php +++ b/tests/Monolog/Processor/ProcessIdProcessorTest.php @@ -22,9 +22,9 @@ public function testProcessor() { $processor = new ProcessIdProcessor(); $record = $processor($this->getRecord()); - $this->assertArrayHasKey('process_id', $record['extra']); - $this->assertInternalType('int', $record['extra']['process_id']); - $this->assertGreaterThan(0, $record['extra']['process_id']); - $this->assertEquals(getmypid(), $record['extra']['process_id']); + $this->assertArrayHasKey('process_id', $record->extra); + $this->assertIsInt($record->extra['process_id']); + $this->assertGreaterThan(0, $record->extra['process_id']); + $this->assertEquals(getmypid(), $record->extra['process_id']); } } diff --git a/tests/Monolog/Processor/PsrLogMessageProcessorTest.php b/tests/Monolog/Processor/PsrLogMessageProcessorTest.php index 3b5ec6e94..e44d13c6c 100644 --- a/tests/Monolog/Processor/PsrLogMessageProcessorTest.php +++ b/tests/Monolog/Processor/PsrLogMessageProcessorTest.php @@ -11,7 +11,9 @@ namespace Monolog\Processor; -class PsrLogMessageProcessorTest extends \PHPUnit\Framework\TestCase +use Monolog\Test\TestCase; + +class PsrLogMessageProcessorTest extends TestCase { /** * @dataProvider getPairs @@ -20,10 +22,7 @@ public function testReplacement($val, $expected) { $proc = new PsrLogMessageProcessor; - $message = $proc([ - 'message' => '{foo}', - 'context' => ['foo' => $val], - ]); + $message = $proc($this->getRecord(message: '{foo}', context: ['foo' => $val])); $this->assertEquals($expected, $message['message']); $this->assertSame(['foo' => $val], $message['context']); } @@ -32,10 +31,7 @@ public function testReplacementWithContextRemoval() { $proc = new PsrLogMessageProcessor($dateFormat = null, $removeUsedContextFields = true); - $message = $proc([ - 'message' => '{foo}', - 'context' => ['foo' => 'bar', 'lorem' => 'ipsum'], - ]); + $message = $proc($this->getRecord(message: '{foo}', context: ['foo' => 'bar', 'lorem' => 'ipsum'])); $this->assertSame('bar', $message['message']); $this->assertSame(['lorem' => 'ipsum'], $message['context']); } @@ -47,10 +43,7 @@ public function testCustomDateFormat() $proc = new PsrLogMessageProcessor($format); - $message = $proc([ - 'message' => '{foo}', - 'context' => ['foo' => $date], - ]); + $message = $proc($this->getRecord(message: '{foo}', context: ['foo' => $date])); $this->assertEquals($date->format($format), $message['message']); $this->assertSame(['foo' => $date], $message['context']); } diff --git a/tests/Monolog/Processor/TagProcessorTest.php b/tests/Monolog/Processor/TagProcessorTest.php index da84378b6..db729e549 100644 --- a/tests/Monolog/Processor/TagProcessorTest.php +++ b/tests/Monolog/Processor/TagProcessorTest.php @@ -24,7 +24,7 @@ public function testProcessor() $processor = new TagProcessor($tags); $record = $processor($this->getRecord()); - $this->assertEquals($tags, $record['extra']['tags']); + $this->assertEquals($tags, $record->extra['tags']); } /** @@ -36,14 +36,14 @@ public function testProcessorTagModification() $processor = new TagProcessor($tags); $record = $processor($this->getRecord()); - $this->assertEquals($tags, $record['extra']['tags']); + $this->assertEquals($tags, $record->extra['tags']); $processor->setTags(['a', 'b']); $record = $processor($this->getRecord()); - $this->assertEquals(['a', 'b'], $record['extra']['tags']); + $this->assertEquals(['a', 'b'], $record->extra['tags']); $processor->addTags(['a', 'c', 'foo' => 'bar']); $record = $processor($this->getRecord()); - $this->assertEquals(['a', 'b', 'a', 'c', 'foo' => 'bar'], $record['extra']['tags']); + $this->assertEquals(['a', 'b', 'a', 'c', 'foo' => 'bar'], $record->extra['tags']); } } diff --git a/tests/Monolog/Processor/UidProcessorTest.php b/tests/Monolog/Processor/UidProcessorTest.php index 927d56482..d9288acd5 100644 --- a/tests/Monolog/Processor/UidProcessorTest.php +++ b/tests/Monolog/Processor/UidProcessorTest.php @@ -22,7 +22,7 @@ public function testProcessor() { $processor = new UidProcessor(); $record = $processor($this->getRecord()); - $this->assertArrayHasKey('uid', $record['extra']); + $this->assertArrayHasKey('uid', $record->extra); } public function testGetUid() diff --git a/tests/Monolog/Processor/WebProcessorTest.php b/tests/Monolog/Processor/WebProcessorTest.php index 6da6ab89c..7c50127a9 100644 --- a/tests/Monolog/Processor/WebProcessorTest.php +++ b/tests/Monolog/Processor/WebProcessorTest.php @@ -28,12 +28,12 @@ public function testProcessor() $processor = new WebProcessor($server); $record = $processor($this->getRecord()); - $this->assertEquals($server['REQUEST_URI'], $record['extra']['url']); - $this->assertEquals($server['REMOTE_ADDR'], $record['extra']['ip']); - $this->assertEquals($server['REQUEST_METHOD'], $record['extra']['http_method']); - $this->assertEquals($server['HTTP_REFERER'], $record['extra']['referrer']); - $this->assertEquals($server['SERVER_NAME'], $record['extra']['server']); - $this->assertEquals($server['UNIQUE_ID'], $record['extra']['unique_id']); + $this->assertEquals($server['REQUEST_URI'], $record->extra['url']); + $this->assertEquals($server['REMOTE_ADDR'], $record->extra['ip']); + $this->assertEquals($server['REQUEST_METHOD'], $record->extra['http_method']); + $this->assertEquals($server['HTTP_REFERER'], $record->extra['referrer']); + $this->assertEquals($server['SERVER_NAME'], $record->extra['server']); + $this->assertEquals($server['UNIQUE_ID'], $record->extra['unique_id']); } public function testProcessorDoNothingIfNoRequestUri() @@ -44,7 +44,7 @@ public function testProcessorDoNothingIfNoRequestUri() ]; $processor = new WebProcessor($server); $record = $processor($this->getRecord()); - $this->assertEmpty($record['extra']); + $this->assertEmpty($record->extra); } public function testProcessorReturnNullIfNoHttpReferer() @@ -57,7 +57,7 @@ public function testProcessorReturnNullIfNoHttpReferer() ]; $processor = new WebProcessor($server); $record = $processor($this->getRecord()); - $this->assertNull($record['extra']['referrer']); + $this->assertNull($record->extra['referrer']); } public function testProcessorDoesNotAddUniqueIdIfNotPresent() @@ -70,7 +70,7 @@ public function testProcessorDoesNotAddUniqueIdIfNotPresent() ]; $processor = new WebProcessor($server); $record = $processor($this->getRecord()); - $this->assertFalse(isset($record['extra']['unique_id'])); + $this->assertFalse(isset($record->extra['unique_id'])); } public function testProcessorAddsOnlyRequestedExtraFields() @@ -85,7 +85,20 @@ public function testProcessorAddsOnlyRequestedExtraFields() $processor = new WebProcessor($server, ['url', 'http_method']); $record = $processor($this->getRecord()); - $this->assertSame(['url' => 'A', 'http_method' => 'C'], $record['extra']); + $this->assertSame(['url' => 'A', 'http_method' => 'C'], $record->extra); + } + + public function testProcessorAddsOnlyRequestedExtraFieldsIncludingOptionalFields() + { + $server = [ + 'REQUEST_URI' => 'A', + 'UNIQUE_ID' => 'X', + ]; + + $processor = new WebProcessor($server, ['url']); + $record = $processor($this->getRecord()); + + $this->assertSame(['url' => 'A'], $record->extra); } public function testProcessorConfiguringOfExtraFields() @@ -100,14 +113,13 @@ public function testProcessorConfiguringOfExtraFields() $processor = new WebProcessor($server, ['url' => 'REMOTE_ADDR']); $record = $processor($this->getRecord()); - $this->assertSame(['url' => 'B'], $record['extra']); + $this->assertSame(['url' => 'B'], $record->extra); } - /** - * @expectedException UnexpectedValueException - */ public function testInvalidData() { + $this->expectException(\TypeError::class); + new WebProcessor(new \stdClass); } } diff --git a/tests/Monolog/PsrLogCompatTest.php b/tests/Monolog/PsrLogCompatTest.php index a4bbcd0cb..45f4f5e4f 100644 --- a/tests/Monolog/PsrLogCompatTest.php +++ b/tests/Monolog/PsrLogCompatTest.php @@ -11,16 +11,29 @@ namespace Monolog; +use DateTimeZone; use Monolog\Handler\TestHandler; use Monolog\Formatter\LineFormatter; use Monolog\Processor\PsrLogMessageProcessor; -use Psr\Log\Test\LoggerInterfaceTest; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\InvalidArgumentException; +use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; +use Stringable; -class PsrLogCompatTest extends LoggerInterfaceTest +class PsrLogCompatTest extends TestCase { - private $handler; + private TestHandler $handler; - public function getLogger() + public function tearDown(): void + { + parent::tearDown(); + + unset($this->handler); + } + + public function getLogger(): LoggerInterface { $logger = new Logger('foo'); $logger->pushHandler($handler = new TestHandler); @@ -32,16 +45,133 @@ public function getLogger() return $logger; } - public function getLogs() + public function getLogs(): array { $convert = function ($record) { $lower = function ($match) { return strtolower($match[0]); }; - return preg_replace_callback('{^[A-Z]+}', $lower, $record['formatted']); + return preg_replace_callback('{^[A-Z]+}', $lower, $record->formatted); }; return array_map($convert, $this->handler->getRecords()); } + + public function testImplements() + { + $this->assertInstanceOf(LoggerInterface::class, $this->getLogger()); + } + + /** + * @dataProvider provideLevelsAndMessages + */ + public function testLogsAtAllLevels($level, $message) + { + $logger = $this->getLogger(); + $logger->{$level}($message, ['user' => 'Bob']); + $logger->log($level, $message, ['user' => 'Bob']); + + $expected = [ + "$level message of level $level with context: Bob", + "$level message of level $level with context: Bob", + ]; + $this->assertEquals($expected, $this->getLogs()); + } + + public function provideLevelsAndMessages() + { + return [ + LogLevel::EMERGENCY => [LogLevel::EMERGENCY, 'message of level emergency with context: {user}'], + LogLevel::ALERT => [LogLevel::ALERT, 'message of level alert with context: {user}'], + LogLevel::CRITICAL => [LogLevel::CRITICAL, 'message of level critical with context: {user}'], + LogLevel::ERROR => [LogLevel::ERROR, 'message of level error with context: {user}'], + LogLevel::WARNING => [LogLevel::WARNING, 'message of level warning with context: {user}'], + LogLevel::NOTICE => [LogLevel::NOTICE, 'message of level notice with context: {user}'], + LogLevel::INFO => [LogLevel::INFO, 'message of level info with context: {user}'], + LogLevel::DEBUG => [LogLevel::DEBUG, 'message of level debug with context: {user}'], + ]; + } + + public function testThrowsOnInvalidLevel() + { + $logger = $this->getLogger(); + + $this->expectException(InvalidArgumentException::class); + $logger->log('invalid level', 'Foo'); + } + + public function testContextReplacement() + { + $logger = $this->getLogger(); + $logger->info('{Message {nothing} {user} {foo.bar} a}', ['user' => 'Bob', 'foo.bar' => 'Bar']); + + $expected = ['info {Message {nothing} Bob Bar a}']; + $this->assertEquals($expected, $this->getLogs()); + } + + public function testObjectCastToString() + { + $string = uniqid('DUMMY'); + $dummy = $this->createStringable($string); + $dummy->expects($this->once()) + ->method('__toString'); + + $this->getLogger()->warning($dummy); + + $expected = ["warning $string"]; + $this->assertEquals($expected, $this->getLogs()); + } + + public function testContextCanContainAnything() + { + $closed = fopen('php://memory', 'r'); + fclose($closed); + + $context = [ + 'bool' => true, + 'null' => null, + 'string' => 'Foo', + 'int' => 0, + 'float' => 0.5, + 'nested' => ['with object' => $this->createStringable()], + 'object' => new \DateTime('now', new DateTimeZone('Europe/London')), + 'resource' => fopen('php://memory', 'r'), + 'closed' => $closed, + ]; + + $this->getLogger()->warning('Crazy context data', $context); + + $expected = ['warning Crazy context data']; + $this->assertEquals($expected, $this->getLogs()); + } + + public function testContextExceptionKeyCanBeExceptionOrOtherValues() + { + $logger = $this->getLogger(); + $logger->warning('Random message', ['exception' => 'oops']); + $logger->critical('Uncaught Exception!', ['exception' => new \LogicException('Fail')]); + + $expected = [ + 'warning Random message', + 'critical Uncaught Exception!', + ]; + $this->assertEquals($expected, $this->getLogs()); + } + + /** + * Creates a mock of a `Stringable`. + * + * @param string $string The string that must be represented by the stringable. + */ + protected function createStringable(string $string = ''): MockObject&Stringable + { + $mock = $this->getMockBuilder(Stringable::class) + ->getMock(); + + $mock->method('__toString') + ->will($this->returnValue($string)); + + return $mock; + } } diff --git a/tests/Monolog/RegistryTest.php b/tests/Monolog/RegistryTest.php index 7ee2cbdf9..3ebfe841b 100644 --- a/tests/Monolog/RegistryTest.php +++ b/tests/Monolog/RegistryTest.php @@ -13,7 +13,7 @@ class RegistryTest extends \PHPUnit\Framework\TestCase { - protected function setUp() + protected function setUp(): void { Registry::clear(); } @@ -114,11 +114,11 @@ public function testGetsSameLogger() } /** - * @expectedException \InvalidArgumentException * @covers Monolog\Registry::getInstance */ - public function testFailsOnNonExistantLogger() + public function testFailsOnNonExistentLogger() { + $this->expectException(\InvalidArgumentException::class); Registry::getInstance('test1'); } @@ -138,7 +138,6 @@ public function testReplacesLogger() } /** - * @expectedException \InvalidArgumentException * @covers Monolog\Registry::addLogger */ public function testFailsOnUnspecifiedReplacement() @@ -148,6 +147,8 @@ public function testFailsOnUnspecifiedReplacement() Registry::addLogger($log1, 'log'); + $this->expectException(\InvalidArgumentException::class); + Registry::addLogger($log2, 'log'); } } diff --git a/tests/Monolog/SignalHandlerTest.php b/tests/Monolog/SignalHandlerTest.php new file mode 100644 index 000000000..18ff37806 --- /dev/null +++ b/tests/Monolog/SignalHandlerTest.php @@ -0,0 +1,291 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog; + +use Monolog\Handler\StreamHandler; +use Monolog\Handler\TestHandler; +use Psr\Log\LogLevel; +use Monolog\Test\TestCase; + +/** + * @author Robert Gust-Bardon + * @covers Monolog\SignalHandler + */ +class SignalHandlerTest extends TestCase +{ + private bool $asyncSignalHandling; + private array $blockedSignals = []; + private array $signalHandlers = []; + + protected function setUp(): void + { + $this->signalHandlers = []; + if (extension_loaded('pcntl')) { + if (function_exists('pcntl_async_signals')) { + $this->asyncSignalHandling = pcntl_async_signals(); + } + if (function_exists('pcntl_sigprocmask')) { + pcntl_sigprocmask(SIG_BLOCK, [], $this->blockedSignals); + } + } + } + + public function tearDown(): void + { + parent::tearDown(); + + if ($this->asyncSignalHandling !== null) { + pcntl_async_signals($this->asyncSignalHandling); + } + if ($this->blockedSignals !== null) { + pcntl_sigprocmask(SIG_SETMASK, $this->blockedSignals); + } + if ($this->signalHandlers) { + pcntl_signal_dispatch(); + foreach ($this->signalHandlers as $signo => $handler) { + pcntl_signal($signo, $handler); + } + } + + unset($this->signalHandlers, $this->blockedSignals, $this->asyncSignalHandling); + } + + private function setSignalHandler($signo, $handler = SIG_DFL) + { + if (function_exists('pcntl_signal_get_handler')) { + $this->signalHandlers[$signo] = pcntl_signal_get_handler($signo); + } else { + $this->signalHandlers[$signo] = SIG_DFL; + } + $this->assertTrue(pcntl_signal($signo, $handler)); + } + + public function testHandleSignal() + { + $logger = new Logger('test', [$handler = new TestHandler]); + $errHandler = new SignalHandler($logger); + $signo = 2; // SIGINT. + $siginfo = ['signo' => $signo, 'errno' => 0, 'code' => 0]; + $errHandler->handleSignal($signo, $siginfo); + $this->assertCount(1, $handler->getRecords()); + $this->assertTrue($handler->hasCriticalRecords()); + $records = $handler->getRecords(); + $this->assertSame($siginfo, $records[0]['context']); + } + + /** + * @depends testHandleSignal + * @requires extension pcntl + * @requires extension posix + * @requires function pcntl_signal + * @requires function pcntl_signal_dispatch + * @requires function posix_getpid + * @requires function posix_kill + */ + public function testRegisterSignalHandler() + { + // SIGCONT and SIGURG should be ignored by default. + if (!defined('SIGCONT') || !defined('SIGURG')) { + $this->markTestSkipped('This test requires the SIGCONT and SIGURG pcntl constants.'); + } + + $this->setSignalHandler(SIGCONT, SIG_IGN); + $this->setSignalHandler(SIGURG, SIG_IGN); + + $logger = new Logger('test', [$handler = new TestHandler]); + $errHandler = new SignalHandler($logger); + $pid = posix_getpid(); + + $this->assertTrue(posix_kill($pid, SIGURG)); + $this->assertTrue(pcntl_signal_dispatch()); + $this->assertCount(0, $handler->getRecords()); + + $errHandler->registerSignalHandler(SIGURG, LogLevel::INFO, false, false, false); + + $this->assertTrue(posix_kill($pid, SIGCONT)); + $this->assertTrue(pcntl_signal_dispatch()); + $this->assertCount(0, $handler->getRecords()); + + $this->assertTrue(posix_kill($pid, SIGURG)); + $this->assertTrue(pcntl_signal_dispatch()); + $this->assertCount(1, $handler->getRecords()); + $this->assertTrue($handler->hasInfoThatContains('SIGURG')); + } + + /** + * @dataProvider defaultPreviousProvider + * @depends testRegisterSignalHandler + * @requires function pcntl_fork + * @requires function pcntl_sigprocmask + * @requires function pcntl_waitpid + */ + public function testRegisterDefaultPreviousSignalHandler($signo, $callPrevious, $expected) + { + $this->setSignalHandler($signo, SIG_DFL); + + $path = tempnam(sys_get_temp_dir(), 'monolog-'); + $this->assertNotFalse($path); + + $pid = pcntl_fork(); + if ($pid === 0) { // Child. + $streamHandler = new StreamHandler($path); + $streamHandler->setFormatter($this->getIdentityFormatter()); + $logger = new Logger('test', [$streamHandler]); + $errHandler = new SignalHandler($logger); + $errHandler->registerSignalHandler($signo, LogLevel::INFO, $callPrevious, false, false); + pcntl_sigprocmask(SIG_SETMASK, [SIGCONT]); + posix_kill(posix_getpid(), $signo); + pcntl_signal_dispatch(); + // If $callPrevious is true, SIGINT should terminate by this line. + pcntl_sigprocmask(SIG_BLOCK, [], $oldset); + file_put_contents($path, implode(' ', $oldset), FILE_APPEND); + posix_kill(posix_getpid(), $signo); + pcntl_signal_dispatch(); + exit(); + } + + $this->assertNotSame(-1, $pid); + $this->assertNotSame(-1, pcntl_waitpid($pid, $status)); + $this->assertNotSame(-1, $status); + $this->assertSame($expected, file_get_contents($path)); + } + + public function defaultPreviousProvider() + { + if (!defined('SIGCONT') || !defined('SIGINT') || !defined('SIGURG')) { + return []; + } + + return [ + [SIGINT, false, 'Program received signal SIGINT'.SIGCONT.'Program received signal SIGINT'], + [SIGINT, true, 'Program received signal SIGINT'], + [SIGURG, false, 'Program received signal SIGURG'.SIGCONT.'Program received signal SIGURG'], + [SIGURG, true, 'Program received signal SIGURG'.SIGCONT.'Program received signal SIGURG'], + ]; + } + + /** + * @dataProvider callablePreviousProvider + * @depends testRegisterSignalHandler + * @requires function pcntl_signal_get_handler + */ + public function testRegisterCallablePreviousSignalHandler($callPrevious) + { + $this->setSignalHandler(SIGURG, SIG_IGN); + + $logger = new Logger('test', [$handler = new TestHandler]); + $errHandler = new SignalHandler($logger); + $previousCalled = 0; + pcntl_signal(SIGURG, function ($signo, array $siginfo = null) use (&$previousCalled) { + ++$previousCalled; + }); + $errHandler->registerSignalHandler(SIGURG, LogLevel::INFO, $callPrevious, false, false); + $this->assertTrue(posix_kill(posix_getpid(), SIGURG)); + $this->assertTrue(pcntl_signal_dispatch()); + $this->assertCount(1, $handler->getRecords()); + $this->assertTrue($handler->hasInfoThatContains('SIGURG')); + $this->assertSame($callPrevious ? 1 : 0, $previousCalled); + } + + public function callablePreviousProvider() + { + return [ + [false], + [true], + ]; + } + + /** + * @dataProvider restartSyscallsProvider + * @depends testRegisterDefaultPreviousSignalHandler + * @requires function pcntl_fork + * @requires function pcntl_waitpid + */ + public function testRegisterSyscallRestartingSignalHandler($restartSyscalls) + { + $this->setSignalHandler(SIGURG, SIG_IGN); + + $parentPid = posix_getpid(); + $microtime = microtime(true); + + $pid = pcntl_fork(); + if ($pid === 0) { // Child. + usleep(100000); + posix_kill($parentPid, SIGURG); + usleep(100000); + exit(); + } + + $this->assertNotSame(-1, $pid); + $logger = new Logger('test', [$handler = new TestHandler]); + $errHandler = new SignalHandler($logger); + $errHandler->registerSignalHandler(SIGURG, LogLevel::INFO, false, $restartSyscalls, false); + if ($restartSyscalls) { + // pcntl_wait is expected to be restarted after the signal handler. + $this->assertNotSame(-1, pcntl_waitpid($pid, $status)); + } else { + // pcntl_wait is expected to be interrupted when the signal handler is invoked. + $this->assertSame(-1, pcntl_waitpid($pid, $status)); + } + $this->assertSame($restartSyscalls, microtime(true) - $microtime > 0.15); + $this->assertTrue(pcntl_signal_dispatch()); + $this->assertCount(1, $handler->getRecords()); + if ($restartSyscalls) { + // The child has already exited. + $this->assertSame(-1, pcntl_waitpid($pid, $status)); + } else { + // The child has not exited yet. + $this->assertNotSame(-1, pcntl_waitpid($pid, $status)); + } + } + + public function restartSyscallsProvider() + { + return [ + [false], + [true], + [false], + [true], + ]; + } + + /** + * @dataProvider asyncProvider + * @depends testRegisterDefaultPreviousSignalHandler + * @requires function pcntl_async_signals + */ + public function testRegisterAsyncSignalHandler($initialAsync, $desiredAsync, $expectedBefore, $expectedAfter) + { + $this->setSignalHandler(SIGURG, SIG_IGN); + pcntl_async_signals($initialAsync); + + $logger = new Logger('test', [$handler = new TestHandler]); + $errHandler = new SignalHandler($logger); + $errHandler->registerSignalHandler(SIGURG, LogLevel::INFO, false, false, $desiredAsync); + $this->assertTrue(posix_kill(posix_getpid(), SIGURG)); + $this->assertCount($expectedBefore, $handler->getRecords()); + $this->assertTrue(pcntl_signal_dispatch()); + $this->assertCount($expectedAfter, $handler->getRecords()); + } + + public function asyncProvider() + { + return [ + [false, false, 0, 1], + [false, null, 0, 1], + [false, true, 1, 1], + [true, false, 0, 1], + [true, null, 1, 1], + [true, true, 1, 1], + ]; + } +} diff --git a/tests/Monolog/UtilsTest.php b/tests/Monolog/UtilsTest.php new file mode 100644 index 000000000..60a662128 --- /dev/null +++ b/tests/Monolog/UtilsTest.php @@ -0,0 +1,184 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog; + +class UtilsTest extends \PHPUnit_Framework_TestCase +{ + /** + * @dataProvider provideObjects + */ + public function testGetClass(string $expected, object $object) + { + $this->assertSame($expected, Utils::getClass($object)); + } + + public function provideObjects() + { + return [ + ['stdClass', new \stdClass()], + ['class@anonymous', new class { + }], + ['stdClass@anonymous', new class extends \stdClass { + }], + ]; + } + + /** + * @dataProvider providePathsToCanonicalize + */ + public function testCanonicalizePath(string $expected, string $input) + { + $this->assertSame($expected, Utils::canonicalizePath($input)); + } + + public function providePathsToCanonicalize() + { + return [ + ['/foo/bar', '/foo/bar'], + ['file://'.getcwd().'/bla', 'file://bla'], + [getcwd().'/bla', 'bla'], + [getcwd().'/./bla', './bla'], + ['file:///foo/bar', 'file:///foo/bar'], + ['any://foo', 'any://foo'], + ['\\\\network\path', '\\\\network\path'], + ]; + } + + /** + * @dataProvider providesHandleJsonErrorFailure + */ + public function testHandleJsonErrorFailure(int $code, string $msg) + { + $this->expectException('RuntimeException', $msg); + Utils::handleJsonError($code, 'faked'); + } + + public function providesHandleJsonErrorFailure() + { + return [ + 'depth' => [JSON_ERROR_DEPTH, 'Maximum stack depth exceeded'], + 'state' => [JSON_ERROR_STATE_MISMATCH, 'Underflow or the modes mismatch'], + 'ctrl' => [JSON_ERROR_CTRL_CHAR, 'Unexpected control character found'], + 'default' => [-1, 'Unknown error'], + ]; + } + + /** + * @param mixed $in Input + * @param mixed $expect Expected output + * @covers Monolog\Formatter\NormalizerFormatter::detectAndCleanUtf8 + * @dataProvider providesDetectAndCleanUtf8 + */ + public function testDetectAndCleanUtf8($in, $expect) + { + $reflMethod = new \ReflectionMethod(Utils::class, 'detectAndCleanUtf8'); + $reflMethod->setAccessible(true); + $args = [&$in]; + $reflMethod->invokeArgs(null, $args); + $this->assertSame($expect, $in); + } + + public function providesDetectAndCleanUtf8() + { + $obj = new \stdClass; + + return [ + 'null' => [null, null], + 'int' => [123, 123], + 'float' => [123.45, 123.45], + 'bool false' => [false, false], + 'bool true' => [true, true], + 'ascii string' => ['abcdef', 'abcdef'], + 'latin9 string' => ["\xB1\x31\xA4\xA6\xA8\xB4\xB8\xBC\xBD\xBE\xFF", '±1€ŠšŽžŒœŸÿ'], + 'unicode string' => ['¤¦¨´¸¼½¾€ŠšŽžŒœŸ', '¤¦¨´¸¼½¾€ŠšŽžŒœŸ'], + 'empty array' => [[], []], + 'array' => [['abcdef'], ['abcdef']], + 'object' => [$obj, $obj], + ]; + } + + /** + * @dataProvider providesPcreLastErrorMessage + */ + public function testPcreLastErrorMessage(int $code, string $msg) + { + if (PHP_VERSION_ID >= 80000) { + $this->assertSame('No error', Utils::pcreLastErrorMessage($code)); + + return; + } + + $this->assertEquals($msg, Utils::pcreLastErrorMessage($code)); + } + + /** + * @return array[] + */ + public function providesPcreLastErrorMessage(): array + { + return [ + [0, 'PREG_NO_ERROR'], + [1, 'PREG_INTERNAL_ERROR'], + [2, 'PREG_BACKTRACK_LIMIT_ERROR'], + [3, 'PREG_RECURSION_LIMIT_ERROR'], + [4, 'PREG_BAD_UTF8_ERROR'], + [5, 'PREG_BAD_UTF8_OFFSET_ERROR'], + [6, 'PREG_JIT_STACKLIMIT_ERROR'], + [-1, 'UNDEFINED_ERROR'], + ]; + } + + public function provideIniValuesToConvertToBytes() + { + return [ + ['1', 1], + ['2', 2], + ['2.5', 2], + ['2.9', 2], + ['1B', false], + ['1X', false], + ['1K', 1024], + ['1 K', 1024], + [' 5 M ', 5*1024*1024], + ['1G', 1073741824], + ['', false], + [null, false], + ['A', false], + ['AA', false], + ['B', false], + ['BB', false], + ['G', false], + ['GG', false], + ['-1', -1], + ['-123', -123], + ['-1A', -1], + ['-1B', -1], + ['-123G', -123], + ['-B', false], + ['-A', false], + ['-', false], + [true, false], + [false, false], + ]; + } + + /** + * @dataProvider provideIniValuesToConvertToBytes + * @param mixed $input + * @param int|false $expected + */ + public function testExpandIniShorthandBytes($input, $expected) + { + $result = Utils::expandIniShorthandBytes($input); + $this->assertEquals($expected, $result); + } +}