From 766786c4dcebfe27fc8f6cadd5410d52950131cf Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Mon, 14 Sep 2026 21:54:36 +0100 Subject: [PATCH 1/5] feature: automatically handle sentry closes #736 --- README.md | 28 +++ composer.json | 10 +- composer.lock | 215 +++++++++++----------- config.default.ini | 3 + src/Application.php | 5 + src/Debug/SentryReporter.php | 47 +++++ test/phpunit/ApplicationTest.php | 4 + test/phpunit/Debug/SentryReporterTest.php | 59 ++++++ test/phpunit/Dispatch/DispatcherTest.php | 33 +++- test/phpunit/Fixture/sentry-functions.php | 16 ++ 10 files changed, 308 insertions(+), 112 deletions(-) create mode 100644 src/Debug/SentryReporter.php create mode 100644 test/phpunit/Debug/SentryReporterTest.php create mode 100644 test/phpunit/Fixture/sentry-functions.php diff --git a/README.md b/README.md index 3169b247..57b1d970 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,34 @@ It would be helpful if you could create your issue in the appropriate repository [contributing]: https://github.com/PhpGt/WebEngine/blob/master/CONTRIBUTING.md [issues]: https://github.com/PhpGt/WebEngine/issues +## Optional Sentry error reporting + +Install `sentry/sentry` in your application (the `sentry/sdk` meta-package also +provides it), then configure your project's `config.ini`: + +```ini +[sentry] +dsn=https://YOUR_KEY@app.glitchtip.com/YOUR_PROJECT +``` + +The DSN can point to Sentry or a compatible service such as GlitchTip. + +WebEngine initializes the SDK when both the SDK functions and a nonempty DSN +are available. No Sentry initialization in `setup.php` is necessary. Leave the +DSN empty in environments that should not report errors. + +Exceptions escaping request logic are reported before the normal error page or +custom error script runs. Failures escaping error-page rendering are also +reported. Expected HTTP responses below 500 are excluded. Reporting failures +do not replace the application's error response. Exceptions caught and handled +by application code still require explicit reporting if desired. + +The SDK's default handlers remain responsible for uncaught exceptions outside +the request handler and fatal PHP errors; WebEngine does not separately report +shutdown errors to avoid duplicate fatal events. Performance tracing is not +enabled by this integration. Review SDK data filtering for sensitive application +payloads before enabling reporting. + # Proudly sponsored by [JetBrains Open Source sponsorship program](https://www.jetbrains.com/community/opensource/) diff --git a/composer.json b/composer.json index af1b8a6e..48ea2d48 100644 --- a/composer.json +++ b/composer.json @@ -43,6 +43,10 @@ "willdurand/negotiation": "^3.0" }, + "suggest": { + "sentry/sentry": "Automatically report application exceptions when config value sentry.dsn is configured" + }, + "require-dev": { "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^12.4", @@ -66,14 +70,14 @@ "scripts": { "phpunit": "vendor/bin/phpunit --configuration phpunit.xml", - "phpstan": "vendor/bin/phpstan analyse --memory-limit=512M --level 6 src", "phpcs": "vendor/bin/phpcs src --standard=phpcs.xml", "phpmd": "vendor/bin/phpmd src/ text phpmd.xml", + "phpstan": "vendor/bin/phpstan analyse --memory-limit=512M --level 6 src", "test": [ "@phpunit", - "@phpstan", "@phpcs", - "@phpmd" + "@phpmd", + "@phpstan" ] }, diff --git a/composer.lock b/composer.lock index f08bbfe7..0364d62f 100644 --- a/composer.lock +++ b/composer.lock @@ -145,19 +145,20 @@ }, { "name": "justinrainbow/json-schema", - "version": "6.10.0", + "version": "6.12.0", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33" + "reference": "8ef236a1a37df364b518b7411a4b5bb95b040d79" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", - "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/8ef236a1a37df364b518b7411a4b5bb95b040d79", + "reference": "8ef236a1a37df364b518b7411a4b5bb95b040d79", "shasum": "" }, "require": { + "ext-filter": "*", "ext-json": "*", "marc-mabe/php-enum": "^4.4", "php": "^7.2 || ^8.0" @@ -190,20 +191,9 @@ ], "authors": [ { - "name": "Bruno Prieto Reis", - "email": "bruno.p.reis@gmail.com" - }, - { - "name": "Justin Rainbow", - "email": "justin.rainbow@gmail.com" - }, - { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" - }, - { - "name": "Robert Schönthal", - "email": "seroscho@googlemail.com" + "name": "Danny van der Sluijs", + "email": "danny.vandersluijs@icloud.com", + "role": "Maintainer" } ], "description": "A library to validate a json schema.", @@ -214,9 +204,9 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.10.0" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.12.0" }, - "time": "2026-06-16T20:50:26+00:00" + "time": "2026-09-04T12:54:20+00:00" }, { "name": "magicalex/write-ini-file", @@ -2613,20 +2603,20 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.4", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.0" }, "conflict": { "doctrine/collections": "<1.6.8", @@ -2661,28 +2651,28 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" + "url": "https://github.com/mnapoli", + "type": "github" } ], - "time": "2025-08-01T08:46:24+00:00" + "time": "2026-08-11T10:17:44+00:00" }, { "name": "nikic/php-parser", - "version": "v5.8.0", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", - "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", "shasum": "" }, "require": { @@ -2724,9 +2714,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" }, - "time": "2026-07-04T14:30:18+00:00" + "time": "2026-09-13T18:51:52+00:00" }, { "name": "pdepend/pdepend", @@ -2994,11 +2984,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.5", + "version": "2.2.14", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", - "reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", + "reference": "9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", "shasum": "" }, "require": { @@ -3054,7 +3044,7 @@ "type": "github" } ], - "time": "2026-07-05T06:31:06+00:00" + "time": "2026-09-12T21:39:33+00:00" }, { "name": "phpunit/php-code-coverage", @@ -3146,23 +3136,23 @@ }, { "name": "phpunit/php-file-iterator", - "version": "6.0.1", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", - "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a248d1640ab059b075f53a2ef0f9856e864e06b5", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.33" }, "type": "library", "extra": { @@ -3195,7 +3185,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.2" }, "funding": [ { @@ -3215,7 +3205,7 @@ "type": "tidelift" } ], - "time": "2026-02-02T14:04:18+00:00" + "time": "2026-08-25T14:40:53+00:00" }, { "name": "phpunit/php-invoker", @@ -3403,16 +3393,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.31", + "version": "12.5.35", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "0608d157a284f15cc73b99a3327eff06b66a176d" + "reference": "345ac22e42cb14cdc7aa8428655d8bb8c82a4aab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0608d157a284f15cc73b99a3327eff06b66a176d", - "reference": "0608d157a284f15cc73b99a3327eff06b66a176d", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/345ac22e42cb14cdc7aa8428655d8bb8c82a4aab", + "reference": "345ac22e42cb14cdc7aa8428655d8bb8c82a4aab", "shasum": "" }, "require": { @@ -3422,18 +3412,18 @@ "ext-libxml": "*", "ext-mbstring": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.4", + "myclabs/deep-copy": "^1.14.0", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", "phpunit/php-code-coverage": "^12.5.7", - "phpunit/php-file-iterator": "^6.0.1", + "phpunit/php-file-iterator": "^6.0.2", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", "sebastian/cli-parser": "^4.2.1", "sebastian/comparator": "^7.1.8", - "sebastian/diff": "^7.0.0", + "sebastian/diff": "^7.0.1", "sebastian/environment": "^8.1.2", "sebastian/exporter": "^7.0.3", "sebastian/global-state": "^8.0.3", @@ -3481,7 +3471,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.31" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.35" }, "funding": [ { @@ -3489,7 +3479,7 @@ "type": "other" } ], - "time": "2026-07-06T14:54:16+00:00" + "time": "2026-09-09T04:48:50+00:00" }, { "name": "psr/log", @@ -3762,24 +3752,24 @@ }, { "name": "sebastian/diff", - "version": "7.0.0", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0", - "symfony/process": "^7.2" + "phpunit/phpunit": "^12.5.33", + "symfony/process": "^7.4.17" }, "type": "library", "extra": { @@ -3817,15 +3807,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" } ], - "time": "2025-02-07T04:55:46+00:00" + "time": "2026-08-25T15:35:54+00:00" }, { "name": "sebastian/environment", @@ -4452,19 +4454,20 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "4.0.1", + "version": "4.0.4", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "0525c73950de35ded110cffafb9892946d7771b5" + "reference": "bbdc3d0532623e21838b7041a4364383a8126f96" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0525c73950de35ded110cffafb9892946d7771b5", - "reference": "0525c73950de35ded110cffafb9892946d7771b5", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/bbdc3d0532623e21838b7041a4364383a8126f96", + "reference": "bbdc3d0532623e21838b7041a4364383a8126f96", "shasum": "" }, "require": { + "ext-libxml": "*", "ext-simplexml": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", @@ -4473,6 +4476,10 @@ "require-dev": { "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31" }, + "suggest": { + "ext-iconv": "For accurate character length calculation when the checked files contain multi-byte characters.", + "ext-pcntl": "For parallel processing support via the --parallel CLI option." + }, "bin": [ "bin/phpcbf", "bin/phpcs" @@ -4527,7 +4534,7 @@ "type": "thanks_dev" } ], - "time": "2025-11-10T16:43:36+00:00" + "time": "2026-08-06T02:45:27+00:00" }, { "name": "staabm/side-effects-detector", @@ -4583,16 +4590,16 @@ }, { "name": "symfony/config", - "version": "v7.4.14", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "7b665e443381ea7c4db03eb03b4bf79ea2b020eb" + "reference": "696e12da8eea497a1a3808d714b43ff67a7df43e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/7b665e443381ea7c4db03eb03b4bf79ea2b020eb", - "reference": "7b665e443381ea7c4db03eb03b4bf79ea2b020eb", + "url": "https://api.github.com/repos/symfony/config/zipball/696e12da8eea497a1a3808d714b43ff67a7df43e", + "reference": "696e12da8eea497a1a3808d714b43ff67a7df43e", "shasum": "" }, "require": { @@ -4638,7 +4645,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v7.4.14" + "source": "https://github.com/symfony/config/tree/v7.4.17" }, "funding": [ { @@ -4658,20 +4665,20 @@ "type": "tidelift" } ], - "time": "2026-06-09T07:51:57+00:00" + "time": "2026-08-20T09:55:18+00:00" }, { "name": "symfony/dependency-injection", - "version": "v7.4.14", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "2c8c64a33e2e6911579e1ff79a8e06c27d48d402" + "reference": "f318ac9da5aba0be2cf43ecdd562c05a33bc11c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/2c8c64a33e2e6911579e1ff79a8e06c27d48d402", - "reference": "2c8c64a33e2e6911579e1ff79a8e06c27d48d402", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f318ac9da5aba0be2cf43ecdd562c05a33bc11c0", + "reference": "f318ac9da5aba0be2cf43ecdd562c05a33bc11c0", "shasum": "" }, "require": { @@ -4722,7 +4729,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v7.4.14" + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.17" }, "funding": [ { @@ -4742,7 +4749,7 @@ "type": "tidelift" } ], - "time": "2026-06-24T07:41:05+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4817,16 +4824,16 @@ }, { "name": "symfony/filesystem", - "version": "v7.4.11", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50" + "reference": "90d412aa5277c6819db39e7605aa46b1019e3232" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d721ea61b4a5fba8c5b6e7c1feda19efea144b50", - "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/90d412aa5277c6819db39e7605aa46b1019e3232", + "reference": "90d412aa5277c6819db39e7605aa46b1019e3232", "shasum": "" }, "require": { @@ -4863,7 +4870,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.11" + "source": "https://github.com/symfony/filesystem/tree/v7.4.18" }, "funding": [ { @@ -4883,7 +4890,7 @@ "type": "tidelift" } ], - "time": "2026-05-11T16:38:44+00:00" + "time": "2026-08-23T10:03:40+00:00" }, { "name": "symfony/polyfill-ctype", @@ -4970,16 +4977,16 @@ }, { "name": "symfony/polyfill-deepclone", - "version": "v1.40.0", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-deepclone.git", - "reference": "dca4ccba5f360070b574414dce4c1e7a559844fa" + "reference": "70ba0627efc68e97ea392843458a2dd9d6dbd156" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/dca4ccba5f360070b574414dce4c1e7a559844fa", - "reference": "dca4ccba5f360070b574414dce4c1e7a559844fa", + "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/70ba0627efc68e97ea392843458a2dd9d6dbd156", + "reference": "70ba0627efc68e97ea392843458a2dd9d6dbd156", "shasum": "" }, "require": { @@ -5033,7 +5040,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.40.0" + "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.42.0" }, "funding": [ { @@ -5053,7 +5060,7 @@ "type": "tidelift" } ], - "time": "2026-06-12T07:27:17+00:00" + "time": "2026-08-07T06:33:24+00:00" }, { "name": "symfony/polyfill-mbstring", @@ -5142,16 +5149,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -5205,7 +5212,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -5225,20 +5232,20 @@ "type": "tidelift" } ], - "time": "2026-06-16T09:55:08+00:00" + "time": "2026-07-27T15:39:01+00:00" }, { "name": "symfony/var-exporter", - "version": "v8.1.1", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "75b74315b4e4be40e5534cf9c5cc30dd0907ed71" + "reference": "802362f41128c430fd6685491e1fb72127fae78a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/75b74315b4e4be40e5534cf9c5cc30dd0907ed71", - "reference": "75b74315b4e4be40e5534cf9c5cc30dd0907ed71", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/802362f41128c430fd6685491e1fb72127fae78a", + "reference": "802362f41128c430fd6685491e1fb72127fae78a", "shasum": "" }, "require": { @@ -5288,7 +5295,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v8.1.1" + "source": "https://github.com/symfony/var-exporter/tree/v8.1.6" }, "funding": [ { @@ -5308,7 +5315,7 @@ "type": "tidelift" } ], - "time": "2026-06-27T09:05:56+00:00" + "time": "2026-08-29T06:30:03+00:00" }, { "name": "theseer/tokenizer", diff --git a/config.default.ini b/config.default.ini index 1825584c..0828ff55 100644 --- a/config.default.ini +++ b/config.default.ini @@ -15,6 +15,9 @@ globals_whitelist_cookies= force_trailing_slash=true error_page_dir=page/_error +[sentry] +dsn= + [router] router_file=router.php router_class=AppRouter diff --git a/src/Application.php b/src/Application.php index 7011e1d7..8da10322 100644 --- a/src/Application.php +++ b/src/Application.php @@ -14,6 +14,7 @@ use ReflectionMethod; use GT\WebEngine\Debug\OutputBuffer; use GT\WebEngine\Debug\Timer; +use GT\WebEngine\Debug\SentryReporter; use GT\WebEngine\Redirection\Redirect; use GT\WebEngine\Redirection\RedirectUri; use GT\WebEngine\Dispatch\Dispatcher; @@ -57,6 +58,7 @@ class Application { private Dispatcher $dispatcher; private static bool $loggerConfigured = false; private bool $finished = false; + private SentryReporter $sentryReporter; /** * @param null|array> $globals @@ -74,6 +76,7 @@ public function __construct( ?Protection $globalProtection = null, ) { $this->config = $config ?? $this->loadConfig(); + $this->sentryReporter = new SentryReporter($this->config); $this->configureLoggerStreams(); $this->redirect = $this->createRedirect($redirect); $application = $this; @@ -184,6 +187,7 @@ private function createRedirect(?Redirect $redirect = null):Redirect { } private function handleThrowable(Throwable $throwable):?Response { + $this->sentryReporter->report($throwable); if ($errorScript = $this->config->getString('app.error_script')) { $this->restoreGlobals(); require($errorScript); @@ -213,6 +217,7 @@ private function handleThrowable(Throwable $throwable):?Response { return $this->dispatcher->generateErrorResponse($throwable); } catch(Throwable $innerThrowable) { + $this->sentryReporter->report($innerThrowable); $this->logErrorMessage( "Failed to render framework error response: " . (string)$innerThrowable, [ diff --git a/src/Debug/SentryReporter.php b/src/Debug/SentryReporter.php new file mode 100644 index 00000000..ec0e2b0a --- /dev/null +++ b/src/Debug/SentryReporter.php @@ -0,0 +1,47 @@ + */ + private WeakMap $reported; + + public function __construct(Config $config) { + $this->reported = new WeakMap(); + $dsn = trim($config->getString("sentry.dsn") ?? ""); + if(!$dsn || !function_exists("Sentry\\init") || !function_exists("Sentry\\captureException")) { + return; + } + + try { + \Sentry\init(["dsn" => $dsn]); + $this->enabled = true; + } + catch(Throwable) { + error_log("WebEngine: Sentry initialization failed."); + } + } + + public function report(Throwable $throwable):void { + if(!$this->enabled || isset($this->reported[$throwable]) || !function_exists("Sentry\\captureException")) { + return; + } + if($throwable instanceof ResponseStatusException && $throwable->getHttpCode() < 500) { + return; + } + + $this->reported[$throwable] = true; + try { + \Sentry\captureException($throwable); + } + catch(Throwable) { + error_log("WebEngine: Sentry exception reporting failed."); + } + } +} diff --git a/test/phpunit/ApplicationTest.php b/test/phpunit/ApplicationTest.php index 5798a6a5..f0865794 100644 --- a/test/phpunit/ApplicationTest.php +++ b/test/phpunit/ApplicationTest.php @@ -19,6 +19,7 @@ use GT\WebEngine\Application; use GT\WebEngine\Debug\OutputBuffer; use GT\WebEngine\Debug\Timer; +use GT\WebEngine\Debug\SentryReporter; use GT\WebEngine\Dispatch\Dispatcher; use GT\WebEngine\Dispatch\DispatcherFactory; use GT\WebEngine\Init\SessionInit; @@ -511,6 +512,8 @@ public function testHandleThrowable_rebuildsDispatcherAndReturnsGeneratedErrorRe $request = $this->createServerRequest("/broken"); $sessionInit = self::createStub(SessionInit::class); $throwable = new Exception("page failed"); + $reporter = self::createMock(SentryReporter::class); + $reporter->expects(self::once())->method("report")->with(self::identicalTo($throwable)); $errorResponse = $this->createResponse(500, "error"); $firstDispatcher = self::createMock(Dispatcher::class); @@ -543,6 +546,7 @@ public function testHandleThrowable_rebuildsDispatcherAndReturnsGeneratedErrorRe dispatcherFactory: $dispatcherFactory, globalProtection: self::createStub(Protection::class), ); + $this->setPrivateProperty($sut, "sentryReporter", $reporter); $this->setPrivateProperty($sut, "request", $request); $this->setPrivateProperty($sut, "dispatcher", $firstDispatcher); diff --git a/test/phpunit/Debug/SentryReporterTest.php b/test/phpunit/Debug/SentryReporterTest.php new file mode 100644 index 00000000..d67a4c4c --- /dev/null +++ b/test/phpunit/Debug/SentryReporterTest.php @@ -0,0 +1,59 @@ +config()))->report(new RuntimeException("test")); + } + + public function testMissingDsnDoesNotInitialiseSdk():void { + require __DIR__ . "/../Fixture/sentry-functions.php"; + (new SentryReporter($this->config("")))->report(new RuntimeException("test")); + self::assertArrayNotHasKey("sentry_options", $GLOBALS); + self::assertArrayNotHasKey("sentry_events", $GLOBALS); + } + + public function testReportsOriginalThrowableOnceAndSkipsClientErrors():void { + require __DIR__ . "/../Fixture/sentry-functions.php"; + $reporter = new SentryReporter($this->config()); + $error = new RuntimeException("Test exception"); + $reporter->report($error); + $reporter->report($error); + $reporter->report(new HttpNotFound()); + self::assertSame(["dsn" => "https://key@example.com/1"], $GLOBALS["sentry_options"]); + self::assertSame([$error], $GLOBALS["sentry_events"]); + } + + public function testInitialisationFailureDoesNotEscape():void { + require __DIR__ . "/../Fixture/sentry-functions.php"; + $GLOBALS["sentry_init_fails"] = true; + (new SentryReporter($this->config()))->report(new RuntimeException("test")); + self::assertArrayNotHasKey("sentry_events", $GLOBALS); + } + + public function testCaptureFailureDoesNotEscape():void { + require __DIR__ . "/../Fixture/sentry-functions.php"; + $GLOBALS["sentry_capture_fails"] = true; + (new SentryReporter($this->config()))->report(new RuntimeException("test")); + self::assertArrayNotHasKey("sentry_events", $GLOBALS); + } + + private function config(string $dsn = "https://key@example.com/1"):Config { + $config = self::createStub(Config::class); + $config->method("getString")->willReturnCallback( + fn(string $key):?string => $key === "sentry.dsn" ? $dsn : null, + ); + return $config; + } +} diff --git a/test/phpunit/Dispatch/DispatcherTest.php b/test/phpunit/Dispatch/DispatcherTest.php index 87e130b9..fcd0b7a4 100644 --- a/test/phpunit/Dispatch/DispatcherTest.php +++ b/test/phpunit/Dispatch/DispatcherTest.php @@ -187,7 +187,13 @@ public function testGenerateResponse_streamsJsonDocumentMutatedByPostLogic():voi self::assertSame("/tmp/api:go", $response->getHeaderLine("X-Logic-Execution")); } - public function testGenerateResponse_jsonDocumentErrorFinishesResponseAndInterruptsLogic():void { + #[\PHPUnit\Framework\Attributes\DataProvider("jsonErrorProvider")] + public function testGenerateResponse_jsonDocumentErrorFinishesResponseAndInterruptsLogic( + ?array $context, + ?int $existingStatus, + string $expectedJson, + ?int $errorStatus = StatusCode::UNPROCESSABLE_ENTITY, + ):void { $stream = new Stream(); $view = new JSONView($stream); $viewModel = new JSONDocument(); @@ -200,13 +206,18 @@ public function testGenerateResponse_jsonDocumentErrorFinishesResponseAndInterru $logicExecutor = $this->createMock(LogicExecutor::class); $logicExecutor->method("invoke") - ->willReturnCallback(function(Assembly $assembly, string $name)use($logicAssembly, $viewModel):\Generator { + ->willReturnCallback(function(Assembly $assembly, string $name)use($logicAssembly, $viewModel, $context, $errorStatus):\Generator { self::assertSame($logicAssembly, $assembly); if($name !== "go") { return; } - $viewModel->error("missing parameter: name", StatusCode::UNPROCESSABLE_ENTITY); + if($errorStatus === null) { + $viewModel->error("missing parameter: name"); + } + else { + $viewModel->error("missing parameter: name", $errorStatus, $context); + } $viewModel->set("hello", "Greg"); yield "/tmp/api.php::go()"; }); @@ -224,15 +235,27 @@ public function testGenerateResponse_jsonDocumentErrorFinishesResponseAndInterru }, ); + if($existingStatus !== null) { + $this->setPrivateProperty($sut, "response", new Response($existingStatus)); + } $response = $sut->generateResponse(); - self::assertSame(StatusCode::UNPROCESSABLE_ENTITY, $response->getStatusCode()); + self::assertSame($existingStatus ?? $errorStatus ?? StatusCode::BAD_REQUEST, $response->getStatusCode()); self::assertSame("application/json", $response->getHeaderLine("Content-Type")); - self::assertSame("{\"error\":\"missing parameter: name\"}\n", (string)$stream); + self::assertSame($expectedJson . "\n", (string)$stream); self::assertSame($response, $finishedResponse); self::assertSame("", $response->getHeaderLine("X-Logic-Execution")); } + public static function jsonErrorProvider():array { + return [ + "default status" => [null, null, '{"error":"missing parameter: name"}', null], + "no context" => [null, null, '{"error":"missing parameter: name"}'], + "context" => [["field" => "name"], null, '{"error":"missing parameter: name","errorContext":{"field":"name"}}'], + "existing status" => [null, StatusCode::BAD_REQUEST, '{"error":"missing parameter: name"}'], + ]; + } + public function testGenerateResponse_executesComponentAndPageLogicAndAppliesHeaders():void { $viewModel = new HTMLDocument( '' diff --git a/test/phpunit/Fixture/sentry-functions.php b/test/phpunit/Fixture/sentry-functions.php new file mode 100644 index 00000000..c42e62bb --- /dev/null +++ b/test/phpunit/Fixture/sentry-functions.php @@ -0,0 +1,16 @@ + Date: Mon, 14 Sep 2026 22:17:20 +0100 Subject: [PATCH 2/5] tweak: object-oriented sentry --- README.md | 17 +- composer.json | 1 + composer.lock | 567 +++++++++++++++++++++- src/Application.php | 18 +- src/Debug/SentryReporter.php | 46 +- test/phpunit/Debug/SentryReporterTest.php | 110 ++++- test/phpunit/Fixture/sentry-functions.php | 16 - 7 files changed, 711 insertions(+), 64 deletions(-) delete mode 100644 test/phpunit/Fixture/sentry-functions.php diff --git a/README.md b/README.md index 57b1d970..016b3199 100644 --- a/README.md +++ b/README.md @@ -102,8 +102,8 @@ dsn=https://YOUR_KEY@app.glitchtip.com/YOUR_PROJECT The DSN can point to Sentry or a compatible service such as GlitchTip. -WebEngine initializes the SDK when both the SDK functions and a nonempty DSN -are available. No Sentry initialization in `setup.php` is necessary. Leave the +WebEngine initializes an SDK client in `Application::start()` when both the SDK +and a nonempty DSN are available. No Sentry initialization in `setup.php` is necessary. Leave the DSN empty in environments that should not report errors. Exceptions escaping request logic are reported before the normal error page or @@ -112,11 +112,14 @@ reported. Expected HTTP responses below 500 are excluded. Reporting failures do not replace the application's error response. Exceptions caught and handled by application code still require explicit reporting if desired. -The SDK's default handlers remain responsible for uncaught exceptions outside -the request handler and fatal PHP errors; WebEngine does not separately report -shutdown errors to avoid duplicate fatal events. Performance tracing is not -enabled by this integration. Review SDK data filtering for sensitive application -payloads before enabling reporting. +The reporter uses an injected `Sentry\ClientInterface` and PSR-7 request, without +the SDK's global hub or default integrations. WebEngine reports fatal errors +through its shutdown handler once the reporter is initialized. Errors before +initialization are not captured. Performance tracing is not enabled. + +Request context includes only the HTTP method and URL without credentials, +query parameters or fragments. Headers, cookies and request bodies are omitted. +Exception messages may still contain sensitive data; review what your application throws. # Proudly sponsored by diff --git a/composer.json b/composer.json index 48ea2d48..cdd0ca62 100644 --- a/composer.json +++ b/composer.json @@ -48,6 +48,7 @@ }, "require-dev": { + "sentry/sentry": "^4.0", "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^12.4", "phpmd/phpmd": "^2.13", diff --git a/composer.lock b/composer.lock index 0364d62f..9bff689f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "713f6aa567b11f3e42f96c22b6f6d060", + "content-hash": "093c2410d53313a4ffd2480148e7e2d9", "packages": [ { "name": "composer/semver", @@ -2601,6 +2601,185 @@ ], "time": "2024-05-06T16:37:16+00:00" }, + { + "name": "guzzlehttp/psr7", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/a3059ba1a84c9139c4ae03cf0f45bea276c97c74", + "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^2.0", + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php82": "^1.27" + }, + "provide": { + "psr/http-factory-implementation": "1.1", + "psr/http-message-implementation": "2.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "php-http/psr7-integration-tests": "^1.5.1", + "phpunit/phpunit": "^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/3.1.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-08-24T11:02:13+00:00" + }, + { + "name": "jean85/pretty-package-versions", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/Jean85/pretty-package-versions.git", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.1.0", + "php": "^7.4|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "jean85/composer-provided-replaced-stub-package": "^1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^7.5|^8.5|^9.6", + "rector/rector": "^2.0", + "vimeo/psalm": "^4.3 || ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Jean85\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Lai", + "email": "alessandro.lai85@gmail.com" + } + ], + "description": "A library to get pretty versions strings of installed dependencies", + "keywords": [ + "composer", + "package", + "release", + "versions" + ], + "support": { + "issues": "https://github.com/Jean85/pretty-package-versions/issues", + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1" + }, + "time": "2025-03-19T14:43:43+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.14.0", @@ -3481,6 +3660,61 @@ ], "time": "2026-09-09T04:48:50+00:00" }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, { "name": "psr/log", "version": "3.0.2", @@ -4452,6 +4686,102 @@ ], "time": "2025-02-07T05:00:38+00:00" }, + { + "name": "sentry/sentry", + "version": "4.31.0", + "source": { + "type": "git", + "url": "https://github.com/getsentry/sentry-php.git", + "reference": "39d9f86bd56c6cc6ae7b64c2b849f7f046d38394" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/39d9f86bd56c6cc6ae7b64c2b849f7f046d38394", + "reference": "39d9f86bd56c6cc6ae7b64c2b849f7f046d38394", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "guzzlehttp/psr7": "^1.8.4|^2.1.1|^3.0.0", + "jean85/pretty-package-versions": "^1.5|^2.0.4", + "php": "^7.2|^8.0", + "psr/log": "^1.0|^2.0|^3.0", + "symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0" + }, + "conflict": { + "raven/raven": "*" + }, + "require-dev": { + "carthage-software/mago": "1.30.0", + "friendsofphp/php-cs-fixer": "^3.4", + "guzzlehttp/guzzle": "^7.0|^8.0", + "guzzlehttp/promises": "^2.0.3|^3.0.0", + "monolog/monolog": "^1.6|^2.0|^3.0", + "nyholm/psr7": "^1.8", + "open-telemetry/api": "^1.1", + "open-telemetry/context": "^1.1", + "open-telemetry/exporter-otlp": "^1.1", + "open-telemetry/sdk": "^1.1", + "open-telemetry/sem-conv": "^1.27", + "phpstan/phpstan": "^1.3", + "phpunit/phpunit": "^8.5.52|^9.6.34", + "spiral/roadrunner-http": "^3.6", + "spiral/roadrunner-worker": "^3.6" + }, + "suggest": { + "ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.", + "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler." + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Sentry\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sentry", + "email": "accounts@sentry.io" + } + ], + "description": "PHP SDK for Sentry (http://sentry.io)", + "homepage": "http://sentry.io", + "keywords": [ + "crash-reporting", + "crash-reports", + "error-handler", + "error-monitoring", + "log", + "logging", + "profiling", + "sentry", + "tracing" + ], + "support": { + "issues": "https://github.com/getsentry/sentry-php/issues", + "source": "https://github.com/getsentry/sentry-php/tree/4.31.0" + }, + "funding": [ + { + "url": "https://sentry.io/", + "type": "custom" + }, + { + "url": "https://sentry.io/pricing/", + "type": "custom" + } + ], + "time": "2026-08-27T12:59:01+00:00" + }, { "name": "squizlabs/php_codesniffer", "version": "4.0.4", @@ -4892,6 +5222,77 @@ ], "time": "2026-08-23T10:03:40+00:00" }, + { + "name": "symfony/options-resolver", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, { "name": "symfony/polyfill-ctype", "version": "v1.37.0", @@ -5147,6 +5548,170 @@ ], "time": "2026-05-27T06:59:30+00:00" }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php82", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php82.git", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php82\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php82/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:45:58+00:00" + }, { "name": "symfony/service-contracts", "version": "v3.7.3", diff --git a/src/Application.php b/src/Application.php index 8da10322..9c4ca7b6 100644 --- a/src/Application.php +++ b/src/Application.php @@ -58,7 +58,7 @@ class Application { private Dispatcher $dispatcher; private static bool $loggerConfigured = false; private bool $finished = false; - private SentryReporter $sentryReporter; + private ?SentryReporter $sentryReporter = null; /** * @param null|array> $globals @@ -76,7 +76,6 @@ public function __construct( ?Protection $globalProtection = null, ) { $this->config = $config ?? $this->loadConfig(); - $this->sentryReporter = new SentryReporter($this->config); $this->configureLoggerStreams(); $this->redirect = $this->createRedirect($redirect); $application = $this; @@ -122,11 +121,6 @@ public function start():void { // to any area of code will not accidentally send output to the web browser. $this->outputBuffer->start(); -// PHP.GT provides object-oriented interfaces to all values stored in $_SERVER, -// $_FILES, $_GET, and $_POST - to enforce good encapsulation and safe variable -// usage, the globals are protected against accidental misuse. - $this->protectGlobals(); - // The RequestFactory takes the necessary global arrays to construct a // ServerRequest object. The $_SERVER array contains metadata about the request, // such as headers and server variables. $_FILES contains any uploaded files, @@ -142,6 +136,11 @@ public function start():void { assert($request instanceof Request); $this->request = $request; +// Initialise SDK options before protecting globals. Request context is injected +// into the reporter, so reporting itself does not require global access. + $this->sentryReporter ??= SentryReporter::create($this->config, $request); + $this->protectGlobals(); + // The Dispatcher is a core component responsible for: // 1. Executing the application's routing logic to match the incoming request // 2. Running any middleware defined for the matched route @@ -187,7 +186,7 @@ private function createRedirect(?Redirect $redirect = null):Redirect { } private function handleThrowable(Throwable $throwable):?Response { - $this->sentryReporter->report($throwable); + $this->sentryReporter?->report($throwable); if ($errorScript = $this->config->getString('app.error_script')) { $this->restoreGlobals(); require($errorScript); @@ -217,7 +216,7 @@ private function handleThrowable(Throwable $throwable):?Response { return $this->dispatcher->generateErrorResponse($throwable); } catch(Throwable $innerThrowable) { - $this->sentryReporter->report($innerThrowable); + $this->sentryReporter?->report($innerThrowable); $this->logErrorMessage( "Failed to render framework error response: " . (string)$innerThrowable, [ @@ -387,6 +386,7 @@ private function handleShutdown():void { $error["file"], $error["line"], ); + $this->sentryReporter?->report($throwable); $this->logError($throwable); if(!isset($this->dispatcher)) { diff --git a/src/Debug/SentryReporter.php b/src/Debug/SentryReporter.php index ec0e2b0a..145edef1 100644 --- a/src/Debug/SentryReporter.php +++ b/src/Debug/SentryReporter.php @@ -3,33 +3,51 @@ use GT\Config\Config; use GT\Http\ResponseStatusException\ResponseStatusException; +use Psr\Http\Message\ServerRequestInterface; +use Sentry\ClientBuilder; +use Sentry\ClientInterface; +use Sentry\Event; +use Sentry\State\Scope; use Throwable; use WeakMap; -/** Optional SDK bridge. Reporting must never prevent an application response. */ +/** Optional SDK bridge with explicit client and request dependencies. */ class SentryReporter { - private bool $enabled = false; /** @var WeakMap */ private WeakMap $reported; - public function __construct(Config $config) { + public function __construct( + private ClientInterface $client, + private ServerRequestInterface $request, + ) { $this->reported = new WeakMap(); + } + + /** Must run before global protection: SDK option defaults read superglobals. */ + public static function create(Config $config, ServerRequestInterface $request):?self { $dsn = trim($config->getString("sentry.dsn") ?? ""); - if(!$dsn || !function_exists("Sentry\\init") || !function_exists("Sentry\\captureException")) { - return; + if(!$dsn || !class_exists(ClientBuilder::class)) { + return null; } try { - \Sentry\init(["dsn" => $dsn]); - $this->enabled = true; + $client = ClientBuilder::create([ + "dsn" => $dsn, + "default_integrations" => false, + ])->getClient(); + if($client->getOptions()->getDsn() === null) { + return null; + } + return new self($client, $request); } catch(Throwable) { error_log("WebEngine: Sentry initialization failed."); + return null; } } public function report(Throwable $throwable):void { - if(!$this->enabled || isset($this->reported[$throwable]) || !function_exists("Sentry\\captureException")) { + if(isset($this->reported[$throwable])) { return; } if($throwable instanceof ResponseStatusException && $throwable->getHttpCode() < 500) { @@ -38,7 +56,17 @@ public function report(Throwable $throwable):void { $this->reported[$throwable] = true; try { - \Sentry\captureException($throwable); + $scope = new Scope(); + $scope->addEventProcessor(function(Event $event):Event { + // Explicitly exclude credentials, query values, cookies and payloads. + $uri = $this->request->getUri()->withUserInfo("")->withQuery("")->withFragment(""); + $event->setRequest([ + "url" => (string)$uri, + "method" => $this->request->getMethod(), + ]); + return $event; + }); + $this->client->captureException($throwable, $scope); } catch(Throwable) { error_log("WebEngine: Sentry exception reporting failed."); diff --git a/test/phpunit/Debug/SentryReporterTest.php b/test/phpunit/Debug/SentryReporterTest.php index d67a4c4c..feca7d41 100644 --- a/test/phpunit/Debug/SentryReporterTest.php +++ b/test/phpunit/Debug/SentryReporterTest.php @@ -3,50 +3,116 @@ use GT\Config\Config; use GT\Http\ResponseStatusException\ClientError\HttpNotFound; +use Gt\ProtectedGlobal\Protection; use GT\WebEngine\Debug\SentryReporter; -use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; -use PHPUnit\Framework\Attributes\PreserveGlobalState; +use GuzzleHttp\Psr7\ServerRequest; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use ReflectionProperty; use RuntimeException; +use Sentry\ClientInterface; +use Sentry\Event; +use Sentry\Transport\TransportInterface; +use Sentry\Transport\Result; +use Sentry\Transport\ResultStatus; -#[RunTestsInSeparateProcesses] -#[PreserveGlobalState(false)] class SentryReporterTest extends TestCase { + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] public function testMissingSdkIsSafe():void { - self::assertFalse(function_exists("Sentry\\init")); - (new SentryReporter($this->config()))->report(new RuntimeException("test")); + $config = $this->config(); + $request = new ServerRequest("GET", "/"); + class_exists(SentryReporter::class); + $autoloaders = spl_autoload_functions(); + foreach($autoloaders as $autoloader) { + spl_autoload_unregister($autoloader); + } + $withoutSentry = static function(string $class) use ($autoloaders):void { + if(str_starts_with($class, "Sentry\\")) { + return; + } + foreach($autoloaders as $autoloader) { + $autoloader($class); + } + }; + spl_autoload_register($withoutSentry); + try { + $hasSdk = class_exists(\Sentry\ClientBuilder::class); + $reporter = SentryReporter::create($config, $request); + } + finally { + spl_autoload_unregister($withoutSentry); + foreach($autoloaders as $autoloader) { + spl_autoload_register($autoloader); + } + } + self::assertFalse($hasSdk); + self::assertNull($reporter); } public function testMissingDsnDoesNotInitialiseSdk():void { - require __DIR__ . "/../Fixture/sentry-functions.php"; - (new SentryReporter($this->config("")))->report(new RuntimeException("test")); - self::assertArrayNotHasKey("sentry_options", $GLOBALS); - self::assertArrayNotHasKey("sentry_events", $GLOBALS); + self::assertNull(SentryReporter::create($this->config(""), new ServerRequest("GET", "/"))); } public function testReportsOriginalThrowableOnceAndSkipsClientErrors():void { - require __DIR__ . "/../Fixture/sentry-functions.php"; - $reporter = new SentryReporter($this->config()); $error = new RuntimeException("Test exception"); + $client = self::createMock(ClientInterface::class); + $client->expects(self::once())->method("captureException")->with($error); + $reporter = new SentryReporter($client, new ServerRequest("GET", "/")); $reporter->report($error); $reporter->report($error); $reporter->report(new HttpNotFound()); - self::assertSame(["dsn" => "https://key@example.com/1"], $GLOBALS["sentry_options"]); - self::assertSame([$error], $GLOBALS["sentry_events"]); } public function testInitialisationFailureDoesNotEscape():void { - require __DIR__ . "/../Fixture/sentry-functions.php"; - $GLOBALS["sentry_init_fails"] = true; - (new SentryReporter($this->config()))->report(new RuntimeException("test")); - self::assertArrayNotHasKey("sentry_events", $GLOBALS); + self::assertNull(SentryReporter::create($this->config("invalid"), new ServerRequest("GET", "/"))); } public function testCaptureFailureDoesNotEscape():void { - require __DIR__ . "/../Fixture/sentry-functions.php"; - $GLOBALS["sentry_capture_fails"] = true; - (new SentryReporter($this->config()))->report(new RuntimeException("test")); - self::assertArrayNotHasKey("sentry_events", $GLOBALS); + $client = self::createMock(ClientInterface::class); + $client->expects(self::once())->method("captureException")->willThrowException(new RuntimeException("offline")); + (new SentryReporter($client, new ServerRequest("GET", "/")))->report(new RuntimeException("test")); + } + + public function testRealSdkReportsWithProtectedGlobalsAndSanitizedRequest():void { + $request = new ServerRequest("POST", "https://user:secret@example.com/error?token=secret#fragment", [ + "Authorization" => "Bearer secret", + "Cookie" => "session=secret", + ], "password=secret"); + $reporter = SentryReporter::create($this->config(), $request); + self::assertNotNull($reporter); + $event = null; + $transport = self::createMock(TransportInterface::class); + $transport->expects(self::once())->method("send")->willReturnCallback( + function(Event $captured) use (&$event):Result { + $event = $captured; + return new Result(ResultStatus::success(), $captured); + }, + ); + $client = (new ReflectionProperty(SentryReporter::class, "client"))->getValue($reporter); + (new ReflectionProperty($client, "transport"))->setValue($client, $transport); + $originalGlobals = []; + foreach(Protection::GLOBAL_KEYS as $key) { + $originalGlobals[$key] = $GLOBALS[$key] ?? null; + } + try { + (new Protection())->overrideInternals([]); + $reporter->report(new RuntimeException("Protected globals exception")); + } + finally { + foreach($originalGlobals as $key => $value) { + if($value === null) { + unset($GLOBALS[$key]); + } + else { + $GLOBALS[$key] = $value; + } + } + } + self::assertInstanceOf(Event::class, $event); + self::assertSame(["url" => "https://example.com/error", "method" => "POST"], $event->getRequest()); + self::assertSame("Protected globals exception", $event->getExceptions()[0]->getValue()); } private function config(string $dsn = "https://key@example.com/1"):Config { diff --git a/test/phpunit/Fixture/sentry-functions.php b/test/phpunit/Fixture/sentry-functions.php deleted file mode 100644 index c42e62bb..00000000 --- a/test/phpunit/Fixture/sentry-functions.php +++ /dev/null @@ -1,16 +0,0 @@ - Date: Mon, 14 Sep 2026 22:34:51 +0100 Subject: [PATCH 3/5] feature: send environment in config --- README.md | 4 +++ config.default.ini | 1 + src/Debug/SentryReporter.php | 9 +++-- test/phpunit/Debug/SentryReporterTest.php | 43 ++++++++++++++++++++--- 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 016b3199..a7574a71 100644 --- a/README.md +++ b/README.md @@ -98,9 +98,13 @@ provides it), then configure your project's `config.ini`: ```ini [sentry] dsn=https://YOUR_KEY@app.glitchtip.com/YOUR_PROJECT +environment=production ``` The DSN can point to Sentry or a compatible service such as GlitchTip. +The optional `sentry.environment` is trimmed and included with reported errors. +If missing, empty or whitespace-only, the SDK's default behavior applies: +`SENTRY_ENVIRONMENT` if supplied by the server, otherwise `production`. WebEngine initializes an SDK client in `Application::start()` when both the SDK and a nonempty DSN are available. No Sentry initialization in `setup.php` is necessary. Leave the diff --git a/config.default.ini b/config.default.ini index 0828ff55..723dd44a 100644 --- a/config.default.ini +++ b/config.default.ini @@ -17,6 +17,7 @@ error_page_dir=page/_error [sentry] dsn= +environment= [router] router_file=router.php diff --git a/src/Debug/SentryReporter.php b/src/Debug/SentryReporter.php index 145edef1..3e61c91c 100644 --- a/src/Debug/SentryReporter.php +++ b/src/Debug/SentryReporter.php @@ -31,10 +31,15 @@ public static function create(Config $config, ServerRequestInterface $request):? } try { - $client = ClientBuilder::create([ + $environment = trim($config->getString("sentry.environment") ?? ""); + $options = [ "dsn" => $dsn, "default_integrations" => false, - ])->getClient(); + ]; + if($environment !== "") { + $options["environment"] = $environment; + } + $client = ClientBuilder::create($options)->getClient(); if($client->getOptions()->getDsn() === null) { return null; } diff --git a/test/phpunit/Debug/SentryReporterTest.php b/test/phpunit/Debug/SentryReporterTest.php index feca7d41..9f741bc8 100644 --- a/test/phpunit/Debug/SentryReporterTest.php +++ b/test/phpunit/Debug/SentryReporterTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\DataProvider; use ReflectionProperty; use RuntimeException; use Sentry\ClientInterface; @@ -75,12 +76,41 @@ public function testCaptureFailureDoesNotEscape():void { (new SentryReporter($client, new ServerRequest("GET", "/")))->report(new RuntimeException("test")); } - public function testRealSdkReportsWithProtectedGlobalsAndSanitizedRequest():void { + /** @return array */ + public static function environments():array { + return [ + "missing" => [null, "production", null], + "empty" => ["", "production", null], + "whitespace" => [" \t\n", "production", null], + "production" => ["production", "production", null], + "trimmed" => [" staging ", "staging", null], + "zero" => ["0", "0", null], + "sdk environment" => [null, "development", "development"], + "config overrides sdk" => ["staging", "staging", "development"], + ]; + } + + #[DataProvider("environments")] + public function testRealSdkReportsWithProtectedGlobalsAndSanitizedRequest( + ?string $environment, + string $expectedEnvironment, + ?string $serverEnvironment, + ):void { $request = new ServerRequest("POST", "https://user:secret@example.com/error?token=secret#fragment", [ "Authorization" => "Bearer secret", "Cookie" => "session=secret", ], "password=secret"); - $reporter = SentryReporter::create($this->config(), $request); + $originalServer = $_SERVER; + try { + unset($_SERVER["SENTRY_ENVIRONMENT"]); + if($serverEnvironment !== null) { + $_SERVER["SENTRY_ENVIRONMENT"] = $serverEnvironment; + } + $reporter = SentryReporter::create($this->config(environment: $environment), $request); + } + finally { + $_SERVER = $originalServer; + } self::assertNotNull($reporter); $event = null; $transport = self::createMock(TransportInterface::class); @@ -111,14 +141,19 @@ function(Event $captured) use (&$event):Result { } } self::assertInstanceOf(Event::class, $event); + self::assertSame($expectedEnvironment, $event->getEnvironment()); self::assertSame(["url" => "https://example.com/error", "method" => "POST"], $event->getRequest()); self::assertSame("Protected globals exception", $event->getExceptions()[0]->getValue()); } - private function config(string $dsn = "https://key@example.com/1"):Config { + private function config(string $dsn = "https://key@example.com/1", ?string $environment = null):Config { $config = self::createStub(Config::class); $config->method("getString")->willReturnCallback( - fn(string $key):?string => $key === "sentry.dsn" ? $dsn : null, + fn(string $key):?string => match($key) { + "sentry.dsn" => $dsn, + "sentry.environment" => $environment, + default => null, + }, ); return $config; } From 632c3ed7ecc87994423fd4793438b573496c5801 Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Mon, 14 Sep 2026 22:51:19 +0100 Subject: [PATCH 4/5] feature: send logs to sentry --- README.md | 35 ------ config.default.ini | 6 +- src/Application.php | 91 ++++++++++++---- src/Debug/LoggerConfigurationException.php | 6 + src/Debug/SentryLogHandler.php | 85 +++++++++++++++ src/Debug/SentryReporter.php | 4 + test/phpunit/ApplicationTest.php | 115 ++++++++++++++++++++ test/phpunit/Debug/SentryLogHandlerTest.php | 99 +++++++++++++++++ 8 files changed, 382 insertions(+), 59 deletions(-) create mode 100644 src/Debug/LoggerConfigurationException.php create mode 100644 src/Debug/SentryLogHandler.php create mode 100644 test/phpunit/Debug/SentryLogHandlerTest.php diff --git a/README.md b/README.md index a7574a71..3169b247 100644 --- a/README.md +++ b/README.md @@ -90,41 +90,6 @@ It would be helpful if you could create your issue in the appropriate repository [contributing]: https://github.com/PhpGt/WebEngine/blob/master/CONTRIBUTING.md [issues]: https://github.com/PhpGt/WebEngine/issues -## Optional Sentry error reporting - -Install `sentry/sentry` in your application (the `sentry/sdk` meta-package also -provides it), then configure your project's `config.ini`: - -```ini -[sentry] -dsn=https://YOUR_KEY@app.glitchtip.com/YOUR_PROJECT -environment=production -``` - -The DSN can point to Sentry or a compatible service such as GlitchTip. -The optional `sentry.environment` is trimmed and included with reported errors. -If missing, empty or whitespace-only, the SDK's default behavior applies: -`SENTRY_ENVIRONMENT` if supplied by the server, otherwise `production`. - -WebEngine initializes an SDK client in `Application::start()` when both the SDK -and a nonempty DSN are available. No Sentry initialization in `setup.php` is necessary. Leave the -DSN empty in environments that should not report errors. - -Exceptions escaping request logic are reported before the normal error page or -custom error script runs. Failures escaping error-page rendering are also -reported. Expected HTTP responses below 500 are excluded. Reporting failures -do not replace the application's error response. Exceptions caught and handled -by application code still require explicit reporting if desired. - -The reporter uses an injected `Sentry\ClientInterface` and PSR-7 request, without -the SDK's global hub or default integrations. WebEngine reports fatal errors -through its shutdown handler once the reporter is initialized. Errors before -initialization are not captured. Performance tracing is not enabled. - -Request context includes only the HTTP method and URL without credentials, -query parameters or fragments. Headers, cookies and request bodies are omitted. -Exception messages may still contain sensitive data; review what your application throws. - # Proudly sponsored by [JetBrains Open Source sponsorship program](https://www.jetbrains.com/community/opensource/) diff --git a/config.default.ini b/config.default.ini index 723dd44a..b100ab51 100644 --- a/config.default.ini +++ b/config.default.ini @@ -37,8 +37,12 @@ log_not_modified=false log_redirects=false ignore_post_fields=password,pass,passwd,password_confirm,password_confirmation,current_password,new_password,old_password,secret,client_secret,token,access_token,refresh_token,id_token,api_key,apikey,authorization,auth,bearer,otp,totp,mfa_code,verification_code,recovery_code,card_number,cc_number,credit_card,cvv,cvc,pin debug_to_javascript=true -stderr_level=ERROR +stderr_level=error +; Comma-separated destinations: stdout,sentry (requires sentry.dsn and SDK). type=stdout +; Levels (case-insensitive): debug,info,notice,warning,error,critical,alert,emergency. +; One minimum level for all destinations, or comma-separated levels matching type order. +; Example: type=sentry,stdout and level=error,debug. level=debug path= timestamp_format=Y-m-d H:i:s diff --git a/src/Application.php b/src/Application.php index 9c4ca7b6..38b31a68 100644 --- a/src/Application.php +++ b/src/Application.php @@ -15,6 +15,8 @@ use GT\WebEngine\Debug\OutputBuffer; use GT\WebEngine\Debug\Timer; use GT\WebEngine\Debug\SentryReporter; +use GT\WebEngine\Debug\SentryLogHandler; +use GT\WebEngine\Debug\LoggerConfigurationException; use GT\WebEngine\Redirection\Redirect; use GT\WebEngine\Redirection\RedirectUri; use GT\WebEngine\Dispatch\Dispatcher; @@ -59,6 +61,7 @@ class Application { private static bool $loggerConfigured = false; private bool $finished = false; private ?SentryReporter $sentryReporter = null; + private ?SentryLogHandler $sentryLogHandler = null; /** * @param null|array> $globals @@ -99,6 +102,9 @@ public function __construct( ], $globals ?? $GLOBALS); $this->globalProtection = $globalProtection ?? new Protection(); register_shutdown_function($handleShutdown ?? $this->handleShutdown(...)); + if($this->sentryLogHandler) { + register_shutdown_function($this->sentryLogHandler->flush(...)); + } } public function start():void { @@ -138,7 +144,7 @@ public function start():void { // Initialise SDK options before protecting globals. Request context is injected // into the reporter, so reporting itself does not require global access. - $this->sentryReporter ??= SentryReporter::create($this->config, $request); + $this->initializeSentry(); $this->protectGlobals(); // The Dispatcher is a core component responsible for: @@ -263,6 +269,7 @@ private function finish( $this->timer->stop(); $this->timer->logDelta(); + $this->sentryLogHandler?->flush(); } private function protectGlobals():void { @@ -312,22 +319,11 @@ private function loadConfig():Config { } private function configureLoggerStreams():void { + $destinations = $this->getLoggerDestinations(); if(self::$loggerConfigured) { return; } - $minimumLogLevel = $this->getMinimumLogLevel(); - $minimumLogLevelIndex = array_search($minimumLogLevel, LogLevel::ALL_LEVELS, true); - if($minimumLogLevelIndex === false) { - return; - } - - $stderrMinLevel = $this->getStderrMinimumLogLevel(); - $stderrMinLevelIndex = array_search($stderrMinLevel, LogLevel::ALL_LEVELS, true); - if($stderrMinLevelIndex === false) { - return; - } - if(!class_exists(StdErrHandler::class)) { return; } @@ -338,7 +334,25 @@ private function configureLoggerStreams():void { return; } - LogConfig::setDefaultHandlerLevel($minimumLogLevel); + LogConfig::setDefaultHandlerLevel($this->getMinimumLogLevel()); + foreach($destinations as $destination => $level) { + if($destination === "sentry") { + $this->sentryLogHandler = new SentryLogHandler(); + LogConfig::addHandler($this->sentryLogHandler, $level); + } + else { + $this->configureLocalLogger($level); + } + } + self::$loggerConfigured = true; + } + + private function configureLocalLogger(string $minimumLogLevel):void { + $minimumLogLevelIndex = array_search($minimumLogLevel, LogLevel::ALL_LEVELS, true); + $stderrMinLevelIndex = array_search($this->getStderrMinimumLogLevel(), LogLevel::ALL_LEVELS, true); + if($minimumLogLevelIndex === false || $stderrMinLevelIndex === false) { + return; + } if($stderrMinLevelIndex > $minimumLogLevelIndex) { $stdoutMaxLevel = LogLevel::ALL_LEVELS[$stderrMinLevelIndex - 1]; @@ -353,7 +367,13 @@ private function configureLoggerStreams():void { LogLevel::ALL_LEVELS[max($stderrMinLevelIndex, $minimumLogLevelIndex)], LogLevel::EMERGENCY, ); - self::$loggerConfigured = true; + } + + private function initializeSentry():void { + $this->sentryReporter ??= SentryReporter::create($this->config, $this->request); + if($this->sentryLogHandler) { + $this->sentryReporter?->connectLogHandler($this->sentryLogHandler); + } } private function handleShutdown():void { @@ -456,9 +476,9 @@ private function logErrorMessage(string $message, array $context = []):void { } private function getStderrMinimumLogLevel():string { - $configuredLevel = strtoupper( + $configuredLevel = strtoupper(trim( $this->config->getString("logger.stderr_level") ?: LogLevel::ERROR - ); + )); if(in_array($configuredLevel, LogLevel::ALL_LEVELS, true)) { return $configuredLevel; } @@ -467,16 +487,41 @@ private function getStderrMinimumLogLevel():string { } private function getMinimumLogLevel():string { - $configuredLevel = $this->config->getString("logger.level") - ?: LogLevel::DEBUG; - $configuredLevel = strtoupper($configuredLevel); - if(in_array($configuredLevel, LogLevel::ALL_LEVELS, true)) { - return $configuredLevel; + $levels = $this->getLoggerDestinations(); + foreach(LogLevel::ALL_LEVELS as $level) { + if(in_array($level, $levels, true)) { + return $level; + } } - return LogLevel::DEBUG; } + /** @return array */ + private function getLoggerDestinations():array { + $types = array_map("trim", explode(",", strtolower($this->config->getString("logger.type") ?: "stdout"))); + $levels = array_map("trim", explode(",", strtoupper($this->config->getString("logger.level") ?: "debug"))); + if(count($levels) !== 1 && count($levels) !== count($types)) { + throw new LoggerConfigurationException( + "logger.level must contain one shared level or one level per logger.type destination; " + . count($levels) . " levels supplied for " . count($types) . " destinations." + ); + } + $destinations = []; + foreach($types as $index => $type) { + $level = $levels[count($levels) === 1 ? 0 : $index]; + if(!in_array($level, LogLevel::ALL_LEVELS, true)) { + $level = LogLevel::DEBUG; + } + // A repeated destination needs only one handler, at its lowest threshold. + if(isset($destinations[$type]) && array_search($destinations[$type], LogLevel::ALL_LEVELS, true) + < array_search($level, LogLevel::ALL_LEVELS, true)) { + continue; + } + $destinations[$type] = $level; + } + return $destinations; + } + /** * @return array * @SuppressWarnings("PHPMD.Superglobals") diff --git a/src/Debug/LoggerConfigurationException.php b/src/Debug/LoggerConfigurationException.php new file mode 100644 index 00000000..c273a179 --- /dev/null +++ b/src/Debug/LoggerConfigurationException.php @@ -0,0 +1,6 @@ + */ + private array $pending = []; + private ?ClientInterface $client = null; + private ?string $traceId = null; + + public function setClient(ClientInterface $client):void { + $this->client = $client; + } + + /** @param array $context */ + public function handle(string $level, string $message, array $context = []):void { + // Context is deliberately excluded: arbitrary values can contain secrets. + $this->pending []= [ + "level" => strtoupper($level), + "message" => substr($message, 0, 8192), + "timestamp" => microtime(true), + ]; + if(count($this->pending) >= self::BATCH_SIZE) { + $this->flush(); + } + } + + public function flush():void { + if(!$this->pending) { + return; + } + $pending = $this->pending; + $this->pending = []; + try { + if(!$this->client) { + $this->fallback($pending); + return; + } + $this->traceId ??= (string)TraceId::generate(); + $logs = []; + foreach($pending as $entry) { + $logs []= (new Log($entry["timestamp"], $this->traceId, $this->mapLevel($entry["level"]), $entry["message"])) + ->setAttribute("sentry.environment", $this->client->getOptions()->getEnvironment() ?? Event::DEFAULT_ENVIRONMENT) + ->setAttribute("logger.level", $entry["level"]); + } + if($this->client->captureEvent(Event::createLogs()->setLogs($logs)) === null) { + $this->fallback($pending); + } + } + catch(Throwable) { + $this->fallback($pending); + } + } + + private function mapLevel(string $level):LogLevel { + return match($level) { + "DEBUG" => LogLevel::debug(), + "WARNING" => LogLevel::warn(), + "ERROR" => LogLevel::error(), + "CRITICAL", "ALERT", "EMERGENCY" => LogLevel::fatal(), + default => LogLevel::info(), + }; + } + + /** @param array $entries */ + private function fallback(array $entries):void { + foreach($entries as $entry) { + error_log("WebEngine: Sentry log delivery unavailable: {$entry['level']} {$entry['message']}"); + } + } + + /** @param array $context */ + protected function unwrapContext(array $context):string { + return ""; + } +} diff --git a/src/Debug/SentryReporter.php b/src/Debug/SentryReporter.php index 3e61c91c..9cd9175d 100644 --- a/src/Debug/SentryReporter.php +++ b/src/Debug/SentryReporter.php @@ -77,4 +77,8 @@ public function report(Throwable $throwable):void { error_log("WebEngine: Sentry exception reporting failed."); } } + + public function connectLogHandler(SentryLogHandler $handler):void { + $handler->setClient($this->client); + } } diff --git a/test/phpunit/ApplicationTest.php b/test/phpunit/ApplicationTest.php index f0865794..db3ceeed 100644 --- a/test/phpunit/ApplicationTest.php +++ b/test/phpunit/ApplicationTest.php @@ -31,6 +31,120 @@ use PHPUnit\Framework\TestCase; class ApplicationTest extends TestCase { + /** @return array */ + public static function destinationLevels():array { + return [ + "shared" => ["sentry,stdout", "WaRnInG", "WARNING", "WARNING"], + "individual" => ["sentry,stdout", "error,debug", "ERROR", "DEBUG"], + "reverse order" => ["stdout,sentry", "debug,error", "ERROR", "DEBUG"], + "mixed case and whitespace" => [" SENTRY , stdout ", " eRrOr , InFo ", "ERROR", "INFO"], + "local above stderr split" => ["sentry,stdout", "debug,critical", "DEBUG", "CRITICAL"], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider("destinationLevels")] + public function testDestinationSpecificLevels(string $types, string $levels, string $sentryMinimum, string $localMinimum):void { + $this->resetApplicationLoggerState(); + new Application(config: $this->createTestConfig([ + "logger.type" => $types, + "logger.level" => $levels, + "logger.stderr_level" => " eRrOr ", + ])); + foreach(\GT\Logger\LogLevel::ALL_LEVELS as $index => $level) { + $handlers = LogConfig::getHandlers($level); + $sentry = array_filter($handlers, fn($handler):bool => $handler instanceof \GT\WebEngine\Debug\SentryLogHandler); + $stdout = array_filter($handlers, fn($handler):bool => $handler instanceof \GT\Logger\LogHandler\StdOutHandler); + $stderr = array_filter($handlers, fn($handler):bool => $handler instanceof \GT\Logger\LogHandler\StdErrHandler); + self::assertCount($index >= array_search($sentryMinimum, \GT\Logger\LogLevel::ALL_LEVELS) ? 1 : 0, $sentry, $level); + $localEnabled = $index >= array_search($localMinimum, \GT\Logger\LogLevel::ALL_LEVELS); + self::assertCount($localEnabled && $index < 4 ? 1 : 0, $stdout, $level); + self::assertCount($localEnabled && $index >= 4 ? 1 : 0, $stderr, $level); + } + } + + /** @return array */ + public static function mismatchedLevels():array { + return [ + ["sentry", "error,debug"], + ["sentry,stdout", "error,debug,info"], + ["sentry,stdout,sentry", "error,debug"], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider("mismatchedLevels")] + public function testMismatchedLevelCountsThrowEvenAfterLoggerInitialization(string $types, string $levels):void { + $this->resetApplicationLoggerState(); + new Application(config: $this->createTestConfig([])); + $this->expectException(\GT\WebEngine\Debug\LoggerConfigurationException::class); + $this->expectExceptionMessage("one shared level or one level per logger.type destination"); + new Application(config: $this->createTestConfig([ + "logger.type" => $types, + "logger.level" => $levels, + ])); + } + + public function testStartConnectsAndFlushesSentryLogsAtSharedThreshold():void { + $this->resetApplicationLoggerState(); + $client = self::createMock(\Sentry\ClientInterface::class); + $client->method("getOptions")->willReturn(new \Sentry\Options()); + $client->expects(self::once())->method("captureEvent")->willReturnCallback( + function(\Sentry\Event $event):\Sentry\EventId { + self::assertCount(1, $event->getLogs()); + self::assertSame("Report this", $event->getLogs()[0]->getBody()); + return $event->getId(); + }, + ); + $dispatcher = self::createMock(Dispatcher::class); + $dispatcher->expects(self::once())->method("generateResponse")->willReturnCallback(function():Response { + \GT\Logger\Log::warning("Do not report this"); + \GT\Logger\Log::error("Report this"); + return $this->createResponse(); + }); + $factory = self::createStub(DispatcherFactory::class); + $factory->method("create")->willReturn($dispatcher); + $sut = new Application( + config: $this->createTestConfig(["logger.type" => "sentry", "logger.level" => "ERROR"]), + requestFactory: $this->createRequestFactory(), + dispatcherFactory: $factory, + globalProtection: self::createStub(Protection::class), + ); + $this->setPrivateProperty($sut, "sentryReporter", new SentryReporter($client, $this->createServerRequest("/"))); + $sut->start(); + } + + public function testSentryOnlyDestinationUsesSharedLevel():void { + $this->resetApplicationLoggerState(); + new Application(config: $this->createTestConfig([ + "logger.type" => "sentry", + "logger.level" => "error", + ])); + foreach(["DEBUG", "INFO", "NOTICE", "WARNING"] as $level) { + self::assertSame([], LogConfig::getHandlers($level)); + } + foreach(["ERROR", "CRITICAL", "ALERT", "EMERGENCY"] as $level) { + $handlers = LogConfig::getHandlers($level); + self::assertCount(1, $handlers); + self::assertInstanceOf(\GT\WebEngine\Debug\SentryLogHandler::class, $handlers[0]); + } + } + + public function testCombinedDestinationsUseSharedLevel():void { + $this->resetApplicationLoggerState(); + new Application(config: $this->createTestConfig([ + "logger.type" => " SENTRY , stdout , sentry ", + "logger.level" => "warning", + ])); + self::assertSame([], LogConfig::getHandlers("INFO")); + $handlers = LogConfig::getHandlers("WARNING"); + self::assertCount(2, $handlers); + self::assertInstanceOf(\GT\WebEngine\Debug\SentryLogHandler::class, $handlers[0]); + self::assertInstanceOf(\GT\Logger\LogHandler\StdOutHandler::class, $handlers[1]); + $handlers = LogConfig::getHandlers("ERROR"); + self::assertCount(2, $handlers); + self::assertInstanceOf(\GT\WebEngine\Debug\SentryLogHandler::class, $handlers[0]); + self::assertInstanceOf(\GT\Logger\LogHandler\StdErrHandler::class, $handlers[1]); + } + protected function tearDown():void { $this->resetApplicationLoggerState(); parent::tearDown(); @@ -41,6 +155,7 @@ public function testDefaultConfig_usesHtmlRoutingAndDoesNotLogNotModifiedRespons $config = parse_ini_file($configFile, true, INI_SCANNER_RAW); self::assertSame("text/html", $config["router"]["default_content_type"]); + self::assertSame("error", $config["logger"]["stderr_level"]); self::assertSame("false", $config["logger"]["log_not_modified"]); self::assertStringContainsString("password", $config["logger"]["ignore_post_fields"]); self::assertStringContainsString("pass", $config["logger"]["ignore_post_fields"]); diff --git a/test/phpunit/Debug/SentryLogHandlerTest.php b/test/phpunit/Debug/SentryLogHandlerTest.php new file mode 100644 index 00000000..b3c6d455 --- /dev/null +++ b/test/phpunit/Debug/SentryLogHandlerTest.php @@ -0,0 +1,99 @@ + "https://key@example.com/1", + "environment" => "staging", + "default_integrations" => false, + ]); + $transport = self::createMock(TransportInterface::class); + $transport->expects(self::once())->method("send")->willReturnCallback( + function(Event $event) use (&$payload, $options):Result { + $payload = (new PayloadSerializer($options))->serialize($event); + return new Result(ResultStatus::success(), $event); + }, + ); + $client = (new ClientBuilder($options))->setTransport($transport)->getClient(); + $handler = new SentryLogHandler(); + $handler->setClient($client); + $original = []; + foreach(Protection::GLOBAL_KEYS as $key) { + $original[$key] = $GLOBALS[$key] ?? null; + } + try { + (new Protection())->overrideInternals([]); + foreach(["DEBUG", "INFO", "NOTICE", "WARNING", "ERROR", "CRITICAL", "ALERT", "EMERGENCY"] as $level) { + $handler->handle($level, "Message {$level}", ["password" => "secret"]); + } + $handler->flush(); + $handler->flush(); + } + finally { + foreach($original as $key => $value) { + if($value === null) { + unset($GLOBALS[$key]); + } + else { + $GLOBALS[$key] = $value; + } + } + } + $lines = explode("\n", trim($payload)); + $header = json_decode($lines[1], true, flags: JSON_THROW_ON_ERROR); + $body = json_decode($lines[2], true, flags: JSON_THROW_ON_ERROR); + self::assertSame("log", $header["type"]); + self::assertSame(8, $header["item_count"]); + self::assertSame(["debug", "info", "info", "warn", "error", "fatal", "fatal", "fatal"], array_column($body["items"], "level")); + self::assertSame("staging", $body["items"][0]["attributes"]["sentry.environment"]["value"]); + self::assertStringNotContainsString("secret", $payload); + } + + public function testBatchesAreBoundedAndEarlyLogsAreRetained():void { + $client = self::createMock(ClientInterface::class); + $client->method("getOptions")->willReturn(new Options(["environment" => null])); + $counts = []; + $client->expects(self::exactly(2))->method("captureEvent")->willReturnCallback( + function(Event $event) use (&$counts):EventId { + $counts []= count($event->getLogs()); + self::assertSame("production", $event->getLogs()[0]->attributes()->toSimpleArray()["sentry.environment"]); + return $event->getId(); + }, + ); + $handler = new SentryLogHandler(); + $handler->handle("ERROR", "Before initialization"); + $handler->setClient($client); + for($i = 0; $i < 100; $i++) { + $handler->handle("ERROR", "After initialization"); + } + $handler->flush(); + self::assertSame([100, 1], $counts); + } + + public function testDeliveryFailureDoesNotEscapeOrRetry():void { + $client = self::createMock(ClientInterface::class); + $client->method("getOptions")->willReturn(new Options()); + $client->expects(self::once())->method("captureEvent")->willThrowException(new RuntimeException("offline")); + $handler = new SentryLogHandler(); + $handler->setClient($client); + $handler->handle("ERROR", "Test delivery failure"); + $handler->flush(); + $handler->flush(); + } +} From 3167e70b90ad07992558e0010ac6d4c78b738c59 Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Tue, 15 Sep 2026 09:02:56 +0100 Subject: [PATCH 5/5] test: improve test coverage --- test/phpunit/ApplicationTest.php | 20 +++++++++++ test/phpunit/Debug/SentryLogHandlerTest.php | 40 +++++++++++++++++++++ test/phpunit/Debug/SentryReporterTest.php | 35 +++++++++++++++++- 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/test/phpunit/ApplicationTest.php b/test/phpunit/ApplicationTest.php index db3ceeed..63d9ced1 100644 --- a/test/phpunit/ApplicationTest.php +++ b/test/phpunit/ApplicationTest.php @@ -39,6 +39,8 @@ public static function destinationLevels():array { "reverse order" => ["stdout,sentry", "debug,error", "ERROR", "DEBUG"], "mixed case and whitespace" => [" SENTRY , stdout ", " eRrOr , InFo ", "ERROR", "INFO"], "local above stderr split" => ["sentry,stdout", "debug,critical", "DEBUG", "CRITICAL"], + "duplicate keeps lower threshold" => ["sentry,stdout,sentry", "warning,debug,error", "WARNING", "DEBUG"], + "duplicate lowers threshold" => ["sentry,stdout,sentry", "error,debug,warning", "WARNING", "DEBUG"], ]; } @@ -145,6 +147,24 @@ public function testCombinedDestinationsUseSharedLevel():void { self::assertInstanceOf(\GT\Logger\LogHandler\StdErrHandler::class, $handlers[1]); } + public function testShutdownDoesNotReportNonFatalErrors():void { + $sut = new Application(config: $this->createTestConfig([])); + $reporter = self::createMock(SentryReporter::class); + $reporter->expects(self::never())->method("report"); + $this->setPrivateProperty($sut, "sentryReporter", $reporter); + error_clear_last(); + try { + $this->invokePrivateMethod($sut, "handleShutdown"); + // A native warning records error_get_last() without terminating PHP. + @file_get_contents(__FILE__ . "/missing"); + self::assertSame(E_WARNING, error_get_last()["type"]); + $this->invokePrivateMethod($sut, "handleShutdown"); + } + finally { + error_clear_last(); + } + } + protected function tearDown():void { $this->resetApplicationLoggerState(); parent::tearDown(); diff --git a/test/phpunit/Debug/SentryLogHandlerTest.php b/test/phpunit/Debug/SentryLogHandlerTest.php index b3c6d455..0904f9c8 100644 --- a/test/phpunit/Debug/SentryLogHandlerTest.php +++ b/test/phpunit/Debug/SentryLogHandlerTest.php @@ -16,6 +16,46 @@ use RuntimeException; class SentryLogHandlerTest extends TestCase { + #[\PHPUnit\Framework\Attributes\DataProvider("unavailableClients")] + public function testUnavailableDeliveryFallsBackOnceWithoutContext(string $failure):void { + $handler = new SentryLogHandler(); + if($failure !== "missing client") { + $client = self::createMock(ClientInterface::class); + $client->method("getOptions")->willReturn(new Options()); + $capture = $client->expects(self::once())->method("captureEvent"); + if($failure === "exception") { + $capture->willThrowException(new RuntimeException("offline")); + } + else { + $capture->willReturn(null); + } + $handler->setClient($client); + } + $logFile = tempnam(sys_get_temp_dir(), "sentry-fallback-"); + $originalLog = ini_set("error_log", $logFile); + try { + $handler->handle("error", "Fallback message", ["password" => "secret"]); + $handler->flush(); + $handler->flush(); + $log = file_get_contents($logFile); + self::assertSame(1, substr_count($log, "WebEngine: Sentry log delivery unavailable: ERROR Fallback message")); + self::assertStringNotContainsString("secret", $log); + } + finally { + ini_set("error_log", $originalLog); + unlink($logFile); + } + } + + /** @return array */ + public static function unavailableClients():array { + return [ + "missing client" => ["missing client"], + "rejected event" => ["rejected event"], + "exception" => ["exception"], + ]; + } + public function testRealSdkSendsStructuredLogsWithProtectedGlobals():void { $payload = ""; $options = new Options([ diff --git a/test/phpunit/Debug/SentryReporterTest.php b/test/phpunit/Debug/SentryReporterTest.php index 9f741bc8..ff1c003d 100644 --- a/test/phpunit/Debug/SentryReporterTest.php +++ b/test/phpunit/Debug/SentryReporterTest.php @@ -56,6 +56,20 @@ public function testMissingDsnDoesNotInitialiseSdk():void { self::assertNull(SentryReporter::create($this->config(""), new ServerRequest("GET", "/"))); } + #[DataProvider("disabledDsns")] + public function testSdkDisabledDsnDoesNotCreateReporter(string $dsn):void { + self::assertNull(SentryReporter::create($this->config($dsn), new ServerRequest("GET", "/"))); + } + + /** @return array */ + public static function disabledDsns():array { + return [ + "false" => ["false"], + "null" => ["null"], + "empty" => ["empty"], + ]; + } + public function testReportsOriginalThrowableOnceAndSkipsClientErrors():void { $error = new RuntimeException("Test exception"); $client = self::createMock(ClientInterface::class); @@ -66,10 +80,29 @@ public function testReportsOriginalThrowableOnceAndSkipsClientErrors():void { $reporter->report(new HttpNotFound()); } - public function testInitialisationFailureDoesNotEscape():void { + public function testInvalidDsnDoesNotCreateReporter():void { self::assertNull(SentryReporter::create($this->config("invalid"), new ServerRequest("GET", "/"))); } + public function testInitialisationFailureDoesNotEscape():void { + $config = self::createStub(Config::class); + $config->method("getString")->willReturnCallback( + fn(string $key):string => $key === "sentry.dsn" + ? "https://key@example.com/1" + : throw new RuntimeException("Environment configuration unavailable"), + ); + $logFile = tempnam(sys_get_temp_dir(), "sentry-initialisation-"); + $originalLog = ini_set("error_log", $logFile); + try { + self::assertNull(SentryReporter::create($config, new ServerRequest("GET", "/"))); + self::assertStringContainsString("WebEngine: Sentry initialization failed.", file_get_contents($logFile)); + } + finally { + ini_set("error_log", $originalLog); + unlink($logFile); + } + } + public function testCaptureFailureDoesNotEscape():void { $client = self::createMock(ClientInterface::class); $client->expects(self::once())->method("captureException")->willThrowException(new RuntimeException("offline"));